diff options
| -rw-r--r-- | .gitignore | 7 | ||||
| -rw-r--r-- | Makefile | 10 | ||||
| -rw-r--r-- | docs/specs/keybinding-console-safety-spec.org | 2 | ||||
| -rw-r--r-- | modules/dirvish-config.el | 10 | ||||
| -rw-r--r-- | modules/elfeed-config.el | 5 | ||||
| -rw-r--r-- | modules/org-capture-config.el | 8 | ||||
| -rw-r--r-- | modules/org-reveal-config.el | 148 | ||||
| -rw-r--r-- | tests/test-dirvish-config--side-key.el | 40 | ||||
| -rw-r--r-- | tests/test-keybindings--fullscreen.el | 45 | ||||
| -rw-r--r-- | tests/test-makefile-clean-compiled.el | 121 | ||||
| -rw-r--r-- | tests/test-org-capture-config-fkey-guard.el | 15 | ||||
| -rw-r--r-- | tests/test-org-reveal-config-header-template.el | 6 | ||||
| -rw-r--r-- | tests/test-org-reveal-config-sizing.el | 246 |
13 files changed, 643 insertions, 20 deletions
@@ -62,9 +62,10 @@ auto-save-list/ # reveal.js local clone (managed by scripts/setup-reveal.sh) /reveal.js/ -# Claude tooling — gitignored, not published to the public mirror +# Agent tooling — gitignored, not published to the public mirror /.ai/ /.claude/ +/.codex/ /CLAUDE.md /AGENTS.md history @@ -87,8 +88,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/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/org-reveal-config.el b/modules/org-reveal-config.el index f842680b..ddb0803d 100644 --- a/modules/org-reveal-config.el +++ b/modules/org-reveal-config.el @@ -18,6 +18,10 @@ ;; Fully offline workflow using a local reveal.js clone (managed by ;; scripts/setup-reveal.sh) and self-contained HTML export. ;; +;; Slides use a 16:9 canvas with a smaller base font than the stock theme, +;; and a script in the export shrinks any slide whose content would +;; otherwise be clipped (see "Slide Sizing" below). +;; ;; Keybindings (C-; p prefix): ;; - C-; p SPC : Ensure headers, export, and open in browser ;; - C-; p e : Export to self-contained HTML and open in browser @@ -37,6 +41,8 @@ (defvar org-reveal-plugins) (defvar org-reveal-highlight-css) (defvar org-reveal-init-options) +(defvar org-reveal-head-preamble) +(defvar org-reveal-extra-script) (declare-function org-reveal-export-to-html "ox-reveal") ;; --------------------------------- Constants --------------------------------- @@ -51,6 +57,135 @@ (defconst cj/reveal-default-transition "none" "Default reveal.js slide transition for new presentations.") +;; ------------------------------- Slide Sizing -------------------------------- +;; +;; reveal.js lays every slide out on a fixed canvas and scales that canvas +;; to the window; content taller than the canvas is clipped, not scrolled. +;; Three things keep a dense slide readable instead of cut off: +;; +;; - a 16:9 canvas (the shape of every screen the slides land on), so lines +;; wrap later than on the stock 960x700 frame; +;; - a smaller base font and heading scale, set through the theme's CSS +;; variables in the head preamble so any reveal.js 4+ theme picks them up; +;; - a script, spliced in after Reveal.initialize, that shrinks any slide +;; whose content still overflows. It floors at half size: a slide that +;; overflows even there needs splitting, not shrinking. +;; +;; The script rides ox-reveal's extra-script slot, so a file that sets its +;; own #+REVEAL_EXTRA_SCRIPT replaces it. Print view (?print-pdf) paginates +;; instead of clipping, so the fit is skipped there and a printed deck can +;; differ from the presented one. + +(defconst cj/reveal-canvas-width 1280 + "Width of the reveal.js slide canvas in CSS pixels.") + +(defconst cj/reveal-canvas-height 720 + "Height of the reveal.js slide canvas in CSS pixels.") + +(defconst cj/reveal-base-font-size "30px" + "Base font size for slide text, as a px value. +The stock themes use 42px, which fits about six short lines on a slide. +Keep it in px: ox-reveal runs the head preamble through `format-spec', +so a % unit would break the export.") + +(defun cj/--reveal-init-options () + "Return the Reveal.initialize options shared by default and template. +One line with no trailing comma, so ox-reveal can splice it into the +Reveal.initialize call." + (format "slideNumber:true, hash:true, transition:'%s', width:%d, height:%d" + cj/reveal-default-transition + cj/reveal-canvas-width + cj/reveal-canvas-height)) + +(defun cj/--reveal-head-preamble () + "Return the <style> element for the document head. +Sets the house font sizes through the theme variables, stops the theme +from upper-casing headings, and left-aligns slide text (reveal centers +everything by default); the title slide and standalone images keep the +centering." + (format "<style> +:root { + --r-main-font-size: %s; + --r-heading1-size: 2.2em; + --r-heading2-size: 1.5em; + --r-heading3-size: 1.2em; + --r-heading4-size: 1em; +} +.reveal h1, .reveal h2, .reveal h3, .reveal p { text-transform: none; } +.reveal .slides section { text-align: left; } +.reveal .slides #sec-title-slide { text-align: center; } +.reveal .slides section .figure { text-align: center; } +</style>" + cj/reveal-base-font-size)) + +(defun cj/--reveal-fit-script () + "Return the JavaScript that shrinks overflowing slides to the canvas. +ox-reveal emits it inside the <script> element right after +Reveal.initialize, so Reveal already exists: every slide is fitted once +reveal is ready and the current one refitted on each slide change. +Hidden slides are shown for the measurement only, so the display state +reveal manages is restored before anything paints." + "// Shrink any slide whose content overflows the canvas height so it scales +// down instead of being clipped. Floors at half size: a slide that still +// overflows there needs splitting, not shrinking. +(function () { + var FLOOR = 0.5; // never below half size: split the slide instead + var MARGIN = 0.05; // breathing room above the bottom edge of the canvas + var PASSES = 8; // fixed-height content converges slowly; cap the loop + var busy = false; // layout() re-enters through slidechanged in scroll view + // Overview boxes every slide at the canvas height and print view paginates, + // so a measurement there is meaningless. + function skip() { + return (Reveal.isPrintView && Reveal.isPrintView()) + || (Reveal.isOverview && Reveal.isOverview()); + } + // Shrink SLIDE until it fits; return true when its font size changed. + function fit(slide) { + if (!slide || skip()) { return false; } + var limit = Reveal.getConfig().height * (1 - MARGIN); + var before = slide.style.fontSize; + var scale = 1; + slide.style.fontSize = ''; + for (var pass = 0; pass < PASSES && scale > FLOOR && slide.scrollHeight > limit; pass++) { + scale = Math.max(FLOOR, scale * limit / slide.scrollHeight); + slide.style.fontSize = (scale * 100).toFixed(1) + '%'; + } + if (scale === FLOOR && slide.scrollHeight > limit) { + console.warn('reveal fit: slide still overflows at half size, split it: ' + + (slide.id || '(no id)')); + } + return slide.style.fontSize !== before; + } + function shown(el, fn) { + var saved = el.style.display; + el.style.display = 'block'; + try { fn(); } finally { el.style.display = saved; } + } + function fitAll() { + busy = true; + try { + Reveal.getSlides().forEach(function (slide) { + var parent = slide.parentNode; + var run = function () { shown(slide, function () { fit(slide); }); }; + if (parent && parent.classList && parent.classList.contains('stack')) { + shown(parent, run); + } else { + run(); + } + }); + Reveal.layout(); + } finally { busy = false; } + } + function refit(slide) { + if (busy) { return; } + busy = true; + try { if (fit(slide)) { Reveal.layout(); } } finally { busy = false; } + } + if (Reveal.isReady()) { fitAll(); } else { Reveal.on('ready', fitAll); } + Reveal.on('slidechanged', function (event) { refit(event.currentSlide); }); + Reveal.on('overviewhidden', function () { refit(Reveal.getCurrentSlide()); }); +})();") + ;; --------------------------------- ox-reveal --------------------------------- (use-package ox-reveal @@ -60,10 +195,9 @@ (setq org-reveal-single-file t) (setq org-reveal-plugins '(highlight notes search zoom)) (setq org-reveal-highlight-css "%r/plugin/highlight/monokai.css") - (setq org-reveal-init-options (format "slideNumber:true, hash:true, transition:'%s'" - cj/reveal-default-transition)) - (setq org-reveal-head-preamble - "<style>.reveal h1,.reveal h2,.reveal h3,.reveal p{text-transform:none;}</style>")) + (setq org-reveal-init-options (cj/--reveal-init-options)) + (setq org-reveal-head-preamble (cj/--reveal-head-preamble)) + (setq org-reveal-extra-script (cj/--reveal-fit-script))) ;; ----------------------------- Private Helpers ------------------------------- @@ -76,15 +210,15 @@ #+DATE: %s #+REVEAL_ROOT: %s #+REVEAL_THEME: %s -#+REVEAL_INIT_OPTIONS: slideNumber:true, hash:true, transition:'%s' +#+REVEAL_INIT_OPTIONS: %s #+REVEAL_PLUGINS: (highlight notes search zoom) #+REVEAL_HIGHLIGHT_CSS: %%r/plugin/highlight/monokai.css -#+OPTIONS: toc:nil num:nil date:nil timestamp:nil author:t +#+OPTIONS: toc:nil num:nil date:nil timestamp:nil author:t ^:nil " title (user-full-name) (format-time-string "%Y-%m-%d") (concat "file://" cj/reveal-root) cj/reveal-default-theme - cj/reveal-default-transition)) + (cj/--reveal-init-options))) (defun cj/--reveal-title-to-filename (title) "Convert TITLE to a slug-based .org filename. 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-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-org-reveal-config-header-template.el b/tests/test-org-reveal-config-header-template.el index 9bda10db..b7ceff53 100644 --- a/tests/test-org-reveal-config-header-template.el +++ b/tests/test-org-reveal-config-header-template.el @@ -144,5 +144,11 @@ "Nil title should signal an error rather than silently producing garbage." (should-error (test-reveal--header nil) :type 'user-error)) +(ert-deftest test-org-reveal-config-header-template-normal-keeps-underscores-literal () + "Normal: #+OPTIONS: turns off ^ markup so snake_case identifiers in slide text stay literal. +Without it a heading like event_payloads_ais exports as event with a subscript." + (let ((result (cj/--reveal-header-template "My Talk"))) + (should (string-match-p "^#\\+OPTIONS:.*\\^:nil" result)))) + (provide 'test-org-reveal-config-header-template) ;;; test-org-reveal-config-header-template.el ends here diff --git a/tests/test-org-reveal-config-sizing.el b/tests/test-org-reveal-config-sizing.el new file mode 100644 index 00000000..19e4e173 --- /dev/null +++ b/tests/test-org-reveal-config-sizing.el @@ -0,0 +1,246 @@ +;;; test-org-reveal-config-sizing.el --- Tests for reveal.js slide sizing and auto-fit -*- lexical-binding: t; -*- + +;;; Commentary: +;; Dense slides overflowed reveal.js's 960x700 canvas at the theme's 42px +;; base font and were clipped with no way to scroll. The module now ships +;; a 16:9 canvas with a smaller base font (via the head preamble) and a +;; script, spliced in after Reveal.initialize, that shrinks any slide whose +;; content still overflows. These tests pin the three builders and run the +;; fit algorithm under node against a stub Reveal API. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'json) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'keybindings) +(require 'org-reveal-config) + +(defun test-org-reveal-config--count (regexp string) + "Return how many times REGEXP matches in STRING." + (let ((n 0) (start 0)) + (while (string-match regexp string start) + (setq n (1+ n) + start (match-end 0))) + n)) + +;; ------------------------------ Init options ------------------------------- + +(ert-deftest test-org-reveal-config-init-options-normal-has-behaviour-and-canvas () + "Normal: the shared init options carry slide numbers, hash, transition and the canvas size." + (let ((opts (cj/--reveal-init-options))) + (should (string-match-p "slideNumber:true" opts)) + (should (string-match-p "hash:true" opts)) + (should (string-match-p (format "transition:'%s'" cj/reveal-default-transition) opts)) + (should (string-match-p (format "width:%d" cj/reveal-canvas-width) opts)) + (should (string-match-p (format "height:%d" cj/reveal-canvas-height) opts)))) + +(ert-deftest test-org-reveal-config-init-options-boundary-splices-into-initialize () + "Boundary: one line, no trailing comma, so it splices into Reveal.initialize({...})." + (let ((opts (cj/--reveal-init-options))) + (should-not (string-match-p "\n" opts)) + (should-not (string-suffix-p "," (string-trim opts))))) + +(ert-deftest test-org-reveal-config-init-options-normal-canvas-is-16-9 () + "Normal: the canvas is a 16:9 frame, the shape of every projector and monitor the slides land on." + (should (= (* 9 cj/reveal-canvas-width) (* 16 cj/reveal-canvas-height)))) + +(ert-deftest test-org-reveal-config-header-template-normal-uses-shared-init-options () + "Normal: the header template emits the same init options as the global default, so a file never drifts from the module." + (let ((result (cj/--reveal-header-template "Sizing"))) + (should (string-match-p + (format "^#\\+REVEAL_INIT_OPTIONS: %s$" + (regexp-quote (cj/--reveal-init-options))) + result)))) + +;; ------------------------------ Head preamble ------------------------------ + +(ert-deftest test-org-reveal-config-head-preamble-normal-sets-font-sizes () + "Normal: the preamble lowers the theme's base font and heading sizes through the theme variables." + (let ((css (cj/--reveal-head-preamble))) + (should (string-match-p (format "--r-main-font-size: *%s" + (regexp-quote cj/reveal-base-font-size)) + css)) + (should (string-match-p "--r-heading1-size" css)) + (should (string-match-p "--r-heading2-size" css)))) + +(ert-deftest test-org-reveal-config-head-preamble-normal-keeps-no-caps-rule () + "Normal: the rule that stops the theme upper-casing headings survives the rewrite." + (should (string-match-p "text-transform: *none" (cj/--reveal-head-preamble)))) + +(ert-deftest test-org-reveal-config-head-preamble-normal-left-aligns-slide-content () + "Normal: slide text is left-aligned by default; the title slide and standalone images keep reveal's centering." + (let ((css (cj/--reveal-head-preamble))) + (should (string-match-p "\\.reveal \\.slides section {[^}]*text-align: *left" css)) + (should (string-match-p "#sec-title-slide {[^}]*text-align: *center" css)) + (should (string-match-p "section \\.figure {[^}]*text-align: *center" css)))) + +(ert-deftest test-org-reveal-config-head-preamble-boundary-one-style-element () + "Boundary: exactly one <style> element, opened and closed." + (let ((css (cj/--reveal-head-preamble))) + (should (= 1 (test-org-reveal-config--count "<style" css))) + (should (= 1 (test-org-reveal-config--count "</style>" css))))) + +;; -------------------------------- Fit script ------------------------------- + +(ert-deftest test-org-reveal-config-fit-script-normal-hooks-reveal-events () + "Normal: the script fits every slide once reveal is ready and refits on each slide change." + (let ((js (cj/--reveal-fit-script))) + (should (string-match-p "Reveal\\.on('ready'" js)) + (should (string-match-p "Reveal\\.on('slidechanged'" js)) + (should (string-match-p "Reveal\\.on('overviewhidden'" js)) + (should (string-match-p "isOverview" js)) + (should (string-match-p "scrollHeight" js)) + (should (string-match-p "getConfig()\\.height" js)))) + +(ert-deftest test-org-reveal-config-fit-script-boundary-no-script-tags () + "Boundary: ox-reveal splices the text inside its own <script> element, so it must carry no tags." + (let ((js (cj/--reveal-fit-script))) + (should-not (string-match-p "<script" js)) + (should-not (string-match-p "</script" js)))) + +(defconst test-org-reveal-config--fit-harness + "const MODE = '%s'; +let overview = false; +const make = (base, fixed, inStack) => { + const s = { style: { fontSize: '', display: 'none' }, + parentNode: { classList: { contains: (c) => inStack && c === 'stack' }, + style: { display: 'none' } } }; + Object.defineProperty(s, 'scrollHeight', { get() { + const m = /^([\\d.]+)%%$/.exec(s.style.fontSize); + const h = fixed + Math.round(base * (m ? parseFloat(m[1]) / 100 : 1)); + return overview ? Math.max(h, 720) : h; } }); + return s; +}; +const slides = [make(600, 0, false), make(684, 0, true), make(1368, 0, true), + make(100000, 0, false), make(900, 100, false), make(500, 450, true)]; +const handlers = {}; +let layouts = 0; +let warnings = 0; +console.warn = () => { warnings++; }; +global.Reveal = { + getConfig: () => ({ height: 720 }), + getSlides: () => slides, + getCurrentSlide: () => slides[2], + layout: () => { layouts++; + if (MODE === 'reentrant' && handlers.slidechanged) { + handlers.slidechanged({ currentSlide: slides[2] }); } }, + isReady: () => MODE === 'ready', + isPrintView: () => false, + isOverview: () => overview, + on: (name, fn) => { handlers[name] = fn; } }; +%s +const readyHandler = Boolean(handlers.ready); +if (handlers.ready) { handlers.ready(); } +if (MODE === 'overview') { + overview = true; + handlers.slidechanged({ currentSlide: slides[0] }); + overview = false; + handlers.overviewhidden(); +} +if (MODE === 'reentrant') { + slides[4].style.fontSize = ''; + handlers.slidechanged({ currentSlide: slides[4] }); +} +handlers.slidechanged({ currentSlide: slides[2] }); +handlers.slidechanged({ currentSlide: null }); +process.stdout.write(JSON.stringify({ + sizes: slides.map(s => s.style.fontSize), + displays: slides.map(s => s.style.display), + parents: slides.map(s => s.parentNode.style.display), + overflow: slides.map(s => s.scrollHeight > 684), + readyHandler, overviewHandler: Boolean(handlers.overviewhidden), + layouts, warnings })); +" + "Node harness: a stub Reveal API around the fit script. +The first %s is the harness MODE (normal, ready, overview, reentrant) and +the second is the script. Each stub slide's scrollHeight is a fixed part +plus a text part that scales with the font-size the script sets, so a +later measuring pass sees the effect of the earlier one. Slides 1, 2 and +5 sit in a .stack parent, as every ox-reveal content slide does. The +canvas is 720px, so the usable limit with the 5% margin is 684px. In +overview the stub floors scrollHeight at the 720px box, the way reveal's +overview CSS does.") + +(defun test-org-reveal-config--run-fit-harness (&optional mode) + "Run the fit script under node in harness MODE; return the parsed result." + (let ((file (make-temp-file "reveal-fit-" nil ".js")) + (out (generate-new-buffer " *fit-harness*"))) + (unwind-protect + (progn + (with-temp-file file + (insert (format test-org-reveal-config--fit-harness + (or mode "normal") + (cj/--reveal-fit-script)))) + (let ((status (call-process "node" nil out nil file))) + (unless (eq status 0) + (error "node exited %s: %s" status + (with-current-buffer out (buffer-string)))) + (with-current-buffer out + (goto-char (point-min)) + (json-parse-buffer :object-type 'alist :array-type 'list)))) + (delete-file file) + (kill-buffer out)))) + +(ert-deftest test-org-reveal-config-fit-script-normal-shrinks-only-overflowing-slides () + "Normal: a slide twice the usable height is scaled to 50%; slides that fit are left alone." + (skip-unless (executable-find "node")) + (let-alist (test-org-reveal-config--run-fit-harness) + (should (equal "" (nth 0 .sizes))) + (should (equal "" (nth 1 .sizes))) + (should (equal "50.0%" (nth 2 .sizes))))) + +(ert-deftest test-org-reveal-config-fit-script-normal-converges-with-fixed-height-content () + "Normal: content with a fixed-height part needs several passes and still lands inside the limit." + (skip-unless (executable-find "node")) + (let-alist (test-org-reveal-config--run-fit-harness) + (should (string-match-p "\\`6[0-9]\\.[0-9]%\\'" (nth 4 .sizes))) + (should (eq :false (nth 4 .overflow))))) + +(ert-deftest test-org-reveal-config-fit-script-boundary-floors-at-half-size () + "Boundary: content that would need less than half size stops at the floor, and only those slides are reported." + (skip-unless (executable-find "node")) + (let-alist (test-org-reveal-config--run-fit-harness) + (should (equal "50.0%" (nth 3 .sizes))) + (should (equal "50.0%" (nth 5 .sizes))) + (should (eq t (nth 3 .overflow))) + (should (eq t (nth 5 .overflow))) + (should (= 2 .warnings)))) + +(ert-deftest test-org-reveal-config-fit-script-boundary-restores-display-and-relayouts () + "Boundary: measuring hidden slides and their stack parents leaves display as reveal set it, and layout runs afterwards." + (skip-unless (executable-find "node")) + (let-alist (test-org-reveal-config--run-fit-harness) + (should (equal '("none" "none" "none" "none" "none" "none") .displays)) + (should (equal '("none" "none" "none" "none" "none" "none") .parents)) + (should (> .layouts 0)))) + +(ert-deftest test-org-reveal-config-fit-script-normal-fits-at-once-when-reveal-is-ready () + "Normal: when reveal is already ready the slides are fitted immediately instead of waiting for an event." + (skip-unless (executable-find "node")) + (let-alist (test-org-reveal-config--run-fit-harness "ready") + (should (eq :false .readyHandler)) + (should (equal "50.0%" (nth 2 .sizes))))) + +(ert-deftest test-org-reveal-config-fit-script-boundary-overview-is-left-alone () + "Boundary: in overview every slide measures as the full box, so nothing is refitted until the overview closes." + (skip-unless (executable-find "node")) + (let-alist (test-org-reveal-config--run-fit-harness "overview") + (should (equal "" (nth 0 .sizes))) + (should (eq t .overviewHandler)))) + +(ert-deftest test-org-reveal-config-fit-script-error-null-slide-is-ignored () + "Error: a slidechanged event without a current slide is ignored rather than thrown." + (skip-unless (executable-find "node")) + (should (test-org-reveal-config--run-fit-harness))) + +(ert-deftest test-org-reveal-config-fit-script-error-reentrant-layout-does-not-recurse () + "Error: a layout that fires slidechanged again (reveal's scroll view does) must not recurse without end." + (skip-unless (executable-find "node")) + (let-alist (test-org-reveal-config--run-fit-harness "reentrant") + (should (>= .layouts 2)))) + +(provide 'test-org-reveal-config-sizing) +;;; test-org-reveal-config-sizing.el ends here |
