diff options
23 files changed, 781 insertions, 199 deletions
@@ -87,8 +87,10 @@ history # Data directory (calendar sync output, API rest files) /data/ -# Network Security Manager runtime database (cert fingerprints, written by Emacs) +# Network Security Manager runtime database (cert fingerprints, written by Emacs; +# network-security.data before Emacs 31, network-security.eld since) /network-security.data +/network-security.eld # --- elisp ruleset --- *.eln @@ -20,7 +20,7 @@ # make telega-image - Build the telega-server container image # make profile - Profile Emacs startup performance # make clean - Remove test artifacts and compiled files -# make clean-compiled - Remove .elc/.eln files only +# make clean-compiled - Remove .elc/.eln files only (keeps elpa/) # make clean-tests - Remove test artifacts only # make reset - Reset to first launch (destructive!) @@ -106,7 +106,7 @@ help: @echo " make telega-image - Build the telega-server container image" @echo " make profile - Profile Emacs startup performance" @echo " make clean - Remove test artifacts and compiled files" - @echo " make clean-compiled - Remove .elc/.eln files only" + @echo " make clean-compiled - Remove .elc/.eln files only (keeps elpa/)" @echo " make clean-tests - Remove test artifacts only" @echo " make reset - Reset to first launch (DESTRUCTIVE!)" @echo "" @@ -494,9 +494,13 @@ task-sorted: clean: clean-tests clean-compiled @echo "✓ Clean complete" +# elpa/ is excluded: package.el owns that bytecode and nothing here rebuilds it. +# Without it every package loads from source, slower and with no native-comp +# JIT, and Emacs 31 warns about each file lacking a lexical-binding cookie. clean-compiled: @echo "Removing compiled files (.elc, .eln)..." - @find $(EMACS_HOME) -type f \( -name "*.eln" -o -name "*.elc" \) -delete + @find "$(EMACS_HOME)" -type f \( -name "*.eln" -o -name "*.elc" \) \ + -not -path "$(patsubst %/,%,$(EMACS_HOME))/elpa/*" -delete @echo "✓ Compiled files removed" clean-tests: diff --git a/docs/specs/keybinding-console-safety-spec.org b/docs/specs/keybinding-console-safety-spec.org index 5fd7d52c..9781b43a 100644 --- a/docs/specs/keybinding-console-safety-spec.org +++ b/docs/specs/keybinding-console-safety-spec.org @@ -846,7 +846,7 @@ have large package-managed binding lists not enumerated here. - [ ] M-D (dirvish-mode-map) — dwim-shell-commands-menu — (dwim-shell-config.el:934) - [ ] + (dirvish-mode-map) — cj/music-add-dired-selection — (music-config.el:597) - [ ] T (dired/dirvish-mode-map) — cj/transcribe-media-at-point — (transcription-config.el:463/467) - - [ ] <f11> (dirvish-mode-map) — dirvish-side — (dirvish-config.el:481) + - [ ] <f9> (global) — dirvish-side — (dirvish-config.el:661; F11 is stock toggle-frame-fullscreen again as of 2026-09-22) - [ ] Shell / terminal - [ ] C-r (eshell-mode-map) — cj/eshell-history-search — (eshell-config.el:202) - [ ] <up> (eshell-hist-mode-map) — previous-line — (eshell-config.el:99) diff --git a/modules/config-utilities.el b/modules/config-utilities.el index 4332f407..62fc29d0 100644 --- a/modules/config-utilities.el +++ b/modules/config-utilities.el @@ -196,27 +196,27 @@ Returns the count of files deleted." count user-emacs-directory))) (keymap-set cj/debug-config-keymap "c d" 'cj/delete-emacs-home-compiled-files) -(defun cj/compile-this-elisp-buffer () - "Compile the current .el: prefer native (.eln), else .elc. Message if neither." - (interactive) - (unless (and buffer-file-name (string-match-p "\\.el\\'" buffer-file-name)) - (user-error "Not visiting a .el file")) - (save-buffer) - (let ((file buffer-file-name)) +(defun cj/--compile-elisp-file (file &optional available-p) + "Compile FILE: prefer async native, then sync native, then byte-compile. +AVAILABLE-P decides which compilers exist; it defaults to `fboundp'. It is +a parameter so tests can force each branch without redefining `fboundp': +an `fset' on that subr pulls in comp-run and bytecomp, whose own `defun' +of `byte-compile-file' then lands on top of any test double." + (let ((available-p (or available-p #'fboundp))) (cond ;; Native compilation (async preferred) - ((fboundp 'native-compile-async) + ((funcall available-p 'native-compile-async) (native-compile-async file) (message "Queued native compilation for %s" file)) ;; Native compilation (sync, if async not available) - ((fboundp 'native-compile) + ((funcall available-p 'native-compile) (condition-case err (progn (native-compile file) (message "Native-compiled %s" file)) (error (message "Native compile failed: %s" (error-message-string err))))) ;; Byte-compile fallback - ((fboundp 'byte-compile-file) + ((funcall available-p 'byte-compile-file) (let ((out (byte-compile-file file))) (if out (message "Byte-compiled -> %s" out) @@ -224,6 +224,14 @@ Returns the count of files deleted." ;; Neither facility available (t (message "No compilation available (no native-compile, no byte-compile)"))))) + +(defun cj/compile-this-elisp-buffer () + "Compile the current .el: prefer native (.eln), else .elc. Message if neither." + (interactive) + (unless (and buffer-file-name (string-match-p "\\.el\\'" buffer-file-name)) + (user-error "Not visiting a .el file")) + (save-buffer) + (cj/--compile-elisp-file buffer-file-name)) (keymap-set cj/debug-config-keymap "c ." 'cj/compile-this-elisp-buffer) ;; --------------------------- Information Reporting --------------------------- diff --git a/modules/dirvish-config.el b/modules/dirvish-config.el index 6c849198..e9b9fde6 100644 --- a/modules/dirvish-config.el +++ b/modules/dirvish-config.el @@ -29,7 +29,7 @@ ;; - S: Study — start an org-drill session on the .org file at point ;; - M-D (Meta-Shift-d): DWIM shell commands menu ;; - TAB: Toggle subtree expansion -;; - F11: Toggle sidebar view +;; - F9: Toggle sidebar view ;;; Code: @@ -653,7 +653,13 @@ no popup frame is live." (("C-x d" . dirvish) ("C-x C-d" . dirvish) ("C-x D" . dirvish) - ("<f11>" . dirvish-side) + ;; F9, not F11: F11 is stock Emacs's toggle-frame-fullscreen, the same key + ;; every other application uses, and the sidebar sat on it for years by + ;; shadowing that. F9 is the free bare F-key: ai-term left it for M-SPC + ;; in June 2026 and the recording chord came off it on 2026-09-22. F2 is + ;; the preview key (markdown-mode binds it) and F5 is reserved for the + ;; debug backend, so neither was a candidate. + ("<f9>" . dirvish-side) :map dirvish-mode-map ("bg" . cj/set-wallpaper) ("/" . dirvish-narrow) diff --git a/modules/elfeed-config.el b/modules/elfeed-config.el index f4feef3a..34e90eb1 100644 --- a/modules/elfeed-config.el +++ b/modules/elfeed-config.el @@ -28,6 +28,11 @@ (require 'user-constants) (require 'system-lib) (require 'media-utils) +;; elfeed.el ends with a bare `(unless byte-compile-root-dir ...)'. That +;; variable lives in bytecomp, so loading elfeed (source or .elc) in a +;; session that hasn't loaded bytecomp signals void-variable. I load it here, ahead of +;; any elfeed load, rather than rely on something else having pulled it in. +(require 'bytecomp) (declare-function elfeed "elfeed") (declare-function elfeed-update "elfeed") diff --git a/modules/org-capture-config.el b/modules/org-capture-config.el index b7250f1e..8f5903fe 100644 --- a/modules/org-capture-config.el +++ b/modules/org-capture-config.el @@ -145,12 +145,16 @@ re-scanning large target files after the first successful lookup." :around #'cj/org-capture--set-target-location-advice)) ;; --------------------------- Capture F-Key Guard ----------------------------- -;; The global popup keys (F1 dashboard sweep, F10 music, F11 dirvish-side, +;; The global popup keys (F1 dashboard sweep, F9 dirvish-side, F10 music, ;; F12 terminal, M-SPC agent swap) fire even while a capture is in progress ;; and pop their UI over the capture popup. org-capture-mode is a minor ;; mode active exactly for the capture's duration and its keymap shadows ;; the global map, so blocking the keys there scopes the guard precisely: ;; the moment the capture finalizes or aborts, the keys work again. +;; +;; The list follows the command, not the key. F11 was on it while it held +;; dirvish-side; now that F11 is the frame fullscreen toggle it pops nothing +;; over a capture and stays usable. (defun cj/--org-capture-blocked-key () "Refuse a global popup key while a capture is in progress." @@ -160,7 +164,7 @@ re-scanning large target files after the first successful lookup." (defvar org-capture-mode-map) (with-eval-after-load 'org-capture - (dolist (key '("<f1>" "<f10>" "<f11>" "<f12>" "M-SPC")) + (dolist (key '("<f1>" "<f9>" "<f10>" "<f12>" "M-SPC")) (keymap-set org-capture-mode-map key #'cj/--org-capture-blocked-key))) ;; ----------------------- Project-Aware Capture Target ------------------------ diff --git a/modules/prog-general.el b/modules/prog-general.el index 77ff88a5..e9586a97 100644 --- a/modules/prog-general.el +++ b/modules/prog-general.el @@ -119,9 +119,11 @@ REGEXP must be a string or an rx form." ;; Manages tree-sitter grammars. Install is 'prompt, never t: with t, ;; merely opening a file could trigger a network download and a compiler -;; build mid-edit. Batch/test runs never load treesit-auto (no package -;; init), so they can never install. Fresh-machine bootstrap is the -;; explicit `cj/install-treesit-grammars' command below. +;; build mid-edit. `make test' runs with no package init and so never +;; loads treesit-auto, but a test file that calls `package-initialize' +;; itself does load it, and a tree-sitter mode then prompts for a missing +;; grammar; such tests must skip on `treesit-ready-p'. Fresh-machine +;; bootstrap is the explicit `cj/install-treesit-grammars' command below. (defun cj/treesit-auto-pin-go-revision (recipes) "Pin the Go grammar revision in treesit-auto RECIPES. Return the updated Go recipe, or nil when RECIPES has no Go entry. diff --git a/modules/transcription-config.el b/modules/transcription-config.el index 944063b8..6d7e39b9 100644 --- a/modules/transcription-config.el +++ b/modules/transcription-config.el @@ -33,6 +33,11 @@ ;; - 'assemblyai: Cloud transcription with speaker diarization ;; API key retrieved from authinfo.gpg (machine api.assemblyai.com) ;; - 'local-whisper: Local transcription (requires whisper installed) +;; - 'ratio: Self-hosted whisper plus speaker diarization on my transcription +;; host, via scripts/ratio-transcribe (ssh queue; falls back to a local +;; worker when the host is unreachable). No API key. SPEAKERS=N in the +;; environment pins the diarizer's speaker count; 1 is right for a +;; recording that holds only one side of a call. ;; ;; NOTIFICATIONS: ;; - "Transcription started on <file>" @@ -56,7 +61,8 @@ "Transcription backend to use. - `openai-api': Fast cloud transcription via OpenAI API - `assemblyai': Cloud transcription with speaker diarization via AssemblyAI -- `local-whisper': Local transcription using installed Whisper") +- `local-whisper': Local transcription using installed Whisper +- `ratio': Self-hosted whisper plus diarization on my transcription host") (defvar cj/transcription-keep-log-when-done nil "Whether to keep log files after successful transcription. @@ -74,9 +80,11 @@ Status: running, complete, error") (defconst cj/--transcription-backends '((openai-api :script "oai-transcribe" :auth-host "api.openai.com" :env-var "OPENAI_API_KEY") (assemblyai :script "assemblyai-transcribe" :auth-host "api.assemblyai.com" :env-var "ASSEMBLYAI_API_KEY") - (local-whisper :script "local-whisper" :auth-host nil :env-var nil)) + (local-whisper :script "local-whisper" :auth-host nil :env-var nil) + (ratio :script "ratio-transcribe" :auth-host nil :env-var nil)) "Per-backend descriptors. Each entry: (SYMBOL :script S :auth-host H :env-var V). -`:auth-host' and `:env-var' are nil for local backends that need no API key.") +`:auth-host' and `:env-var' are nil for backends that need no API key: the +local whisper install, and the self-hosted ratio queue reached over ssh.") (defun cj/--backend-plist (backend) "Return the descriptor plist for BACKEND, or signal if unknown." @@ -470,9 +478,11 @@ duration is computed from START-TIME." "Switch transcription backend. Prompts with completing-read to select from available backends." (interactive) - (let* ((backends '(("assemblyai" . assemblyai) - ("openai-api" . openai-api) - ("local-whisper" . local-whisper))) + ;; Offer exactly the descriptor set. This used to carry its own list, so + ;; a backend added to the descriptors was reachable only by setq. + (let* ((backends (mapcar (lambda (entry) + (cons (symbol-name (car entry)) (car entry))) + cj/--transcription-backends)) (current (symbol-name cj/transcribe-backend)) (prompt (format "Transcription backend (current: %s): " current)) (choice (completing-read prompt backends nil t)) diff --git a/modules/video-audio-recording.el b/modules/video-audio-recording.el index 65a8612f..8fd3d23b 100644 --- a/modules/video-audio-recording.el +++ b/modules/video-audio-recording.el @@ -300,40 +300,13 @@ Changes take effect on the next recording (not the current one)." (cj/register-prefix-map "r" cj/record-map) -;; Fast chords for the two toggles, alongside C-; r v and C-; r a. F9 is free: -;; ai-term vacated the F9 family when its swap moved to M-SPC, and -;; `test-ai-term-f9-family-removed-globally' keeps it vacated. Both commands -;; still take a prefix argument, so C-u F9 prompts for the recording location. -(keymap-global-set "<f9>" #'cj/video-recording-toggle) -(keymap-global-set "S-<f9>" #'cj/audio-recording-toggle) - -(defvar eat-mode-map) -(defvar eat-semi-char-mode-map) -(defvar eat-char-mode-map) -(defvar eat-eshell-char-mode-map) - -;; EAT builds each input mode's keymap from key categories, and which categories -;; a mode claims is what decides whether a chord reaches Emacs at all. -;; -;; Semi-char mode -- the default, and where every agent buffer sits -- is built -;; from :ascii, :arrow and :navigation. It never claims function keys, so F9 -;; already fell through to the global map. The semi-char entry below is -;; belt-and-braces rather than the fix, which is why it reads as redundant. -;; -;; Char mode is the one that swallows F9. It adds :function, binding f1 through -;; f63 to eat-self-input, and it is a minor mode, so its map outranks -;; eat-mode-map. Without an entry here the pair splits in the worst possible -;; way: :function claims only the unmodified keys, so S-F9 would toggle audio in -;; a char-mode buffer while F9 went to the program under the cursor. That is a -;; recording you believe you started and didn't. -;; -;; Claiming both costs a char-mode program the use of F9. I would rather pay -;; that than ship a toggle that works for audio and silently fails for video. -(with-eval-after-load 'eat - (dolist (map (list eat-semi-char-mode-map eat-mode-map - eat-char-mode-map eat-eshell-char-mode-map)) - (keymap-set map "<f9>" #'cj/video-recording-toggle) - (keymap-set map "S-<f9>" #'cj/audio-recording-toggle))) +;; The toggles live only under C-; r. They had a fast chord (F9 video, S-F9 +;; audio, claimed in every EAT map too) until 2026-09-22: with Fn Lock on the +;; Framework 13 the F-key row sends plain function keys, and a bare F9 press +;; started a screen recording. Both chords went together, because a lone S-F9 +;; on a live F-key row is one fat-finger from an unwanted audio recording. +;; Both commands still take a prefix argument, so C-u C-; r v prompts for the +;; recording location. (with-eval-after-load 'which-key (which-key-add-key-based-replacements diff --git a/scripts/ratio-transcribe b/scripts/ratio-transcribe new file mode 100755 index 00000000..8db59b66 --- /dev/null +++ b/scripts/ratio-transcribe @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# ratio-transcribe - Transcribe audio on my own transcription host, with speaker labels +# Usage: ratio-transcribe <audio-file> [language] +# +# Same contract as assemblyai-transcribe: the transcript goes to stdout, one line +# per speaker turn ("HH:MM:SS Speaker A: text"); progress and errors go to stderr; +# any failure exits non-zero with nothing on stdout. +# +# The work happens on a host that runs the meeting-transcribe queue (whisper-cpp +# plus pyannote). This script copies the audio over ssh, drops a job into the +# queue, waits, and prints the result. The job id is a hash of the audio and its +# options, so if the connection drops or the laptop sleeps, running the same +# command again just collects the finished transcript. If the host can't be +# reached at all, the same queue and worker run on this machine instead. +# +# Optional environment: +# SPEAKERS exact number of speakers, when you know it +# MIN_SPEAKERS, MAX_SPEAKERS a range instead +# TRANSCRIBE_HOST ssh name of the host (default: ratio) +# TRANSCRIBE_TIMEOUT seconds to wait for the job (default: 3600) +# TRANSCRIBE_POLL seconds between checks (default: 10) +# TRANSCRIBE_LOCAL=1 skip the host and run here +# TRANSCRIBE_WORKER path to the local worker + +set -euo pipefail + +AUDIO="${1:-}" +LANG_CODE="${2:-en}" +HOST="${TRANSCRIBE_HOST:-ratio}" +TIMEOUT="${TRANSCRIBE_TIMEOUT:-3600}" +POLL="${TRANSCRIBE_POLL:-10}" +WORKER="${TRANSCRIBE_WORKER:-$HOME/.local/share/pyannote-diarize/src/transcribe-worker}" +STATE=".local/state/meeting-transcribe" # relative to the home directory, on either machine + +if [[ -z "$AUDIO" ]]; then + echo "Usage: ratio-transcribe <audio-file> [language]" >&2 + echo "Example: SPEAKERS=3 ratio-transcribe meeting.m4a en" >&2 + exit 1 +fi + +if [[ ! -f "$AUDIO" ]]; then + echo "Error: Audio file not found: $AUDIO" >&2 + exit 1 +fi +# scp reads "name:with:colons" as host:path; an absolute path removes the ambiguity. +AUDIO="$(realpath -- "$AUDIO")" + +# Everything below ends up in a job file and on command lines, so check it first. +if [[ ! "$LANG_CODE" =~ ^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$ ]]; then + echo "Error: Invalid language code: $LANG_CODE" >&2 + exit 1 +fi + +for name in SPEAKERS MIN_SPEAKERS MAX_SPEAKERS; do + value="${!name:-}" + if [[ -n "$value" && ! "$value" =~ ^[1-9][0-9]*$ ]]; then + echo "Error: $name must be a positive whole number of speakers, got: $value" >&2 + exit 1 + fi +done +if [[ -n "${SPEAKERS:-}" && ( -n "${MIN_SPEAKERS:-}" || -n "${MAX_SPEAKERS:-}" ) ]]; then + echo "Error: give an exact SPEAKERS count or a MIN/MAX speaker range, not both" >&2 + exit 1 +fi +if [[ -n "${MIN_SPEAKERS:-}" && -n "${MAX_SPEAKERS:-}" ]] && (( MIN_SPEAKERS > MAX_SPEAKERS )); then + echo "Error: MIN_SPEAKERS cannot exceed MAX_SPEAKERS (speaker range)" >&2 + exit 1 +fi + +for tool in jq sha256sum; do + if ! command -v "$tool" &> /dev/null; then + echo "Error: $tool command not found" >&2 + exit 1 + fi +done + +EXT="${AUDIO##*.}" +[[ "$EXT" =~ ^[A-Za-z0-9]{1,5}$ ]] || EXT="bin" +EXT="${EXT,,}" + +if [[ -n "${SPEAKERS:-}" ]]; then + COUNT_TAG="s${SPEAKERS}" +elif [[ -n "${MIN_SPEAKERS:-}${MAX_SPEAKERS:-}" ]]; then + COUNT_TAG="r${MIN_SPEAKERS:-x}-${MAX_SPEAKERS:-x}" +else + COUNT_TAG="auto" +fi +JOB_ID="$(sha256sum "$AUDIO" | cut -c1-16)-${LANG_CODE,,}-${COUNT_TAG}" + +JOB_JSON=$(jq -cn \ + --arg language "$LANG_CODE" \ + --arg name "$(basename "$AUDIO")" \ + --arg speakers "${SPEAKERS:-}" --arg min "${MIN_SPEAKERS:-}" --arg max "${MAX_SPEAKERS:-}" \ + '{language: $language} + + (if $speakers != "" then {speakers: ($speakers | tonumber)} else {} end) + + (if $min != "" then {min_speakers: ($min | tonumber)} else {} end) + + (if $max != "" then {max_speakers: ($max | tonumber)} else {} end) + + {original_name: $name}') + +# ssh reads stdin unless told not to, which would swallow the input of any loop +# this script is called from. Only the job-file upload needs stdin. +remote() { ssh -n -o BatchMode=yes -o ConnectTimeout=8 "$HOST" "$@"; } +remote_with_stdin() { ssh -o BatchMode=yes -o ConnectTimeout=8 "$HOST" "$@"; } + +# One word for where the job stands on the host: done, failed, queued or new. +remote_status() { + remote "cd $STATE 2>/dev/null || { echo new; exit 0; } + if [ -e done/$JOB_ID.txt ]; then echo done + elif [ -e failed/$JOB_ID.log ]; then echo failed + elif [ -d incoming/$JOB_ID ] || [ -d work/$JOB_ID ]; then echo queued + else echo new; fi" +} + +print_transcript() { # $1 = the transcript text + if [[ -z "${1//[[:space:]]/}" ]]; then + echo "Error: the transcript came back empty" >&2 + exit 1 + fi + echo "Transcription complete! (${SECONDS}s total)" >&2 + printf '%s\n' "$1" +} + +run_remote() { + local status + status=$(remote_status) + + if [[ "$status" == "failed" ]]; then + echo "An earlier attempt at this job failed; trying again..." >&2 + remote "rm -f $STATE/failed/$JOB_ID.log" + status="new" + fi + + if [[ "$status" == "new" ]]; then + echo "Uploading audio file to $HOST..." >&2 + # Copy into uploading/, then rename into incoming/. The queue only ever sees + # a complete job. + remote "mkdir -p $STATE/incoming $STATE/uploading/$JOB_ID" + scp -q -o BatchMode=yes "$AUDIO" "$HOST:$STATE/uploading/$JOB_ID/audio.$EXT" < /dev/null + printf '%s' "$JOB_JSON" | remote_with_stdin "cat > $STATE/uploading/$JOB_ID/job.json" + remote "mv $STATE/uploading/$JOB_ID $STATE/incoming/$JOB_ID" + echo "Job $JOB_ID queued. Waiting for completion..." >&2 + elif [[ "$status" == "queued" ]]; then + echo "Job $JOB_ID is already queued on $HOST. Waiting for completion..." >&2 + fi + + while true; do + # A dropped connection is not a failed job; keep asking until the timeout. + status=$(remote_status 2> /dev/null) || status="unreachable" + case "$status" in + done) + print_transcript "$(remote "cat $STATE/done/$JOB_ID.txt")" + return 0 + ;; + failed) + echo "Error: transcription failed on $HOST" >&2 + remote "cat $STATE/failed/$JOB_ID.log" >&2 || true + exit 1 + ;; + esac + if (( SECONDS >= TIMEOUT )); then + echo "Error: no result after ${TIMEOUT}s. The job is still with $HOST;" >&2 + echo "run the same command again to collect the transcript." >&2 + exit 1 + fi + sleep "$POLL" + [[ "$status" == "unreachable" ]] || echo "Processing... (${SECONDS}s elapsed)" >&2 + done +} + +run_local() { + if [[ ! -x "$WORKER" ]]; then + echo "Error: $HOST is unreachable and there is no local worker at $WORKER" >&2 + exit 1 + fi + local state="$HOME/$STATE" + if [[ ! -s "$state/done/$JOB_ID.txt" ]]; then + echo "Running the transcription locally (this machine is slower; expect a wait)..." >&2 + rm -f "$state/failed/$JOB_ID.log" + rm -rf "$state/uploading/$JOB_ID" + mkdir -p "$state/incoming" "$state/uploading/$JOB_ID" + cp "$AUDIO" "$state/uploading/$JOB_ID/audio.$EXT" + printf '%s' "$JOB_JSON" > "$state/uploading/$JOB_ID/job.json" + [[ -d "$state/incoming/$JOB_ID" ]] || mv "$state/uploading/$JOB_ID" "$state/incoming/$JOB_ID" + HF_HUB_OFFLINE=1 "$WORKER" >&2 < /dev/null + fi + if [[ -e "$state/failed/$JOB_ID.log" ]]; then + echo "Error: local transcription failed" >&2 + cat "$state/failed/$JOB_ID.log" >&2 + exit 1 + fi + if [[ ! -e "$state/done/$JOB_ID.txt" ]]; then + echo "Error: the local worker finished without producing a transcript" >&2 + exit 1 + fi + print_transcript "$(< "$state/done/$JOB_ID.txt")" +} + +if [[ -z "${TRANSCRIBE_LOCAL:-}" ]] && remote true 2> /dev/null; then + run_remote +else + [[ -n "${TRANSCRIBE_LOCAL:-}" ]] || echo "$HOST is unreachable." >&2 + run_local +fi diff --git a/tests/test-agenda-query--render.el b/tests/test-agenda-query--render.el index 48d28773..da5c3f60 100644 --- a/tests/test-agenda-query--render.el +++ b/tests/test-agenda-query--render.el @@ -162,10 +162,17 @@ still parses as JSON, which is the worst kind." Pinning the failure mode, not endorsing it. This is what the surface showed before the batch writer learned the vocabulary, and it is why the test above exists." + ;; No priority cookie in this fixture. org 9.8 (Emacs 31.1) parses the + ;; cookie with `org-priority-regexp' under `looking-at', and that regexp's + ;; lazy `.*?' prefix swallows everything between the stars and the cookie, + ;; unknown keyword included. A cookie here would test org's bug rather than + ;; the vocabulary gap this test pins. (let ((org-todo-keywords '((sequence "TODO" "|" "DONE")))) (test-aq-render--with-agenda-file - "* DOING [#A] Justin Johns advisor projects\nSCHEDULED: <2026-07-31 Fri 09:00>\n" - (should (string-prefix-p "DOING" (alist-get 't (car rows))))))) + "* DOING Justin Johns advisor projects\nSCHEDULED: <2026-07-31 Fri 09:00>\n" + (let ((row (car rows))) + (should (string-prefix-p "DOING" (alist-get 't row))) + (should-not (equal "DOING" (alist-get 'keyword row))))))) ;;; ---------- the cache writer ---------- diff --git a/tests/test-calibredb-epub-config.el b/tests/test-calibredb-epub-config.el index 7afc58f3..0e430a4e 100644 --- a/tests/test-calibredb-epub-config.el +++ b/tests/test-calibredb-epub-config.el @@ -16,6 +16,11 @@ (package-initialize) (add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) (require 'calibredb-epub-config) +;; Load calibredb before any test stubs its functions with `cl-letf'. The +;; module's jump path calls `(require 'calibredb)' inside the body; if the +;; package is still an autoload at that point, the real `defun' lands on top of +;; the stub and the test runs the real command against the real library. +(require 'calibredb) (require 'nov nil t) ; for the nov-mode-map keybinding test; harmless if absent (declare-function cj/nov--text-width "calibredb-epub-config" (total-cols)) diff --git a/tests/test-config-utilities--compile-this-elisp-buffer.el b/tests/test-config-utilities--compile-this-elisp-buffer.el index a06440ab..f1a442b4 100644 --- a/tests/test-config-utilities--compile-this-elisp-buffer.el +++ b/tests/test-config-utilities--compile-this-elisp-buffer.el @@ -1,10 +1,14 @@ ;;; test-config-utilities--compile-this-elisp-buffer.el --- Tests for cj/compile-this-elisp-buffer -*- lexical-binding: t; -*- ;;; Commentary: -;; Tests for `cj/compile-this-elisp-buffer'. The function dispatches -;; among native-compile-async, native-compile (sync), and -;; byte-compile-file based on which is fboundp. Tests force each -;; branch by mocking fboundp at the boundary. +;; Tests for `cj/compile-this-elisp-buffer' and its helper +;; `cj/--compile-elisp-file'. The helper dispatches among +;; native-compile-async, native-compile (sync), and byte-compile-file based +;; on an AVAILABLE-P predicate that defaults to `fboundp'. Tests force each +;; branch by passing the predicate, never by redefining `fboundp': an `fset' +;; on that subr autoloads comp-run, which requires bytecomp, whose `defun' of +;; `byte-compile-file' replaces any test double installed earlier in the same +;; `cl-letf' (Emacs 30.2 hid this because ert happened to preload bytecomp). ;;; Code: @@ -14,6 +18,10 @@ (add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) (require 'config-utilities) +(defun test-config-utilities--available (&rest syms) + "Return a predicate that reports only SYMS as available compilers." + (lambda (sym) (memq sym syms))) + (defmacro test-config-utilities--with-elisp-buffer (path &rest body) "Run BODY in a temp buffer visiting PATH (a .el file path). Skips the interactive `save-buffer' so tests stay free of disk side @@ -24,72 +32,108 @@ effects." (cl-letf (((symbol-function 'save-buffer) (lambda (&rest _) nil))) ,@body))) +;; -- the interactive wrapper ------------------------------------------------- + (ert-deftest test-config-utilities-compile-buffer-not-elisp-raises () "Error: a buffer whose file isn't .el raises `user-error'." (test-config-utilities--with-elisp-buffer "/tmp/not-elisp.txt" (should-error (cj/compile-this-elisp-buffer) :type 'user-error))) -(ert-deftest test-config-utilities-compile-buffer-no-buffer-file-name-raises () - "Error: a buffer with no `buffer-file-name' raises `user-error'." +(ert-deftest test-config-utilities-compile-buffer-no-file-raises () + "Boundary: a buffer visiting no file raises `user-error' rather than +passing nil to the compiler." (with-temp-buffer - (setq buffer-file-name nil) (should-error (cj/compile-this-elisp-buffer) :type 'user-error))) +(ert-deftest test-config-utilities-compile-buffer-saves-then-delegates () + "Normal: the wrapper saves the buffer and hands its file to the helper." + (let (saved compiled) + (with-temp-buffer + (setq buffer-file-name "/tmp/some.el") + (cl-letf (((symbol-function 'save-buffer) (lambda (&rest _) (setq saved t))) + ((symbol-function 'cj/--compile-elisp-file) + (lambda (file &optional _) (setq compiled file)))) + (cj/compile-this-elisp-buffer))) + (should saved) + (should (equal compiled "/tmp/some.el")))) + +;; -- the helper's dispatch --------------------------------------------------- + (ert-deftest test-config-utilities-compile-buffer-prefers-native-async () "Normal: `native-compile-async' is preferred when available." (let (called-with) - (test-config-utilities--with-elisp-buffer "/tmp/some.el" - (cl-letf (((symbol-function 'fboundp) - (lambda (sym) - (memq sym '(native-compile-async native-compile byte-compile-file)))) - ((symbol-function 'native-compile-async) - (lambda (file) (setq called-with file))) - ((symbol-function 'native-compile) - (lambda (_) (error "should not call sync native-compile"))) - ((symbol-function 'byte-compile-file) - (lambda (&rest _) (error "should not call byte-compile-file")))) - (cj/compile-this-elisp-buffer) - (should (equal called-with "/tmp/some.el")))))) + (cl-letf (((symbol-function 'native-compile-async) + (lambda (file) (setq called-with file))) + ((symbol-function 'native-compile) + (lambda (_) (error "should not call sync native-compile"))) + ((symbol-function 'byte-compile-file) + (lambda (&rest _) (error "should not call byte-compile-file")))) + (cj/--compile-elisp-file + "/tmp/some.el" + (test-config-utilities--available 'native-compile-async 'native-compile + 'byte-compile-file)) + (should (equal called-with "/tmp/some.el"))))) (ert-deftest test-config-utilities-compile-buffer-falls-back-to-sync-native () "Normal: `native-compile' is used when async isn't available." (let (called-with) - (test-config-utilities--with-elisp-buffer "/tmp/some.el" - (cl-letf (((symbol-function 'fboundp) - (lambda (sym) (memq sym '(native-compile byte-compile-file)))) - ((symbol-function 'native-compile) - (lambda (file) (setq called-with file))) - ((symbol-function 'byte-compile-file) - (lambda (&rest _) (error "should not call byte-compile-file")))) - (cj/compile-this-elisp-buffer) - (should (equal called-with "/tmp/some.el")))))) + (cl-letf (((symbol-function 'native-compile) + (lambda (file) (setq called-with file))) + ((symbol-function 'byte-compile-file) + (lambda (&rest _) (error "should not call byte-compile-file")))) + (cj/--compile-elisp-file + "/tmp/some.el" + (test-config-utilities--available 'native-compile 'byte-compile-file)) + (should (equal called-with "/tmp/some.el"))))) (ert-deftest test-config-utilities-compile-buffer-falls-back-to-byte-compile () "Normal: `byte-compile-file' is used when neither native option is available." (let (called-with) - (test-config-utilities--with-elisp-buffer "/tmp/some.el" - (cl-letf (((symbol-function 'fboundp) - (lambda (sym) (eq sym 'byte-compile-file))) - ((symbol-function 'byte-compile-file) - (lambda (file &rest _) (setq called-with file) "/tmp/some.elc"))) - (cj/compile-this-elisp-buffer) - (should (equal called-with "/tmp/some.el")))))) + (cl-letf (((symbol-function 'byte-compile-file) + (lambda (file &rest _) (setq called-with file) "/tmp/some.elc"))) + (cj/--compile-elisp-file + "/tmp/some.el" + (test-config-utilities--available 'byte-compile-file)) + (should (equal called-with "/tmp/some.el"))))) + +(ert-deftest test-config-utilities-compile-buffer-reports-when-nothing-available () + "Boundary: with no compiler available the helper only messages, calling none." + (let (captured) + (cl-letf (((symbol-function 'native-compile-async) + (lambda (&rest _) (error "should not call native-compile-async"))) + ((symbol-function 'native-compile) + (lambda (&rest _) (error "should not call native-compile"))) + ((symbol-function 'byte-compile-file) + (lambda (&rest _) (error "should not call byte-compile-file"))) + ((symbol-function 'message) + (lambda (fmt &rest args) (setq captured (apply #'format fmt args))))) + (cj/--compile-elisp-file "/tmp/some.el" (test-config-utilities--available))) + (should (string-match-p "No compilation available" captured)))) (ert-deftest test-config-utilities-compile-buffer-handles-sync-native-error () "Error: a sync `native-compile' that signals is caught and reported. -Asserts no error escapes by running the function and checking that the +Asserts no error escapes by running the helper and checking that the message captured contains the failure prefix." - (test-config-utilities--with-elisp-buffer "/tmp/some.el" - (let (captured) - (cl-letf (((symbol-function 'fboundp) - (lambda (sym) (memq sym '(native-compile byte-compile-file)))) - ((symbol-function 'native-compile) - (lambda (_) (error "boom"))) - ((symbol-function 'message) - (lambda (fmt &rest args) - (setq captured (apply #'format fmt args))))) - (cj/compile-this-elisp-buffer)) - (should (string-match-p "Native compile failed" captured))))) + (let (captured) + (cl-letf (((symbol-function 'native-compile) + (lambda (_) (error "boom"))) + ((symbol-function 'message) + (lambda (fmt &rest args) (setq captured (apply #'format fmt args))))) + (cj/--compile-elisp-file + "/tmp/some.el" + (test-config-utilities--available 'native-compile 'byte-compile-file))) + (should (string-match-p "Native compile failed" captured)))) + +(ert-deftest test-config-utilities-compile-buffer-default-predicate-is-fboundp () + "Normal: with no predicate the helper consults `fboundp', so on a real +Emacs it reaches whichever compiler exists rather than the no-compiler +message." + (let (captured) + (cl-letf (((symbol-function 'native-compile-async) (lambda (&rest _) nil)) + ((symbol-function 'message) + (lambda (fmt &rest args) (setq captured (apply #'format fmt args))))) + (cj/--compile-elisp-file "/tmp/some.el")) + (should (string-match-p "Queued native compilation" captured)))) (provide 'test-config-utilities--compile-this-elisp-buffer) ;;; test-config-utilities--compile-this-elisp-buffer.el ends here diff --git a/tests/test-dirvish-config--side-key.el b/tests/test-dirvish-config--side-key.el new file mode 100644 index 00000000..1f08314c --- /dev/null +++ b/tests/test-dirvish-config--side-key.el @@ -0,0 +1,40 @@ +;;; test-dirvish-config--side-key.el --- dirvish-side lives on F9 -*- lexical-binding: t; -*- + +;;; Commentary: +;; The sidebar toggle moved from F11 to F9 on 2026-09-22 so F11 could go back +;; to `toggle-frame-fullscreen', the stock binding every other application +;; shares and which the :bind had been shadowing. F9 was the free bare +;; F-key: ai-term left it for M-SPC in June and the recording chord came off +;; it the same day this moved. F2 is the preview key and F5 is reserved for +;; the debug backend, so neither was a candidate. + +;;; Code: + +(require 'ert) +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'keybindings) +(require 'dirvish-config) + +;;; Normal + +(ert-deftest test-dirvish-config-side-on-f9 () + "Normal: F9 toggles the dirvish sidebar." + (should (eq (keymap-lookup global-map "<f9>") #'dirvish-side))) + +;;; Boundary + +(ert-deftest test-dirvish-config-side-not-on-f11 () + "Boundary/regression: F11 no longer reaches dirvish-side, so the stock +`toggle-frame-fullscreen' binding is no longer shadowed." + (should-not (eq (keymap-lookup global-map "<f11>") #'dirvish-side)) + (should (eq (keymap-lookup global-map "<f11>") #'toggle-frame-fullscreen))) + +;;; Error + +(ert-deftest test-dirvish-config-side-target-is-a-command () + "Error (positive control): the binding points at a real interactive +command, so a keypress does not fail with a `commandp' error at press time." + (should (commandp (keymap-lookup global-map "<f9>")))) + +(provide 'test-dirvish-config--side-key) +;;; test-dirvish-config--side-key.el ends here diff --git a/tests/test-integration-recurring-events.el b/tests/test-integration-recurring-events.el index 8339d167..44ddfb00 100644 --- a/tests/test-integration-recurring-events.el +++ b/tests/test-integration-recurring-events.el @@ -24,13 +24,22 @@ ;;; Setup and Teardown +(defvar test-integration-recurring-events--saved-tz nil + "The TZ in force before setup pinned it, restored by teardown.") + (defun test-integration-recurring-events-setup () - "Setup for recurring events integration tests." - nil) + "Setup for recurring events integration tests. +Pins TZ to America/Chicago: the fixtures are TZID=America/Chicago and the +assertions expect that zone's local rendering (\"Sat 10:30-11:00\"), so on +any other machine zone the pipeline's correct conversion reads as a failure. +`setenv' on TZ also calls `set-time-zone-rule', which is what the time +functions actually consult." + (setq test-integration-recurring-events--saved-tz (getenv "TZ")) + (setenv "TZ" "America/Chicago")) (defun test-integration-recurring-events-teardown () - "Teardown for recurring events integration tests." - nil) + "Teardown for recurring events integration tests: restore the machine TZ." + (setenv "TZ" test-integration-recurring-events--saved-tz)) ;;; Test Data diff --git a/tests/test-keybindings--fullscreen.el b/tests/test-keybindings--fullscreen.el new file mode 100644 index 00000000..c35e398c --- /dev/null +++ b/tests/test-keybindings--fullscreen.el @@ -0,0 +1,45 @@ +;;; test-keybindings--fullscreen.el --- F11 toggles frame fullscreen -*- lexical-binding: t; -*- + +;;; Commentary: +;; Stock Emacs binds F11 to `toggle-frame-fullscreen', the same key every +;; other application on the desktop uses for it. This config never rebound +;; it; dirvish-config's :bind put dirvish-side on F11 and shadowed the stock +;; binding for as long as that entry existed. It moved to F9 on 2026-09-22 +;; (see test-dirvish-config--side-key.el, which pins the un-shadowing). +;; +;; These tests pin the foundation layer's side of that: keybindings.el must +;; leave the stock F11 binding in place. Only keybindings.el is loaded here +;; on purpose. A feature module that later claimed F11 would fail the +;; dirvish-side regression test, not this one. + +;;; Code: + +(require 'ert) +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'keybindings) + +;;; Normal + +(ert-deftest test-keybindings-f11-toggles-fullscreen () + "Normal: F11 runs `toggle-frame-fullscreen' once the foundation layer has +loaded. Stock Emacs provides the binding; this pins that keybindings.el does +not unset or rebind it." + (should (eq (keymap-lookup global-map "<f11>") #'toggle-frame-fullscreen))) + +;;; Boundary + +(ert-deftest test-keybindings-f11-is-the-only-fullscreen-chord () + "Boundary: the plain key carries it; the shifted chord is left free so a +grazed Shift does not toggle the frame twice." + (should-not (eq (keymap-lookup global-map "S-<f11>") + #'toggle-frame-fullscreen))) + +;;; Error + +(ert-deftest test-keybindings-f11-target-is-a-command () + "Error (positive control): the target is a real interactive command, so a +press does not fail with a `commandp' error at press time." + (should (commandp (keymap-lookup global-map "<f11>")))) + +(provide 'test-keybindings--fullscreen) +;;; test-keybindings--fullscreen.el ends here diff --git a/tests/test-makefile-clean-compiled.el b/tests/test-makefile-clean-compiled.el new file mode 100644 index 00000000..0deb54f3 --- /dev/null +++ b/tests/test-makefile-clean-compiled.el @@ -0,0 +1,121 @@ +;;; test-makefile-clean-compiled.el --- Tests for the clean-compiled make target -*- lexical-binding: t -*- + +;;; Commentary: +;; `make clean-compiled' (and `make clean', which depends on it) removes the +;; config's own compiled output. It must leave elpa/ alone: package.el owns +;; that bytecode, nothing in this repo regenerates it, and without it Emacs +;; loads every package from source -- slower, no native-comp JIT, and on +;; Emacs 31 a "Missing `lexical-binding' cookie" warning per cookie-less file. +;; +;; Each test runs the real target against a throwaway EMACS_HOME. Before the +;; real run, a dry run (make -n) confirms the override reached the recipe, so a +;; broken override can never sweep the developer's live tree. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(defconst test-makefile-cc--repo + (file-name-directory + (directory-file-name + (file-name-directory (or load-file-name buffer-file-name)))) + "Repository root holding the Makefile under test.") + +(defun test-makefile-cc--touch (root rel) + "Create an empty file REL under ROOT, making parent dirs." + (let ((path (expand-file-name rel root))) + (make-directory (file-name-directory path) t) + (write-region "" nil path) + path)) + +(defun test-makefile-cc--make (home &optional dry-run) + "Run the clean-compiled target with EMACS_HOME=HOME. +With DRY-RUN, pass -n. Return (EXIT . OUTPUT)." + (with-temp-buffer + (let* ((process-environment + (cl-remove-if (lambda (e) (string-match-p "\\`MAKE\\(FLAGS\\|LEVEL\\)=" e)) + process-environment)) + (args (append (and dry-run '("-n")) + (list "--no-print-directory" + "-C" test-makefile-cc--repo "clean-compiled" + (concat "EMACS_HOME=" (directory-file-name home))))) + (exit (apply #'call-process "make" nil t nil args))) + (cons exit (buffer-string))))) + +(defmacro test-makefile-cc--with-home (files &rest body) + "Create a temp EMACS_HOME holding FILES, bind it to HOME, run BODY. +Refuses to run the real target unless a dry run shows the override applied." + (declare (indent 1)) + `(let ((home (file-name-as-directory (make-temp-file "test-makefile-cc-" t)))) + (unwind-protect + (progn + (dolist (f ,files) (test-makefile-cc--touch home f)) + (let ((dry (test-makefile-cc--make home t))) + (should (= 0 (car dry))) + (should (string-match-p (regexp-quote (directory-file-name home)) + (cdr dry))) + (should-not (string-match-p + (regexp-quote (expand-file-name "~/.emacs.d")) + (cdr dry)))) + ,@body) + (delete-directory home t)))) + +(defun test-makefile-cc--exists (home rel) + "Return non-nil when REL exists under HOME." + (file-exists-p (expand-file-name rel home))) + +(ert-deftest test-makefile-clean-compiled-keeps-elpa-bytecode () + "Normal: package bytecode under elpa/ survives; the config's own is removed." + (test-makefile-cc--with-home '("modules/foo.elc" + "init.elc" + "elpa/pkg-1.0/pkg.el" + "elpa/pkg-1.0/pkg.elc" + "elpa/pkg-1.0/sub/inner.elc") + (should (= 0 (car (test-makefile-cc--make home)))) + (should (test-makefile-cc--exists home "elpa/pkg-1.0/pkg.elc")) + (should (test-makefile-cc--exists home "elpa/pkg-1.0/sub/inner.elc")) + (should (test-makefile-cc--exists home "elpa/pkg-1.0/pkg.el")) + (should-not (test-makefile-cc--exists home "modules/foo.elc")) + (should-not (test-makefile-cc--exists home "init.elc")))) + +(ert-deftest test-makefile-clean-compiled-still-removes-native-code () + "Normal: .eln outside elpa/ is still removed, as before." + (test-makefile-cc--with-home '("eln-cache/31.1-abc/foo.eln" + "modules/foo.elc") + (should (= 0 (car (test-makefile-cc--make home)))) + (should-not (test-makefile-cc--exists home "eln-cache/31.1-abc/foo.eln")) + (should-not (test-makefile-cc--exists home "modules/foo.elc")))) + +(ert-deftest test-makefile-clean-compiled-no-elpa-dir () + "Boundary: a home with no elpa/ at all still cleans and succeeds." + (test-makefile-cc--with-home '("modules/foo.elc") + (should (= 0 (car (test-makefile-cc--make home)))) + (should-not (test-makefile-cc--exists home "modules/foo.elc")))) + +(ert-deftest test-makefile-clean-compiled-only-top-level-elpa-spared () + "Boundary: only the top-level elpa/ is spared, not look-alike paths." + (test-makefile-cc--with-home '(".elpa-mirror/x.elc" + "tests/fixtures/elpa/y.elc" + "elpa/pkg-1.0/pkg.elc") + (should (= 0 (car (test-makefile-cc--make home)))) + (should-not (test-makefile-cc--exists home ".elpa-mirror/x.elc")) + (should-not (test-makefile-cc--exists home "tests/fixtures/elpa/y.elc")) + (should (test-makefile-cc--exists home "elpa/pkg-1.0/pkg.elc")))) + +(ert-deftest test-makefile-clean-compiled-missing-home-fails () + "Error: a nonexistent EMACS_HOME makes the target fail, not silently pass." + (let* ((parent (make-temp-file "test-makefile-cc-missing-" t)) + (home (expand-file-name "absent/" parent))) + (unwind-protect + (progn + (should-not (file-exists-p home)) + (let ((dry (cdr (test-makefile-cc--make home t)))) + (should (string-match-p (regexp-quote (directory-file-name home)) dry)) + (should-not (string-match-p + (regexp-quote (expand-file-name "~/.emacs.d")) dry))) + (should-not (= 0 (car (test-makefile-cc--make home))))) + (delete-directory parent t)))) + +(provide 'test-makefile-clean-compiled) +;;; test-makefile-clean-compiled.el ends here diff --git a/tests/test-org-capture-config-fkey-guard.el b/tests/test-org-capture-config-fkey-guard.el index b288ce4f..c0687bb2 100644 --- a/tests/test-org-capture-config-fkey-guard.el +++ b/tests/test-org-capture-config-fkey-guard.el @@ -2,8 +2,10 @@ ;;; Commentary: ;; While a capture is in progress, the global popup keys (F1 dashboard -;; sweep, F10 music, F11 dirvish-side, F12 terminal, M-SPC agent swap) -;; must not fire and pop UI over the capture. org-capture-mode is a +;; sweep, F9 dirvish-side, F10 music, F12 terminal, M-SPC agent swap) +;; must not fire and pop UI over the capture. F11 is deliberately not on +;; the list: it toggles frame fullscreen, which pops nothing over the +;; capture, so blocking it would only get in the way. org-capture-mode is a ;; minor mode active exactly for the capture's duration and its keymap ;; shadows the global map, so the guard binds those keys there to a ;; blocker that signals a `user-error' naming the way out. @@ -21,10 +23,17 @@ (ert-deftest test-org-capture-fkey-guard-keys-bound () "Normal: every leaking popup key is bound to the blocker in capture mode." - (dolist (key '("<f1>" "<f10>" "<f11>" "<f12>" "M-SPC")) + (dolist (key '("<f1>" "<f9>" "<f10>" "<f12>" "M-SPC")) (should (eq (keymap-lookup org-capture-mode-map key) #'cj/--org-capture-blocked-key)))) +(ert-deftest test-org-capture-fkey-guard-leaves-fullscreen-alone () + "Boundary: F11 (frame fullscreen) is not blocked. It pops no UI, and +before dirvish-side moved to F9 it was on this list only because F11 was the +sidebar key; the entry must follow the command, not the key." + (should-not (eq (keymap-lookup org-capture-mode-map "<f11>") + #'cj/--org-capture-blocked-key))) + (ert-deftest test-org-capture-fkey-guard-blocker-signals-user-error () "Error: the blocker signals a user-error rather than doing nothing." (should-error (cj/--org-capture-blocked-key) :type 'user-error)) diff --git a/tests/test-prog-general-yas-activation.el b/tests/test-prog-general-yas-activation.el index d6ea42cd..d9ae76e3 100644 --- a/tests/test-prog-general-yas-activation.el +++ b/tests/test-prog-general-yas-activation.el @@ -122,7 +122,11 @@ produces the marker block." "Boundary: <cj + expand in python-ts-mode (a tree-sitter prog-mode-derived mode) produces the marker block. Verifies the snippet reaches modern tree-sitter modes through fundamental-mode inheritance." - (skip-unless (fboundp 'python-ts-mode)) + ;; `python-ts-mode' prompts to install a missing grammar, which a batch run + ;; cannot answer, so skip on the grammar rather than on the mode's existence. + (skip-unless (and (fboundp 'python-ts-mode) + (require 'treesit nil t) + (treesit-ready-p 'python t))) (should (string= (test-prog-general--expand-cj-in-mode #'python-ts-mode) test-prog-general--cj-expected))) diff --git a/tests/test-system-defaults-functions.el b/tests/test-system-defaults-functions.el index 4b647166..09bf9f2a 100644 --- a/tests/test-system-defaults-functions.el +++ b/tests/test-system-defaults-functions.el @@ -55,6 +55,12 @@ ;; so it doesn't leak into a shared batch session. `make test-name' loads ;; every test file into one Emacs; a leaked cwd there breaks the relative ;; loads of every file that follows. +;; Declared special before the `let' below binds it: this file is lexical, +;; so without the defvar the binding is a lexical local, and use-package's +;; own `defcustom' then fails with "Defining as dynamic an already lexical +;; var" (fatal at load since 31.1 moved the defcustom to autoload time). +(defvar use-package-always-ensure) + (let ((default-directory default-directory) (use-package-always-ensure nil)) (cl-letf (((symbol-function 'server-running-p) (lambda (&rest _) t)) diff --git a/tests/test-transcription-config--ratio-backend.el b/tests/test-transcription-config--ratio-backend.el new file mode 100644 index 00000000..f368b859 --- /dev/null +++ b/tests/test-transcription-config--ratio-backend.el @@ -0,0 +1,102 @@ +;;; test-transcription-config--ratio-backend.el --- the ratio self-hosted backend -*- lexical-binding: t; -*- + +;;; Commentary: +;; The `ratio' backend runs whisper plus speaker diarization on my own host +;; through the scripts/ratio-transcribe client. It needs no API key, so its +;; descriptor carries nil for both :auth-host and :env-var, and the process +;; environment passes through unchanged. +;; +;; Two seams have to agree for a backend to be usable: the descriptor alist +;; that resolves the script, and the completing-read list the interactive +;; switcher offers. The switcher used to carry its own copy of that list, so +;; a descriptor added without a switcher entry was reachable only by setq. It +;; now derives its choices from the alist; the boundary test below guards +;; against a return to a hardcoded list. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +(unless (fboundp 'notifications-notify) + (defun notifications-notify (&rest _args) + "Stub notification function for testing." + nil)) + +(require 'transcription-config) + +(defun test-transcription-ratio--switcher-choices () + "Return the backend names `cj/transcription-switch-backend' offers. +Captures the collection handed to `completing-read' and answers with the +current backend so the switcher is a no-op." + (let (offered) + (cl-letf (((symbol-function 'completing-read) + (lambda (_prompt collection &rest _) + (setq offered (mapcar #'car collection)) + (symbol-name cj/transcribe-backend)))) + (let ((cj/transcribe-backend cj/transcribe-backend)) + (cj/transcription-switch-backend))) + offered)) + +;;; Normal + +(ert-deftest test-transcription-config-ratio-descriptor-resolves () + "Normal: the ratio backend resolves to the ratio-transcribe client with no +API-key requirement." + (let ((desc (cj/--backend-plist 'ratio))) + (should (equal (plist-get desc :script) "ratio-transcribe")) + (should (null (plist-get desc :auth-host))) + (should (null (plist-get desc :env-var))))) + +(ert-deftest test-transcription-config-ratio-script-path-and-executable () + "Normal: the script path lands on scripts/ratio-transcribe and the file is +there and executable, so a transcription can actually start." + (let ((cj/transcribe-backend 'ratio)) + (let ((path (cj/--transcription-script-path))) + (should (string-suffix-p "scripts/ratio-transcribe" path)) + (should (file-executable-p path))))) + +(ert-deftest test-transcription-config-ratio-environment-passes-through () + "Normal: no API key means the process environment is returned unchanged, +and nothing consults authinfo." + (cl-letf (((symbol-function 'cj/--auth-source-password) + (lambda (&rest _) (ert-fail "auth-source consulted for a keyless backend")))) + (should (eq (cj/--build-process-environment 'ratio) process-environment)))) + +(ert-deftest test-transcription-config-switcher-offers-ratio () + "Normal: the interactive switcher lists ratio, so the backend is reachable +without a setq." + (should (member "ratio" (test-transcription-ratio--switcher-choices)))) + +;;; Boundary + +(ert-deftest test-transcription-config-switcher-matches-descriptors () + "Boundary: the switcher offers exactly the descriptor set. It derives the +list from the alist now; this is what stops a hardcoded copy coming back." + (should (equal (sort (test-transcription-ratio--switcher-choices) #'string<) + (sort (mapcar (lambda (entry) (symbol-name (car entry))) + cj/--transcription-backends) + #'string<)))) + +(ert-deftest test-transcription-config-every-descriptor-script-exists () + "Boundary: every descriptor names a script that exists under scripts/. +A descriptor for a script that never landed (a hosted alternative referenced +from another repo, say) would fail at transcription time instead." + (dolist (entry cj/--transcription-backends) + (let ((cj/transcribe-backend (car entry))) + (should (file-exists-p (cj/--transcription-script-path)))))) + +;;; Error + +(ert-deftest test-transcription-config-unknown-backend-still-signals () + "Error: adding ratio did not loosen the descriptor lookup; an unknown +backend still signals `user-error'." + (should-error (cj/--backend-plist 'no-such-backend) :type 'user-error)) + +(provide 'test-transcription-config--ratio-backend) +;;; test-transcription-config--ratio-backend.el ends here diff --git a/tests/test-video-audio-recording--keybindings.el b/tests/test-video-audio-recording--keybindings.el index cdb6493a..9ddce661 100644 --- a/tests/test-video-audio-recording--keybindings.el +++ b/tests/test-video-audio-recording--keybindings.el @@ -1,25 +1,21 @@ ;;; test-video-audio-recording--keybindings.el --- recording toggle keybinding placement -*- lexical-binding: t; -*- ;;; Commentary: -;; The two recording toggles get a fast chord alongside the C-; r prefix: F9 -;; starts/stops video, S-F9 starts/stops audio. +;; The two recording toggles live only under the C-; r prefix. They used to +;; have a fast chord too: F9 for video, S-F9 for audio, claimed globally and in +;; every EAT map so char mode's :function category could not swallow them. ;; -;; Reaching them from inside an EAT buffer turns on which key categories each -;; input mode claims. Semi-char mode -- the default, and where agent buffers -;; sit -- is built from (:ascii :arrow :navigation) and never claims function -;; keys, so F9 already fell through to the global map there. Char mode adds -;; :function, binding f1 through f63 to `eat-self-input', and it is a minor -;; mode, so its map outranks `eat-mode-map'. The char-mode entries are the -;; load-bearing ones; the semi-char entry is belt-and-braces. +;; That chord went away on 2026-09-22. With Fn Lock on the Framework 13, the +;; F-key row sends plain function keys, so a bare F9 press started a screen +;; recording. The pair is removed as a unit: a lone S-F9 audio chord on a live +;; F-key row is one fat-finger from an unwanted recording, and the module +;; treated the two as one feature. ;; -;; :function claims only the unmodified keys, which is why getting this wrong -;; split the pair rather than breaking it outright: S-F9 toggled audio in a -;; char-mode buffer while F9 went to the program under the cursor. -;; -;; These tests require eat first so the module's `with-eval-after-load' fires. -;; The char-mode cases resolve through `key-binding' in a fixture that -;; reproduces minor-mode precedence, because reading a binding back out of the -;; map the module just wrote proves nothing about which map wins on a keypress. +;; These tests pin the removal. The char-mode case resolves through +;; `key-binding' in a fixture that reproduces minor-mode precedence, because +;; reading a map the module no longer writes proves nothing about which map wins +;; on a keypress. They require eat first so any `with-eval-after-load' the +;; module still carries would have fired. ;;; Code: @@ -32,56 +28,41 @@ (require 'eat) (require 'video-audio-recording) +(defconst test-video-audio-recording--toggles + '(cj/video-recording-toggle cj/audio-recording-toggle) + "The two commands the retired F9 chords used to reach.") + +(defun test-video-audio-recording--bound-to-toggle-p (binding) + "Return non-nil when BINDING is one of the recording toggles." + (memq binding test-video-audio-recording--toggles)) + ;;; Normal -(ert-deftest test-video-audio-recording-f9-bound-globally () - "Normal: F9 toggles video recording, S-F9 toggles audio recording." - (should (eq (lookup-key (current-global-map) (kbd "<f9>")) - #'cj/video-recording-toggle)) - (should (eq (lookup-key (current-global-map) (kbd "S-<f9>")) - #'cj/audio-recording-toggle))) - -(ert-deftest test-video-audio-recording-f9-bound-in-eat-semi-char-mode-map () - "Normal: both chords are bound in `eat-semi-char-mode-map'. -Redundant rather than load-bearing: semi-char is built without :function, so a -function key already falls through to the global map. Asserted anyway so the -entry cannot be dropped silently while the comment explaining it stays." - (should (eq (keymap-lookup eat-semi-char-mode-map "<f9>") - #'cj/video-recording-toggle)) - (should (eq (keymap-lookup eat-semi-char-mode-map "S-<f9>") - #'cj/audio-recording-toggle))) - -(ert-deftest test-video-audio-recording-f9-bound-in-eat-mode-map () - "Normal: both chords are bound in `eat-mode-map', the major-mode map every -EAT buffer carries regardless of input mode." - (should (eq (keymap-lookup eat-mode-map "<f9>") - #'cj/video-recording-toggle)) - (should (eq (keymap-lookup eat-mode-map "S-<f9>") - #'cj/audio-recording-toggle))) - -(ert-deftest test-video-audio-recording-f9-bound-in-eat-char-mode-maps () - "Normal: both chords are bound in the two char-mode maps. -Char mode is built with EAT's :function category, which binds f1 through f63 -to `eat-self-input'. These entries are what override that." - (dolist (map (list eat-char-mode-map eat-eshell-char-mode-map)) - (should (eq (keymap-lookup map "<f9>") #'cj/video-recording-toggle)) - (should (eq (keymap-lookup map "S-<f9>") #'cj/audio-recording-toggle)))) +(ert-deftest test-video-audio-recording-f9-not-bound-globally () + "Normal/regression: neither F9 nor S-F9 reaches a recording toggle globally." + (should-not (test-video-audio-recording--bound-to-toggle-p + (lookup-key (current-global-map) (kbd "<f9>")))) + (should-not (test-video-audio-recording--bound-to-toggle-p + (lookup-key (current-global-map) (kbd "S-<f9>"))))) + +(ert-deftest test-video-audio-recording-f9-not-bound-in-eat-maps () + "Normal/regression: none of the four EAT maps carries a recording toggle on +F9 or S-F9. The module used to write all four so the chord survived char +mode; the removal has to reach every one of them." + (dolist (map (list eat-semi-char-mode-map eat-mode-map + eat-char-mode-map eat-eshell-char-mode-map)) + (should-not (test-video-audio-recording--bound-to-toggle-p + (keymap-lookup map "<f9>"))) + (should-not (test-video-audio-recording--bound-to-toggle-p + (keymap-lookup map "S-<f9>"))))) ;;; Boundary -(ert-deftest test-video-audio-recording-f9-chords-are-distinct () - "Boundary: the shifted and unshifted chords resolve to different commands. -A copy-paste binding both to the same toggle would satisfy every -binding-is-present assertion above, so assert the difference directly." - (should-not (eq (lookup-key (current-global-map) (kbd "<f9>")) - (lookup-key (current-global-map) (kbd "S-<f9>"))))) - (defun test-video-audio-recording--in-char-mode (body) "Run BODY in a buffer wired the way a live EAT char-mode buffer is. `eat--char-mode' is a minor mode, so its map is consulted ahead of the -major-mode map. Reproducing that ordering is the point: reading a binding -back out of the map the module just wrote proves nothing about which map wins -when a key is actually pressed." +major-mode map. Reproducing that ordering is the point: the assertion is +about which map wins when a key is actually pressed." (with-temp-buffer (use-local-map eat-mode-map) (let ((minor-mode-overriding-map-alist @@ -89,42 +70,34 @@ when a key is actually pressed." (eat--char-mode t)) (funcall body)))) -(ert-deftest test-video-audio-recording-f9-resolves-in-char-mode () - "Boundary: both chords resolve to the toggles through the real precedence -chain in a char-mode buffer. Before this override F9 resolved to -`eat-self-input' and went to the program under the cursor, while S-F9 reached -Emacs — so the pair silently split, audio recording and video not." +(ert-deftest test-video-audio-recording-f9-reaches-program-in-char-mode () + "Boundary: in a char-mode buffer F9 goes to the program under the cursor +again. EAT's :function category binds f1 through f63 to `eat-self-input'; +the module's override used to sit in front of it, and now nothing does." (test-video-audio-recording--in-char-mode (lambda () - (should (eq (key-binding (kbd "<f9>")) #'cj/video-recording-toggle)) - (should (eq (key-binding (kbd "S-<f9>")) #'cj/audio-recording-toggle))))) + (should (eq (key-binding (kbd "<f9>")) #'eat-self-input))))) ;;; Error (ert-deftest test-video-audio-recording-char-mode-fixture-really-is-char-mode () "Error (positive control): the char-mode fixture genuinely puts EAT's map in front. F8 sits in the same :function category as F9 and this module never -touches it, so it must still reach `eat-self-input'. If it resolves anywhere -else the fixture is inert, and the resolution test above would pass without -ever consulting `eat-char-mode-map' — which is precisely how the first cut of -this file missed that F9 was being swallowed there." +touched it, so it must reach `eat-self-input'. If it resolves anywhere else +the fixture is inert and the F9 assertion above passes for the wrong reason." (test-video-audio-recording--in-char-mode (lambda () (should (eq (key-binding (kbd "<f8>")) #'eat-self-input))))) -(ert-deftest test-video-audio-recording-f9-targets-are-commands () - "Error: a key bound to a non-interactive function fails at press time with a -`commandp' error rather than at load, so assert both targets are real commands." - (should (commandp (lookup-key (current-global-map) (kbd "<f9>")))) - (should (commandp (lookup-key (current-global-map) (kbd "S-<f9>"))))) - (ert-deftest test-video-audio-recording-prefix-bindings-still-reachable () - "Error/regression (positive control): the fast chords must not disturb the -C-; r prefix path. Without this, deleting the prefix map outright would leave -every assertion above green." + "Error/regression (positive control): removing the fast chords must leave +the C-; r prefix path intact. Without this, deleting the toggles outright +would leave every not-bound assertion above green." (should (eq (keymap-lookup cj/record-map "v") #'cj/video-recording-toggle)) (should (eq (keymap-lookup cj/record-map "a") #'cj/audio-recording-toggle)) - (should (eq (keymap-lookup cj/custom-keymap "r") cj/record-map))) + (should (eq (keymap-lookup cj/custom-keymap "r") cj/record-map)) + (should (commandp #'cj/video-recording-toggle)) + (should (commandp #'cj/audio-recording-toggle))) (provide 'test-video-audio-recording--keybindings) ;;; test-video-audio-recording--keybindings.el ends here |
