diff options
Diffstat (limited to 'tests')
64 files changed, 2555 insertions, 1676 deletions
diff --git a/tests/test-ai-term--runtime.el b/tests/test-ai-term--runtime.el new file mode 100644 index 00000000..7644e468 --- /dev/null +++ b/tests/test-ai-term--runtime.el @@ -0,0 +1,136 @@ +;;; test-ai-term--runtime.el --- Tests for the ai-term runtime selection -*- lexical-binding: t; -*- + +;;; Commentary: +;; Multi-backend launch: a fresh agent session can run Claude, Codex, or a +;; local model through codex --oss (ollama). The runtime names and launch +;; strings mirror the rulesets bin/ai launcher so the two stay one mental +;; model: "claude", "codex", and "local:<model>". +;; +;; Pure pieces tested here: +;; - `cj/--ai-term-runtime-command' maps a runtime name to the full shell +;; command (agent CLI + the shared opening prompt). +;; - `cj/--ai-term-parse-runtime-lines' parses `ai --print-runtimes' output +;; into (NAME . LABEL) choices. +;; - `cj/--ai-term-runtime-choices' shells out to `ai' at its boundary +;; (mocked here) and falls back to the static list when `ai' is absent. +;; The interactive picker is a thin completing-read wrapper and is not +;; tested (Interactive vs Internal split). + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-term) + +;;; ------------------------- runtime -> command ------------------------------ + +(ert-deftest test-ai-term-runtime-command-claude-is-agent-command () + "Normal: \"claude\" (and nil) return `cj/ai-term-agent-command' verbatim." + (let ((cj/ai-term-agent-command "claude \"do the thing\"")) + (should (equal (cj/--ai-term-runtime-command "claude") + "claude \"do the thing\"")) + (should (equal (cj/--ai-term-runtime-command nil) + "claude \"do the thing\"")))) + +(ert-deftest test-ai-term-runtime-command-codex-composes-prompt () + "Normal: \"codex\" is the codex CLI plus the shared opening prompt." + (let ((cj/ai-term-agent-prompt "read the protocols")) + (should (equal (cj/--ai-term-runtime-command "codex") + (concat "codex " (shell-quote-argument "read the protocols")))))) + +(ert-deftest test-ai-term-runtime-command-local-model () + "Normal: \"local:<model>\" runs codex --oss against that ollama model. +The model is shell-quoted, and POSIX `shell-quote-argument' backslash-escapes +the colons, so the assertion matches the quoted form." + (let ((cj/ai-term-agent-prompt "read the protocols")) + (let ((cmd (cj/--ai-term-runtime-command "local:gpt-oss:120b"))) + (should (string-prefix-p "codex --oss --local-provider=ollama -m " cmd)) + (should (string-match-p + (regexp-quote (shell-quote-argument "gpt-oss:120b")) cmd)) + (should (string-suffix-p (shell-quote-argument "read the protocols") cmd))))) + +(ert-deftest test-ai-term-runtime-command-boundary-empty-model () + "Boundary: \"local:\" with no model is rejected, not launched half-formed." + (should-error (cj/--ai-term-runtime-command "local:") :type 'user-error)) + +(ert-deftest test-ai-term-runtime-command-error-unknown-runtime () + "Error: an unknown runtime name signals a `user-error' naming it." + (should-error (cj/--ai-term-runtime-command "gemini") :type 'user-error) + (condition-case err + (cj/--ai-term-runtime-command "gemini") + (user-error (should (string-match-p "gemini" (cadr err)))))) + +;;; --------------------------- choice-list parsing --------------------------- + +(ert-deftest test-ai-term-parse-runtime-lines-normal () + "Normal: `ai --print-runtimes' lines parse into (NAME . LABEL) pairs." + (should (equal (cj/--ai-term-parse-runtime-lines + "claude — Claude Code\ncodex — ChatGPT (Codex CLI)\nlocal:gpt-oss:120b — ollama\n") + '(("claude" . "Claude Code") + ("codex" . "ChatGPT (Codex CLI)") + ("local:gpt-oss:120b" . "ollama"))))) + +(ert-deftest test-ai-term-parse-runtime-lines-boundary-junk () + "Boundary: blank lines and lines without a separator are dropped." + (should (equal (cj/--ai-term-parse-runtime-lines + "\nclaude — Claude Code\nwarning: something\n\n") + '(("claude" . "Claude Code"))))) + +(ert-deftest test-ai-term-parse-runtime-lines-boundary-empty () + "Boundary: empty output parses to nil." + (should (null (cj/--ai-term-parse-runtime-lines "")))) + +;;; ------------------------------ choice list -------------------------------- + +(ert-deftest test-ai-term-runtime-choices-uses-ai-launcher () + "Normal: when the `ai' launcher exists, its runtime list is the choice list." + (cl-letf (((symbol-function 'executable-find) + (lambda (prog &rest _) (when (equal prog "ai") "/usr/bin/ai"))) + ((symbol-function 'process-file) + (lambda (_prog _infile buffer _display &rest _args) + (with-current-buffer (cond ((eq buffer t) (current-buffer)) + ((consp buffer) (car buffer)) + (t buffer)) + (insert "claude — Claude Code\nlocal:q — ollama\n")) + 0))) + (should (equal (cj/--ai-term-runtime-choices) + '(("claude" . "Claude Code") ("local:q" . "ollama")))))) + +(ert-deftest test-ai-term-runtime-choices-fallback-without-ai () + "Boundary: with no `ai' launcher, the static claude-first list stands." + (cl-letf (((symbol-function 'executable-find) (lambda (&rest _) nil))) + (let ((choices (cj/--ai-term-runtime-choices))) + (should (equal (caar choices) "claude")) + (should (assoc "codex" choices))))) + +(ert-deftest test-ai-term-runtime-choices-error-ai-fails () + "Error: a nonzero exit from `ai' falls back instead of erroring." + (cl-letf (((symbol-function 'executable-find) + (lambda (prog &rest _) (when (equal prog "ai") "/usr/bin/ai"))) + ((symbol-function 'process-file) (lambda (&rest _) 1))) + (should (equal (caar (cj/--ai-term-runtime-choices)) "claude")))) + +;;; --------------------- launch command takes an override -------------------- + +(ert-deftest test-ai-term-launch-command-runtime-override () + "Normal: an explicit agent command is embedded instead of the default. +The inner command is shell-quoted by the launch builder, so the assertion +matches the quoted form." + (let ((cj/ai-term-agent-command "claude default")) + (let ((cmd (cj/--ai-term-launch-command "/tmp/proj" "codex prompted"))) + (should (string-match-p + (regexp-quote (shell-quote-argument "codex prompted; exec bash")) + cmd)) + (should-not (string-match-p "claude" cmd))))) + +(ert-deftest test-ai-term-launch-command-no-override-falls-back () + "Boundary: without an override the configured agent command is used." + (let ((cj/ai-term-agent-command "claude default")) + (should (string-match-p + (regexp-quote (shell-quote-argument "claude default; exec bash")) + (cj/--ai-term-launch-command "/tmp/proj"))))) + +(provide 'test-ai-term--runtime) +;;; test-ai-term--runtime.el ends here diff --git a/tests/test-auto-dim-config.el b/tests/test-auto-dim-config.el index 2686b88f..12435fa0 100644 --- a/tests/test-auto-dim-config.el +++ b/tests/test-auto-dim-config.el @@ -35,6 +35,210 @@ (when (fboundp 'auto-dim-other-buffers-mode) (auto-dim-other-buffers-mode -1)))) +(defconst test-auto-dim--flat-dimmed-org-faces + (append (mapcar (lambda (n) (intern (format "org-level-%d" n))) + (number-sequence 1 8)) + '(org-link org-tag + ;; Document header: #+TITLE:, #+AUTHOR:, #+ARCHIVE: and their values. + org-document-title org-document-info org-document-info-keyword + org-meta-line + ;; Inline markup and blocks. + org-code org-verbatim org-block-begin-line org-block-end-line + ;; Drawers, properties, planning lines. + org-drawer org-special-keyword org-property-value org-date + ;; Tables and the fold indicator. + org-table org-table-row org-ellipsis)) + "Org faces that must flat-dim to the `auto-dim-other-buffers' face. +These carry structure, not status: nothing about them needs to stay +readable in a window the user is not looking at. Excluded on purpose are +`org-todo' and `org-priority' (keyword class -- see the -dim variant test +below) and `org-hide' (needs `auto-dim-other-buffers-hide' so folded text +stays hidden).") + +(defconst test-auto-dim--flat-dimmed-link-faces + '(link link-visited) + "Built-in link faces that must flat-dim, distinct from `org-link'. +They fontify links in help, info, and customize buffers. Both carry +`:underline t', which survives the relative remap, so a dimmed link still +reads as a link.") + +(defconst test-auto-dim--flat-dimmed-superstar-faces + '(org-superstar-header-bullet org-superstar-item org-superstar-first) + "org-superstar faces that must flat-dim. +org-superstar puts its own face ahead of the org face beneath, so a heading +star renders as (org-superstar-header-bullet org-level-1) and wins over the +dimmed org-level-1. Without these, bullets stay lit in an unfocused window +even though every face under them dims. +`org-superstar-leading' is excluded on purpose -- see the test below.") + +(defconst test-auto-dim--hide-class-faces + '(org-hide org-superstar-leading org-indent) + "Faces whose foreground IS the background colour. +That is what makes them invisible. They take `auto-dim-other-buffers-hide', +never the flat dim, which would paint them visible grey.") + +(defconst test-auto-dim--no-foreground-faces + '(bold italic underline) + "Faces that carry no foreground, even through inheritance. +They set weight, slant or underline only, so text wearing them takes its +colour from `default', which is already remapped. They need no entry and +must not gain one, or the alist grows entries that do nothing.") + +(defconst test-auto-dim--keyword-dim-variants + '((org-faces-todo . org-faces-todo-dim) + (org-faces-doing . org-faces-doing-dim) + (org-faces-priority-a . org-faces-priority-a-dim)) + "Sample of keyword faces that must keep dedicated -dim variants.") + +(ert-deftest test-auto-dim-config-org-structure-faces-flat-dim () + "Normal: org heading, link, and tag faces remap to the flat dim face." + (skip-unless (file-directory-p test-auto-dim--fork)) + (require 'auto-dim-config) + (dolist (face test-auto-dim--flat-dimmed-org-faces) + (let ((entry (assq face auto-dim-other-buffers-affected-faces))) + (should entry) + (should (eq 'auto-dim-other-buffers (car (cdr entry)))) + (should (null (cdr (cdr entry))))))) + +(ert-deftest test-auto-dim-config-link-faces-flat-dim () + "Normal: the built-in `link' and `link-visited' faces flat-dim. +Without these, links in help, info, and customize buffers stay lit while +the rest of an unfocused window fades. `org-link' is a separate face and +is covered above." + (skip-unless (file-directory-p test-auto-dim--fork)) + (require 'auto-dim-config) + (dolist (face test-auto-dim--flat-dimmed-link-faces) + (let ((entry (assq face auto-dim-other-buffers-affected-faces))) + (should entry) + (should (eq 'auto-dim-other-buffers (car (cdr entry)))) + (should (null (cdr (cdr entry))))))) + +(ert-deftest test-auto-dim-config-link-underline-survives-the-remap () + "Boundary: the dim face sets no `:underline', so the link cue survives. +A relative remap layers the dim face over the base face, so an underline +the dim face does not specify falls through from `link'. If the theme ever +gives `auto-dim-other-buffers' an `:underline', dimmed links stop looking +like links and this test says so." + (skip-unless (file-directory-p test-auto-dim--fork)) + (require 'auto-dim-config) + (should (eq 'unspecified + (face-attribute 'auto-dim-other-buffers :underline nil nil)))) + +(ert-deftest test-auto-dim-config-superstar-bullets-flat-dim () + "Normal: org-superstar's heading stars and list bullets flat-dim. +They were the last thing left lit in an unfocused org window." + (skip-unless (file-directory-p test-auto-dim--fork)) + (require 'auto-dim-config) + (dolist (face test-auto-dim--flat-dimmed-superstar-faces) + (let ((entry (assq face auto-dim-other-buffers-affected-faces))) + (should entry) + (should (eq 'auto-dim-other-buffers (car (cdr entry)))) + (should (null (cdr (cdr entry))))))) + +(ert-deftest test-auto-dim-config-superstar-leading-uses-hide-face () + "Error: `org-superstar-leading' takes the -hide face, never the flat dim. +Its foreground is the background colour, which is what keeps hidden leading +stars invisible. Flat-dimming it would give them the dim face's visible grey +and reveal stars the user chose to hide. Same contract as `org-hide'." + (skip-unless (file-directory-p test-auto-dim--fork)) + (require 'auto-dim-config) + (should-not (memq 'org-superstar-leading + test-auto-dim--flat-dimmed-superstar-faces)) + (let ((entry (assq 'org-superstar-leading auto-dim-other-buffers-affected-faces))) + (should entry) + (should (eq 'auto-dim-other-buffers-hide (car (cdr entry)))))) + +(ert-deftest test-auto-dim-config-hide-class-faces-use-hide-face () + "Error: every background-coloured face takes the -hide face. +`org-hide', `org-superstar-leading' and `org-indent' all resolve to the +background colour, which is what keeps folded text, leading stars and indent +prefixes invisible. Flat-dimming any of them reveals what the user hid." + (skip-unless (file-directory-p test-auto-dim--fork)) + (require 'auto-dim-config) + (dolist (face test-auto-dim--hide-class-faces) + (let ((entry (assq face auto-dim-other-buffers-affected-faces))) + (should entry) + (should (eq 'auto-dim-other-buffers-hide (car (cdr entry))))))) + +(ert-deftest test-auto-dim-config-no-org-face-left-unmapped () + "Boundary: a fontified org buffer uses no face we forgot to handle. +Four rounds of this bug all had the same shape: a face nobody enumerated, +sitting ahead of a mapped face in a face list and outranking it. This walks +a representative buffer, collects every face it actually uses (including the +`line-prefix' and `wrap-prefix' org-indent hangs its faces on), and fails on +anything that is neither mapped nor deliberately excluded. + +Built-in org only. org-superstar and org-drill are elpa packages, and the +test run has no `package-initialize', so their faces are pinned by name in +the tests above instead." + (skip-unless (file-directory-p test-auto-dim--fork)) + (require 'auto-dim-config) + (require 'org) + (let ((used (make-hash-table :test #'eq)) + (allowed (append test-auto-dim--no-foreground-faces + ;; Keyword class: deliberately unmapped so status stays + ;; readable in an unfocused window. Pinned by + ;; test-auto-dim-config-todo-priority-faces-not-flat-dimmed. + '(org-todo org-priority) + (mapcar #'car auto-dim-other-buffers-affected-faces)))) + (with-temp-buffer + (insert "#+TITLE: T\n#+AUTHOR: A\n\n* H1 :tag:\n** TODO [#A] task\n" + "DEADLINE: <2026-07-10 Fri>\n:PROPERTIES:\n:K: v\n:END:\n" + "Body ~verbatim~ =code= [[https://x.org][link]].\n" + "| a | b |\n|---+---|\n| 1 | 2 |\n" + "#+begin_src sh\necho hi\n#+end_src\n" + "- [X] done item\n") + (org-mode) + (font-lock-ensure) + (let ((p (point-min))) + (while (< p (point-max)) + (dolist (f (let ((v (get-text-property p 'face))) + (if (listp v) v (list v)))) + (when (and f (symbolp f)) (puthash f t used))) + (dolist (prop '(line-prefix wrap-prefix)) + (let ((s (get-text-property p prop))) + (when (stringp s) + (dolist (f (let ((v (get-text-property 0 'face s))) + (if (listp v) v (list v)))) + (when (and f (symbolp f)) (puthash f t used)))))) + (setq p (1+ p))))) + (let (unmapped) + (maphash (lambda (face _v) + (unless (memq face allowed) (push face unmapped))) + used) + (should (equal nil (sort unmapped #'string<)))))) + +(ert-deftest test-auto-dim-config-keyword-faces-keep-dim-variants () + "Boundary: org TODO-keyword faces keep dedicated -dim variants, not flat dim. +Keyword status is scanned across unfocused windows, so it earns a variant; +heading colour does not. Guards the flat-dim change from over-reaching." + (skip-unless (file-directory-p test-auto-dim--fork)) + (require 'auto-dim-config) + (dolist (pair test-auto-dim--keyword-dim-variants) + (let ((entry (assq (car pair) auto-dim-other-buffers-affected-faces))) + (should entry) + (should (eq (cdr pair) (car (cdr entry))))))) + +(ert-deftest test-auto-dim-config-todo-priority-faces-not-flat-dimmed () + "Boundary: `org-todo' and `org-priority' are never flat-dimmed. +They are keyword class. Dimming them would erase the status colour the +-dim variants exist to preserve, so they stay out of the flat-dim set." + (skip-unless (file-directory-p test-auto-dim--fork)) + (require 'auto-dim-config) + (dolist (face '(org-todo org-priority)) + (should-not (memq face test-auto-dim--flat-dimmed-org-faces)) + (let ((entry (assq face auto-dim-other-buffers-affected-faces))) + (should-not (and entry (eq 'auto-dim-other-buffers (car (cdr entry)))))))) + +(ert-deftest test-auto-dim-config-org-hide-uses-hide-face () + "Boundary: `org-hide' remaps to the -hide face, not the flat dim face. +Flat-dimming it would give folded text a visible foreground." + (skip-unless (file-directory-p test-auto-dim--fork)) + (require 'auto-dim-config) + (let ((entry (assq 'org-hide auto-dim-other-buffers-affected-faces))) + (should entry) + (should (eq 'auto-dim-other-buffers-hide (car (cdr entry)))))) + (ert-deftest test-auto-dim-config-never-dim-dashboard-exempts-dashboard () "Normal: the *dashboard* buffer is exempt from dimming." (skip-unless (file-directory-p test-auto-dim--fork)) diff --git a/tests/test-calendar-sync--get-exdates.el b/tests/test-calendar-sync--get-exdates.el index 3283bbae..981a1857 100644 --- a/tests/test-calendar-sync--get-exdates.el +++ b/tests/test-calendar-sync--get-exdates.el @@ -103,6 +103,35 @@ END:VEVENT")) (should (= 1 (length result))) (should (string= "20260210T130000" (car result)))))) +(ert-deftest test-calendar-sync--get-exdates-boundary-comma-separated-returns-all () + "Boundary: comma-separated EXDATE values on one line are each returned. +RFC 5545 permits multiple datetimes per EXDATE line; missing the split +drops those exclusions, so cancelled instances resurrect in the agenda." + (let ((event "BEGIN:VEVENT +DTSTART:20260203T130000 +RRULE:FREQ=WEEKLY;BYDAY=TU +EXDATE:20260210T130000,20260217T130000,20260224T130000 +SUMMARY:Weekly Meeting +END:VEVENT")) + (let ((result (calendar-sync--get-exdates event))) + (should (= 3 (length result))) + (should (member "20260210T130000" result)) + (should (member "20260217T130000" result)) + (should (member "20260224T130000" result))))) + +(ert-deftest test-calendar-sync--get-exdates-boundary-comma-separated-with-tzid () + "Boundary: comma-separated EXDATE values sharing a TZID are each returned." + (let ((event "BEGIN:VEVENT +DTSTART;TZID=America/New_York:20260203T130000 +RRULE:FREQ=WEEKLY;BYDAY=TU +EXDATE;TZID=America/New_York:20260210T130000,20260217T130000 +SUMMARY:Weekly Meeting +END:VEVENT")) + (let ((result (calendar-sync--get-exdates event))) + (should (= 2 (length result))) + (should (member "20260210T130000" result)) + (should (member "20260217T130000" result))))) + ;;; Error Cases (ert-deftest test-calendar-sync--get-exdates-error-empty-string-returns-nil () diff --git a/tests/test-calendar-sync-source-fetch-sentinel.el b/tests/test-calendar-sync-source-fetch-sentinel.el new file mode 100644 index 00000000..0b7ba1cf --- /dev/null +++ b/tests/test-calendar-sync-source-fetch-sentinel.el @@ -0,0 +1,72 @@ +;;; test-calendar-sync-source-fetch-sentinel.el --- Tests for the fetch sentinel -*- lexical-binding: t; -*- + +;;; Commentary: +;; calendar-sync--fetch-sentinel-finish is the extracted tail of the async +;; .ics fetch sentinel. The async-worker tests stub the whole fetch, so its +;; success, failure, and temp-file-cleanup branches were never exercised. +;; These tests drive the helper directly with fake success/failure inputs, +;; no live curl process. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'calendar-sync) + +(ert-deftest test-calendar-sync-fetch-sentinel-success-passes-temp-file () + "Normal: on success the callback gets the temp file and it is not deleted." + (let ((temp-file (make-temp-file "calendar-sync-sentinel-" nil ".ics")) + (buffer (generate-new-buffer " *cs-test*")) + (got 'unset)) + (unwind-protect + (progn + (calendar-sync--fetch-sentinel-finish + t "finished\n" temp-file buffer (lambda (r) (setq got r))) + (should (equal got temp-file)) + (should (file-exists-p temp-file)) + (should-not (buffer-live-p buffer))) + (when (file-exists-p temp-file) (delete-file temp-file)) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(ert-deftest test-calendar-sync-fetch-sentinel-failure-deletes-and-passes-nil () + "Error: on failure the temp file is deleted and the callback gets nil." + (let ((temp-file (make-temp-file "calendar-sync-sentinel-" nil ".ics")) + (buffer (generate-new-buffer " *cs-test*")) + (got 'unset) + (logged nil)) + (unwind-protect + (cl-letf (((symbol-function 'calendar-sync--log-silently) + (lambda (&rest _) (setq logged t)))) + (calendar-sync--fetch-sentinel-finish + nil "exited abnormally with code 22\n" temp-file buffer + (lambda (r) (setq got r))) + (should (null got)) + (should-not (file-exists-p temp-file)) + (should logged) + (should-not (buffer-live-p buffer))) + (when (file-exists-p temp-file) (delete-file temp-file)) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(ert-deftest test-calendar-sync-fetch-sentinel-failure-tolerates-missing-temp-file () + "Boundary: failure with the temp file already gone does not error." + (let ((temp-file (make-temp-file "calendar-sync-sentinel-" nil ".ics")) + (got 'unset)) + (delete-file temp-file) + (cl-letf (((symbol-function 'calendar-sync--log-silently) #'ignore)) + (calendar-sync--fetch-sentinel-finish + nil "failed\n" temp-file nil (lambda (r) (setq got r))) + (should (null got))))) + +(ert-deftest test-calendar-sync-fetch-sentinel-tolerates-dead-buffer () + "Boundary: a already-dead process buffer is not touched on success." + (let ((temp-file (make-temp-file "calendar-sync-sentinel-" nil ".ics")) + (got 'unset)) + (unwind-protect + (progn + (calendar-sync--fetch-sentinel-finish + t "finished\n" temp-file nil (lambda (r) (setq got r))) + (should (equal got temp-file))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(provide 'test-calendar-sync-source-fetch-sentinel) +;;; test-calendar-sync-source-fetch-sentinel.el ends here diff --git a/tests/test-config-utilities--recompile-emacs-home.el b/tests/test-config-utilities--recompile-emacs-home.el index 18d17f96..da364e24 100644 --- a/tests/test-config-utilities--recompile-emacs-home.el +++ b/tests/test-config-utilities--recompile-emacs-home.el @@ -81,20 +81,29 @@ Returns the temp dir path." (should-not (file-exists-p (expand-file-name "sub/c.elc" dir)))) (delete-directory dir t)))) -(ert-deftest test-config-utilities-recompile-removes-eln-dir-on-native-path () - "Boundary: the native path removes the eln cache directory when present." +(ert-deftest test-config-utilities-recompile-removes-eln-cache-dir-on-native-path () + "Boundary: the native path removes the eln-cache directory when present. +The native cache is eln-cache/, not eln/, so that is the directory to clear." (let ((dir (test-config-utilities--make-recompile-fixture)) - (eln-dir nil)) + (eln-cache-dir nil)) (unwind-protect (progn - (setq eln-dir (expand-file-name "eln" dir)) - (make-directory eln-dir) - (with-temp-file (expand-file-name "stale.eln" eln-dir) (insert "")) + (setq eln-cache-dir (expand-file-name "eln-cache" dir)) + (make-directory eln-cache-dir) + (with-temp-file (expand-file-name "stale.eln" eln-cache-dir) (insert "")) (cl-letf (((symbol-function 'native-compile-async) (lambda (&rest _) nil))) (cj/--recompile-emacs-home dir t)) - (should-not (file-exists-p eln-dir))) + (should-not (file-exists-p eln-cache-dir))) (delete-directory dir t)))) +(ert-deftest test-config-utilities-native-comp-detection-not-boundp () + "Regression: native-comp detection must not test `boundp' of the async +function -- native-compile-async is a function, so `boundp' is always nil and +native compilation would never be selected. On a native-comp build, detection +returns non-nil." + (when (and (fboundp 'native-comp-available-p) (native-comp-available-p)) + (should (cj/--native-comp-p)))) + (ert-deftest test-config-utilities-recompile-removes-elc-dir-on-byte-path () "Boundary: the byte path removes the elc cache directory when present." (let ((dir (test-config-utilities--make-recompile-fixture)) diff --git a/tests/test-custom-buffer-file--view-email-in-buffer.el b/tests/test-custom-buffer-file--view-email-in-buffer.el index 99e0e44d..b0209b78 100644 --- a/tests/test-custom-buffer-file--view-email-in-buffer.el +++ b/tests/test-custom-buffer-file--view-email-in-buffer.el @@ -13,6 +13,7 @@ ;;; Code: (require 'ert) +(require 'cl-lib) (require 'testutil-general) (require 'custom-buffer-file) @@ -233,5 +234,24 @@ Note: shr may insert newlines between words for wrapping." (kill-buffer))) (test-email--teardown))) +(ert-deftest test-custom-buffer-file--view-email-no-displayable-destroys-handle () + "Error: the MIME handle is destroyed even when no displayable part is found. +The `user-error' fires before cleanup, so without `unwind-protect' the dissected +handle leaks." + (test-email--setup) + (unwind-protect + (let ((eml-file (test-email--create-eml-file test-email--image-only)) + (destroy-called nil)) + (with-current-buffer (find-file-noselect eml-file) + (let ((real (symbol-function 'mm-destroy-parts))) + (cl-letf (((symbol-function 'mm-destroy-parts) + (lambda (handle) + (setq destroy-called t) + (funcall real handle)))) + (should-error (cj/view-email-in-buffer) :type 'user-error))) + (should destroy-called) + (kill-buffer))) + (test-email--teardown))) + (provide 'test-custom-buffer-file--view-email-in-buffer) ;;; test-custom-buffer-file--view-email-in-buffer.el ends here diff --git a/tests/test-custom-buffer-file-copy-link-to-buffer-file.el b/tests/test-custom-buffer-file-copy-link-to-buffer-file.el index 262968d6..5ee57b3a 100644 --- a/tests/test-custom-buffer-file-copy-link-to-buffer-file.el +++ b/tests/test-custom-buffer-file-copy-link-to-buffer-file.el @@ -4,7 +4,8 @@ ;; Tests for the cj/copy-link-to-buffer-file function from custom-buffer-file.el ;; ;; This function copies the full file:// path of the current buffer's file to -;; the kill ring. For non-file buffers, it does nothing (no error). +;; the kill ring. For non-file buffers, it signals a user-error, matching its +;; sibling copy commands. ;;; Code: @@ -58,12 +59,12 @@ (test-copy-link-teardown))) (ert-deftest test-copy-link-non-file-buffer () - "Should do nothing for non-file buffer without error." + "Error: a non-file buffer signals `user-error' and leaves the kill ring alone." (test-copy-link-setup) (unwind-protect (with-temp-buffer (setq kill-ring nil) - (cj/copy-link-to-buffer-file) + (should-error (cj/copy-link-to-buffer-file) :type 'user-error) (should (null kill-ring))) (test-copy-link-teardown))) @@ -195,13 +196,13 @@ (test-copy-link-teardown))) (ert-deftest test-copy-link-scratch-buffer () - "Should do nothing for *scratch* buffer." + "Error: the *scratch* buffer (no file) signals `user-error'." (test-copy-link-setup) (unwind-protect (progn (setq kill-ring nil) (with-current-buffer "*scratch*" - (cj/copy-link-to-buffer-file) + (should-error (cj/copy-link-to-buffer-file) :type 'user-error) (should (null kill-ring)))) (test-copy-link-teardown))) diff --git a/tests/test-custom-case-title-case-region.el b/tests/test-custom-case-title-case-region.el index 383ae927..82b4966a 100644 --- a/tests/test-custom-case-title-case-region.el +++ b/tests/test-custom-case-title-case-region.el @@ -60,16 +60,17 @@ an active region, and return the result." "The Art of War"))) (ert-deftest test-custom-case-title-case-region-normal-all-minor-words () - "All minor words in the skip list should be lowercased in mid-sentence." + "All minor words in the skip list should be lowercased in mid-sentence. +\"is\" is a linking verb, so it is a major word and is capitalized." (should (equal (test-title-case--on-string "go a an and as at but by for if in is nor of on or so the to yet go") - "Go a an and as at but by for if in is nor of on or so the to yet Go"))) + "Go a an and as at but by for if in Is nor of on or so the to yet Go"))) (ert-deftest test-custom-case-title-case-region-normal-four-letter-words-capitalized () "Words of four or more letters should always be capitalized. -Note: 'is' is explicitly in the minor word list, so it stays lowercase." +\"is\" is a linking verb (a major word), so it is capitalized too." (should (equal (test-title-case--on-string "this is from that with over") - "This is From That With Over"))) + "This Is From That With Over"))) (ert-deftest test-custom-case-title-case-region-normal-allcaps-input () "All-caps input should be downcased first, then title-cased." @@ -94,7 +95,16 @@ Note: 'is' is explicitly in the minor word list, so it stays lowercase." (ert-deftest test-custom-case-title-case-region-normal-question-resets () "Word immediately after a question mark should be capitalized, even if minor." (should (equal (test-title-case--on-string "really? the answer is no") - "Really? The Answer is No"))) + "Really? The Answer Is No"))) + +(ert-deftest test-custom-case-title-case-region-normal-last-word-capitalized () + "The last word is always capitalized, even a minor one." + (should (equal (test-title-case--on-string "the art of") "The Art Of"))) + +(ert-deftest test-custom-case-title-case-region-normal-period-restarts () + "A word after a sentence-ending period should be capitalized." + (should (equal (test-title-case--on-string "one. the next sentence") + "One. The Next Sentence"))) (ert-deftest test-custom-case-title-case-region-normal-hyphenated-word () "Second part of a hyphenated word should NOT be capitalized." @@ -141,7 +151,7 @@ Note: 'is' is explicitly in the minor word list, so it stays lowercase." (ert-deftest test-custom-case-title-case-region-boundary-unicode-words () "Unicode characters should pass through without error." (should (equal (test-title-case--on-string "the café is nice") - "The Café is Nice"))) + "The Café Is Nice"))) (ert-deftest test-custom-case-title-case-region-boundary-numbers-in-text () "Numbers mixed with text should not break title casing." diff --git a/tests/test-custom-comments-comment-inline-border.el b/tests/test-custom-comments-comment-inline-border.el index 78e86035..305a2c7a 100644 --- a/tests/test-custom-comments-comment-inline-border.el +++ b/tests/test-custom-comments-comment-inline-border.el @@ -120,6 +120,16 @@ Returns the buffer string for assertions." (let ((result (test-inline-border-at-column 0 ";;" "" "=" "" 10))) (should (string-match-p ";" result)))) +(ert-deftest test-inline-border-elisp-fills-exact-width-all-parities () + "Boundary: even, odd, and empty text all fill LENGTH exactly. +Even-length and empty text used to come out two columns short because the +right decoration count keyed off text-length parity instead of the remaining +width, so stacked dividers of differing text lengths misaligned." + (dolist (text '("" "X" "EVEN" "ODD" "Header")) + (let* ((result (test-inline-border-at-column 0 ";;" "" "=" text 50)) + (line (string-trim-right result "\n"))) + (should (= 50 (length line)))))) + (ert-deftest test-inline-border-elisp-text-centering-even () "Should center text properly with even length." (let ((result (test-inline-border-at-column 0 ";;" "" "=" "EVEN" 70))) diff --git a/tests/test-custom-comments-comment-reformat.el b/tests/test-custom-comments-comment-reformat.el index 83248aee..91b7dfe3 100644 --- a/tests/test-custom-comments-comment-reformat.el +++ b/tests/test-custom-comments-comment-reformat.el @@ -146,19 +146,12 @@ Insert CONTENT-BEFORE, select all, run cj/comment-reformat, verify EXPECTED-AFTE (should (string-match-p ";; Start line 1.*Start line 2" (buffer-string))))) (ert-deftest test-comment-reformat-elisp-no-region-active () - "Should show message when no region selected." + "Should signal `user-error' when no region is selected." (with-temp-buffer (emacs-lisp-mode) (insert ";; Comment line") (deactivate-mark) - (let ((message-log-max nil) - (messages '())) - ;; Capture messages - (cl-letf (((symbol-function 'message) - (lambda (format-string &rest args) - (push (apply #'format format-string args) messages)))) - (cj/comment-reformat) - (should (string-match-p "No region was selected" (car messages))))))) + (should-error (cj/comment-reformat) :type 'user-error))) (ert-deftest test-comment-reformat-elisp-read-only-buffer () "Should signal error in read-only buffer." diff --git a/tests/test-custom-datetime-all-methods.el b/tests/test-custom-datetime-all-methods.el index 62b421bd..1f31c6c4 100644 --- a/tests/test-custom-datetime-all-methods.el +++ b/tests/test-custom-datetime-all-methods.el @@ -54,9 +54,10 @@ (should (string-match-p "14:30:45" result)))) (ert-deftest test-custom-datetime-all-methods-normal-sortable-time () - "cj/insert-sortable-time should insert time with AM/PM and timezone." + "cj/insert-sortable-time should insert 24-hour time so it sorts lexically." (let ((result (test-datetime--run #'cj/insert-sortable-time))) - (should (string-match-p "02:30:45 PM" result)))) + (should (string-match-p "14:30:45" result)) + (should-not (string-match-p "PM" result)))) (ert-deftest test-custom-datetime-all-methods-normal-readable-time () "cj/insert-readable-time should insert short time with AM/PM." diff --git a/tests/test-custom-line-paragraph-duplicate-line-or-region.el b/tests/test-custom-line-paragraph-duplicate-line-or-region.el index 84f5bc2d..9e501086 100644 --- a/tests/test-custom-line-paragraph-duplicate-line-or-region.el +++ b/tests/test-custom-line-paragraph-duplicate-line-or-region.el @@ -327,6 +327,40 @@ (should (> (length (buffer-string)) (length "line one\nline two\nline three")))) (test-duplicate-line-or-region-teardown))) +(ert-deftest test-duplicate-line-or-region-mid-line-bounds-duplicate-whole-lines () + "A region ending mid-line duplicates every whole line it touches, no splits. +The old open-line loop split the mid-line-ending line instead." + (test-duplicate-line-or-region-setup) + (unwind-protect + (with-temp-buffer + (insert "aaa\nbbb\nccc") + (transient-mark-mode 1) + (goto-char (point-min)) + (forward-char 1) ; mid first line + (set-mark (point)) + (forward-line 1) + (forward-char 2) ; mid second line + (activate-mark) + (cj/duplicate-line-or-region) + (should (string= "aaa\nbbb\naaa\nbbb\nccc" (buffer-string)))) + (test-duplicate-line-or-region-teardown))) + +(ert-deftest test-duplicate-line-or-region-ends-at-bol-no-extra-empty-line () + "A region ending at beginning-of-line duplicates only the fully-included lines. +The old open-line loop duplicated a stray empty line here." + (test-duplicate-line-or-region-setup) + (unwind-protect + (with-temp-buffer + (insert "aaa\nbbb\nccc") + (transient-mark-mode 1) + (goto-char (point-min)) + (set-mark (point)) + (forward-line 2) ; region "aaa\nbbb\n", ends at bol of ccc + (activate-mark) + (cj/duplicate-line-or-region) + (should (string= "aaa\nbbb\naaa\nbbb\nccc" (buffer-string)))) + (test-duplicate-line-or-region-teardown))) + (ert-deftest test-duplicate-line-or-region-trailing-whitespace () "Should preserve trailing whitespace." (test-duplicate-line-or-region-setup) diff --git a/tests/test-custom-line-paragraph-join-line-or-region.el b/tests/test-custom-line-paragraph-join-line-or-region.el index f8738910..5d421683 100644 --- a/tests/test-custom-line-paragraph-join-line-or-region.el +++ b/tests/test-custom-line-paragraph-join-line-or-region.el @@ -62,8 +62,8 @@ (should (string-match-p "line one line two" (buffer-string)))) (test-join-line-or-region-teardown))) -(ert-deftest test-join-line-or-region-no-region-adds-newline-after-join () - "Without region, should add newline after joining." +(ert-deftest test-join-line-or-region-no-region-adds-newline-at-end-of-buffer () + "Without region, joining the last line adds a trailing newline at end of buffer." (test-join-line-or-region-setup) (unwind-protect (with-temp-buffer @@ -73,6 +73,20 @@ (should (string-suffix-p "\n" (buffer-string)))) (test-join-line-or-region-teardown))) +(ert-deftest test-join-line-or-region-no-region-mid-buffer-no-blank-line () + "Without region, joining a non-last line must not insert a blank line. +The trailing newline belongs only at end of buffer; adding it unconditionally +left a stray blank line between the joined line and the rest of the buffer." + (test-join-line-or-region-setup) + (unwind-protect + (with-temp-buffer + (insert "line one\nline two\nline three") + (goto-char (point-min)) + (forward-line 1) ; point on "line two", not the last line + (cj/join-line-or-region) + (should (string= "line one line two\nline three" (buffer-string)))) + (test-join-line-or-region-teardown))) + (ert-deftest test-join-line-or-region-with-region-joins-all-lines () "With region, should join all lines in region." (test-join-line-or-region-setup) diff --git a/tests/test-custom-line-paragraph-jump-to-matching-paren.el b/tests/test-custom-line-paragraph-jump-to-matching-paren.el index 31853da6..bd24faed 100644 --- a/tests/test-custom-line-paragraph-jump-to-matching-paren.el +++ b/tests/test-custom-line-paragraph-jump-to-matching-paren.el @@ -83,11 +83,11 @@ POINT-POSITION is 1-indexed (1 = first character)." ;;; Normal Cases - Backward Jump (Closing to Opening) (ert-deftest test-jump-paren-backward-simple () - "Should jump backward from closing paren to opening paren." + "Should jump from a closing paren to its matching opening paren." ;; Text: "(hello)" ;; Start at position 7 (on closing paren) - ;; Should end at position 2 (after opening paren) - (should (= 2 (test-jump-to-matching-paren "(hello)" 7)))) + ;; Should end at position 1 (the matching opening paren) + (should (= 1 (test-jump-to-matching-paren "(hello)" 7)))) (ert-deftest test-jump-paren-backward-nested () "Should jump backward over nested parens from after outer closing." @@ -97,11 +97,11 @@ POINT-POSITION is 1-indexed (1 = first character)." (should (= 1 (test-jump-to-matching-paren "(foo (bar))" 12)))) (ert-deftest test-jump-paren-backward-inner-nested () - "Should jump backward from inner closing paren." + "Should jump from an inner closing paren to its matching inner opener." ;; Text: "(foo (bar))" ;; Start at position 10 (on inner closing paren) - ;; Should end at position 7 (after inner opening paren) - (should (= 7 (test-jump-to-matching-paren "(foo (bar))" 10)))) + ;; Should end at position 6 (the matching inner opening paren) + (should (= 6 (test-jump-to-matching-paren "(foo (bar))" 10)))) (ert-deftest test-jump-bracket-backward () "Should jump backward from after closing bracket." @@ -145,11 +145,11 @@ POINT-POSITION is 1-indexed (1 = first character)." (should (= 1 (test-jump-to-matching-paren "(hello" 1)))) (ert-deftest test-jump-paren-unmatched-closing () - "Should move to beginning from unmatched closing paren." + "Should stay put on an unmatched closing paren (no matching opener)." ;; Text: "hello)" ;; Start at position 6 (on closing paren with no opening) - ;; backward-sexp with unmatched closing paren goes to beginning - (should (= 1 (test-jump-to-matching-paren "hello)" 6)))) + ;; There is no matching opener, so point is restored and stays at 6 + (should (= 6 (test-jump-to-matching-paren "hello)" 6)))) ;;; Boundary Cases - Empty Delimiters @@ -161,11 +161,11 @@ POINT-POSITION is 1-indexed (1 = first character)." (should (= 3 (test-jump-to-matching-paren "()" 1)))) (ert-deftest test-jump-paren-empty-backward () - "Should stay put when on closing paren of empty parens." + "Should jump from the closing paren of empty parens to its opener." ;; Text: "()" ;; Start at position 2 (on closing paren) - ;; backward-sexp from closing of empty parens gives an error, so stays at 2 - (should (= 2 (test-jump-to-matching-paren "()" 2)))) + ;; Should end at position 1 (the matching opening paren) + (should (= 1 (test-jump-to-matching-paren "()" 2)))) ;;; Boundary Cases - Multiple Delimiter Types diff --git a/tests/test-custom-ordering-number-lines.el b/tests/test-custom-ordering-number-lines.el index adda84f0..142e5561 100644 --- a/tests/test-custom-ordering-number-lines.el +++ b/tests/test-custom-ordering-number-lines.el @@ -122,9 +122,10 @@ Returns the transformed string." (should (string= result "1. ")))) (ert-deftest test-number-lines-empty-lines () - "Should number empty lines." + "Should number empty lines, treating the final newline as a terminator. +The old split counted the trailing newline as a spurious third line." (let ((result (test-number-lines "\n\n" "N. " nil))) - (should (string= result "1. \n2. \n3. ")))) + (should (string= result "1. \n2. \n")))) (ert-deftest test-number-lines-with-existing-numbers () "Should number lines that already have content." diff --git a/tests/test-custom-ordering-reverse-lines.el b/tests/test-custom-ordering-reverse-lines.el index 3c71362d..5b8c01ac 100644 --- a/tests/test-custom-ordering-reverse-lines.el +++ b/tests/test-custom-ordering-reverse-lines.el @@ -86,9 +86,11 @@ Returns the transformed string." (should (string= result "b\n\na")))) (ert-deftest test-reverse-lines-trailing-newline () - "Should handle trailing newline." + "Should reverse the lines and preserve the trailing newline. +The old split dropped the trailing newline into a leading empty line, +producing \"\\nline2\\nline1\"." (let ((result (test-reverse-lines "line1\nline2\n"))) - (should (string= result "\nline2\nline1")))) + (should (string= result "line2\nline1\n")))) (ert-deftest test-reverse-lines-only-newlines () "Should reverse lines that are only newlines." diff --git a/tests/test-custom-text-enclose-indent.el b/tests/test-custom-text-enclose-indent.el index e9042d35..f37d1800 100644 --- a/tests/test-custom-text-enclose-indent.el +++ b/tests/test-custom-text-enclose-indent.el @@ -43,6 +43,35 @@ Returns the transformed string." Returns the transformed string." (cj/--dedent-lines text count)) +;;; Interactive default resolution (the prefix-arg decoupling fix) + +(ert-deftest test-indent-lines-interactive-no-prefix-is-four-spaces () + "Interactive: no prefix indents by 4, spaces when `indent-tabs-mode' is nil. +The old \"p\\nP\" spec defaulted count to 1 and forced tabs on any prefix." + (with-temp-buffer + (setq-local indent-tabs-mode nil) + (insert "line") + (let ((current-prefix-arg nil)) + (call-interactively #'cj/indent-lines-in-region-or-buffer)) + (should (string= " line" (buffer-string))))) + +(ert-deftest test-indent-lines-interactive-follows-indent-tabs-mode () + "Interactive: tabs-vs-spaces follows `indent-tabs-mode', not the prefix arg." + (with-temp-buffer + (setq-local indent-tabs-mode t) + (insert "line") + (let ((current-prefix-arg nil)) + (call-interactively #'cj/indent-lines-in-region-or-buffer)) + (should (string= "\t\t\t\tline" (buffer-string))))) + +(ert-deftest test-dedent-lines-interactive-no-prefix-is-four () + "Interactive: no prefix removes up to 4 leading whitespace characters." + (with-temp-buffer + (insert " line") ; eight leading spaces + (let ((current-prefix-arg nil)) + (call-interactively #'cj/dedent-lines-in-region-or-buffer)) + (should (string= " line" (buffer-string))))) + ;;; Indent Tests - Normal Cases with Spaces (ert-deftest test-indent-single-line-4-spaces () diff --git a/tests/test-dashboard-config-launchers.el b/tests/test-dashboard-config-launchers.el index 53c46caa..76fbcc42 100644 --- a/tests/test-dashboard-config-launchers.el +++ b/tests/test-dashboard-config-launchers.el @@ -28,20 +28,21 @@ ;; Telegram moved from "g" to "G" so "g" is free for dashboard refresh. ;; Signal ("S") added as the 14th launcher. ;; Weather ("w") added after Agenda as the 15th launcher (top-row daily glance). -(defconst test-dash--keys '("c" "d" "t" "a" "w" "r" "b" "f" "m" "e" "i" "G" "s" "l" "S")) +(defconst test-dash--keys '("c" "d" "t" "a" "w" "r" "b" "f" "m" "e" "i" "G" "s" "l")) ;; ----------------------------- launcher table -------------------------------- (ert-deftest test-dashboard-launchers-keys-in-order () - "Normal: 15 launchers with the expected keys in display order." - (should (= 15 (length cj/dashboard--launchers))) + "Normal: 14 launchers with the expected keys in display order. +(Signal left the table when the signel client was retired to archive/.)" + (should (= 14 (length cj/dashboard--launchers))) (should (equal test-dash--keys (mapcar (lambda (l) (nth 0 l)) cj/dashboard--launchers)))) (ert-deftest test-dashboard-launchers-labels-in-order () "Normal: labels in display order (Telegram and Slack reordered so Slack sits next to Linear on the last navigator row)." (should (equal '("Code" "Files" "Terminal" "Agenda" "Weather" "Feeds" "Books" - "Flashcards" "Music" "Email" "IRC" "Telegram" "Slack" "Linear" "Signal") + "Flashcards" "Music" "Email" "IRC" "Telegram" "Slack" "Linear") (mapcar (lambda (l) (nth 3 l)) cj/dashboard--launchers)))) (ert-deftest test-dashboard-row-sizes-cover-all-launchers () @@ -51,9 +52,9 @@ next to Linear on the last navigator row)." ;; --------------------------- navigator rows ---------------------------------- -(ert-deftest test-dashboard-navigator-rows-grouped-5-4-3-3 () - "Normal: navigator derives rows per `cj/dashboard--row-sizes' (5 4 3 3), with -Weather joining the top row and Slack, Linear, and Signal sharing the last row." +(ert-deftest test-dashboard-navigator-rows-grouped-5-4-3-2 () + "Normal: navigator derives rows per `cj/dashboard--row-sizes' (5 4 3 2), with +Weather joining the top row and Slack and Linear pairing on the last row." (cl-letf (((symbol-function 'nerd-icons-faicon) (lambda (n &rest _) (concat "I:" n))) ((symbol-function 'nerd-icons-devicon) (lambda (n &rest _) (concat "I:" n))) ((symbol-function 'nerd-icons-mdicon) (lambda (n &rest _) (concat "I:" n))) @@ -62,10 +63,10 @@ Weather joining the top row and Slack, Linear, and Signal sharing the last row." ((symbol-function 'nerd-icons-wicon) (lambda (n &rest _) (concat "I:" n)))) (let ((rows (cj/dashboard--navigator-rows))) (should (= 4 (length rows))) - (should (equal '(5 4 3 3) (mapcar #'length rows))) + (should (equal '(5 4 3 2) (mapcar #'length rows))) (should (equal '("Code" "Files" "Terminal" "Agenda" "Weather") (mapcar (lambda (b) (nth 1 b)) (nth 0 rows)))) - (should (equal '("Slack" "Linear" "Signal") + (should (equal '("Slack" "Linear") (mapcar (lambda (b) (nth 1 b)) (nth 3 rows)))) (let ((btn (car (car rows)))) ; (icon label tooltip action nil " " "") (should (string= "I:nf-fa-code" (nth 0 btn))) @@ -100,7 +101,6 @@ Weather joining the top row and Slack, Linear, and Signal sharing the last row." ((symbol-function 'cj/slack-start) (lambda (&rest _) (push 'slack calls))) ((symbol-function 'cj/telega) (lambda (&rest _) (push 'tg calls))) ((symbol-function 'pearl-list-issues) (lambda (&rest _) (push 'linear calls))) - ((symbol-function 'cj/signel-message) (lambda (&rest _) (push 'signal calls))) ;; wttrin is invoked via `call-interactively', so the stub must be ;; a command -- a plain variadic lambda masked the real arity bug. ((symbol-function 'wttrin) (lambda (&rest _) (interactive) (push 'weather calls)))) @@ -112,9 +112,8 @@ Weather joining the top row and Slack, Linear, and Signal sharing the last row." (should (memq 'linear calls)) (should (memq 'm-toggle calls)) (should (memq 'm-load calls)) - (should (memq 'signal calls)) (should (memq 'weather calls)) - (should (= 16 (length calls)))))) ; 15 keys, Music fires two + (should (= 15 (length calls)))))) ; 14 keys, Music fires two (provide 'test-dashboard-config-launchers) ;;; test-dashboard-config-launchers.el ends here diff --git a/tests/test-eat-config--xtwinops.el b/tests/test-eat-config--xtwinops.el new file mode 100644 index 00000000..29f87f2f --- /dev/null +++ b/tests/test-eat-config--xtwinops.el @@ -0,0 +1,120 @@ +;;; test-eat-config--xtwinops.el --- Tests for the EAT XTWINOPS window-size reply -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for the XTWINOPS (CSI <n> t) window-size responder. eat 0.9.4 +;; has no CSI <n> t handler, so it silently drops the window-size requests +;; tmux 3.7b sends to learn the cell pixel size it needs before it will emit +;; Sixel -- images then never render inside EAT. The module answers three +;; requests: 14 (text area in pixels), 16 (cell size in pixels), 18 (text area +;; in characters). +;; +;; Two pure pieces carry the logic and are tested here directly: +;; - `cj/--eat-xtwinops-report' computes the reply string from the display and +;; cell dimensions. +;; - `cj/--eat-xtwinops-queries' extracts the request numbers from a chunk of +;; terminal output. +;; The thin accessor glue (`cj/--eat-send-window-size-report', which reads the +;; live `eat--t-term' struct) is verified in the running daemon, since eat's +;; structs are not loadable under `make test' (no package-initialize). The +;; detector's dispatch is tested against a recording stub of the responder. + +;;; Code: + +(require 'ert) + +;; Stub keymap dep before loading the module (matches the other module tests). +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +(require 'eat-config) + +;;; --------------------------- reply computation ---------------------------- + +(ert-deftest test-eat-config-xtwinops-report-normal-14-text-area-pixels () + "Normal: request 14 reports text-area size in pixels (rows*ch by cols*cw)." + ;; 80x24 chars, 10x20 px cells -> height 24*20=480, width 80*10=800. + (should (equal (cj/--eat-xtwinops-report 14 80 24 10 20) + "\e[4;480;800t"))) + +(ert-deftest test-eat-config-xtwinops-report-normal-16-cell-pixels () + "Normal: request 16 reports the cell size in pixels (height then width)." + (should (equal (cj/--eat-xtwinops-report 16 80 24 10 20) + "\e[6;20;10t"))) + +(ert-deftest test-eat-config-xtwinops-report-normal-18-text-area-chars () + "Normal: request 18 reports the text-area size in characters (rows then cols)." + (should (equal (cj/--eat-xtwinops-report 18 80 24 10 20) + "\e[8;24;80t"))) + +(ert-deftest test-eat-config-xtwinops-report-boundary-unit-cells () + "Boundary: with eat's default 1x1 px cells, pixel dims equal the char dims." + (should (equal (cj/--eat-xtwinops-report 14 80 24 1 1) "\e[4;24;80t")) + (should (equal (cj/--eat-xtwinops-report 16 80 24 1 1) "\e[6;1;1t"))) + +(ert-deftest test-eat-config-xtwinops-report-error-unknown-request-is-nil () + "Error: any request number other than 14/16/18 returns nil (unanswered)." + (should (null (cj/--eat-xtwinops-report 15 80 24 10 20))) + (should (null (cj/--eat-xtwinops-report 24 80 24 10 20))) + (should (null (cj/--eat-xtwinops-report nil 80 24 10 20)))) + +;;; ----------------------------- query detection ---------------------------- + +(ert-deftest test-eat-config-xtwinops-queries-normal-single () + "Normal: a lone CSI 14 t query is detected." + (should (equal (cj/--eat-xtwinops-queries "\e[14t") '(14)))) + +(ert-deftest test-eat-config-xtwinops-queries-normal-embedded-multiple () + "Normal: several queries embedded in other output are returned in order." + (should (equal (cj/--eat-xtwinops-queries "foo\e[14tbar\e[18tbaz\e[16t") + '(14 18 16)))) + +(ert-deftest test-eat-config-xtwinops-queries-boundary-none () + "Boundary: output with no XTWINOPS query returns nil, including empty." + (should (null (cj/--eat-xtwinops-queries ""))) + (should (null (cj/--eat-xtwinops-queries "hello\e[0m\e[2J")))) + +(ert-deftest test-eat-config-xtwinops-queries-error-unhandled-ops-ignored () + "Error: CSI t ops we do not answer (and parametrized forms) are not matched. +`\\e[24t' is a resize op, `\\e[3;14t' carries a leading param -- neither is the +bare CSI 14/16/18 t we answer, so both are ignored." + (should (null (cj/--eat-xtwinops-queries "\e[24t"))) + (should (null (cj/--eat-xtwinops-queries "\e[3;14t"))) + (should (null (cj/--eat-xtwinops-queries "\e[114t")))) + +;;; --------------------------- advice-needed guard -------------------------- + +(ert-deftest test-eat-config-xtwinops-advice-needed-normal-eat-0-9-4 () + "Normal: on eat 0.9.4 (no upstream CSI t clause) the advice is needed." + ;; The upstream parser clause defines `eat--t-send-window-size-report'; + ;; 0.9.4 does not, so it must be unbound here. + (should-not (fboundp 'eat--t-send-window-size-report)) + (should (cj/--eat-xtwinops-advice-needed-p))) + +(ert-deftest test-eat-config-xtwinops-advice-needed-boundary-upstream-ships () + "Boundary: once upstream defines the parser clause, the advice must NOT +install -- both would answer and tmux gets a double reply." + (cl-letf (((symbol-function 'eat--t-send-window-size-report) #'ignore)) + (should-not (cj/--eat-xtwinops-advice-needed-p)))) + +;;; ------------------------- detector dispatch (seam) ----------------------- + +(ert-deftest test-eat-config-xtwinops-answer-dispatches-once-per-query () + "Integration: the detector calls the responder once per query, in order. +Mocks only our own responder seam (`cj/--eat-send-window-size-report'); the +detector under test does the real query extraction." + (let ((calls '())) + (cl-letf (((symbol-function 'cj/--eat-send-window-size-report) + (lambda (n) (push n calls)))) + (cj/--eat-answer-xtwinops "\e[14t\e[16t\e[18t")) + (should (equal (nreverse calls) '(14 16 18))))) + +(ert-deftest test-eat-config-xtwinops-answer-no-query-no-call () + "Boundary: output with no query never calls the responder." + (let ((called nil)) + (cl-letf (((symbol-function 'cj/--eat-send-window-size-report) + (lambda (_n) (setq called t)))) + (cj/--eat-answer-xtwinops "no query here\e[2J")) + (should-not called))) + +(provide 'test-eat-config--xtwinops) +;;; test-eat-config--xtwinops.el ends here diff --git a/tests/test-flycheck-config-ledger-hook.el b/tests/test-flycheck-config-ledger-hook.el new file mode 100644 index 00000000..e9444b71 --- /dev/null +++ b/tests/test-flycheck-config-ledger-hook.el @@ -0,0 +1,31 @@ +;;; test-flycheck-config-ledger-hook.el --- flycheck reaches ledger buffers -*- lexical-binding: t; -*- + +;;; Commentary: +;; `flycheck-ledger' registers a `ledger' checker, but a checker only runs where +;; `flycheck-mode' is on. Until 2026-07-10 flycheck-config enabled the mode in +;; `sh-mode' and `emacs-lisp-mode' only, and no `global-flycheck-mode' existed, so +;; an unbalanced transaction in a ledger file produced no warning at all. +;; +;; These tests pin the hook, not the checker. Whether the `ledger' checker itself +;; works is flycheck-ledger's problem; whether it ever gets a chance to run is ours. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'flycheck-config) + +(ert-deftest test-flycheck-config-enables-flycheck-in-ledger-buffers () + "Normal: opening a ledger buffer turns `flycheck-mode' on. +Without this, `flycheck-ledger' is loaded, its checker is registered, and +nothing ever lints a financial file." + (should (memq #'flycheck-mode (default-value 'ledger-mode-hook)))) + +(ert-deftest test-flycheck-config-keeps-its-existing-mode-hooks () + "Boundary: adding ledger doesn't displace the modes flycheck already covered." + (should (memq #'flycheck-mode (default-value 'sh-mode-hook))) + (should (memq #'flycheck-mode (default-value 'emacs-lisp-mode-hook)))) + +(provide 'test-flycheck-config-ledger-hook) +;;; test-flycheck-config-ledger-hook.el ends here diff --git a/tests/test-flyspell-and-abbrev.el b/tests/test-flyspell-and-abbrev.el index ef8cc637..3b494d5e 100644 --- a/tests/test-flyspell-and-abbrev.el +++ b/tests/test-flyspell-and-abbrev.el @@ -97,5 +97,27 @@ (cj/flyspell-on-for-buffer-type))) (should mode-called))) +;; --------------------------- cj/flyspell-then-abbrev ------------------------- + +(ert-deftest test-flyspell-then-abbrev-enables-mode-not-bare-rescan () + "Regression: cj/flyspell-then-abbrev routes the initial scan through +cj/flyspell-on-for-buffer-type, which enables flyspell-mode so it sticks. +The bare flyspell-buffer it replaced never turned the mode on, so the guard +never tripped and every C-' press re-scanned the whole buffer (O(buffer) per +keypress in large files)." + (let (on-called scan-called) + (cl-letf (((symbol-function 'cj/--require-spell-checker) #'ignore) + ((symbol-function 'cj/flyspell-on-for-buffer-type) + (lambda () (setq on-called t))) + ((symbol-function 'flyspell-buffer) + (lambda (&rest _) (setq scan-called t))) + ((symbol-function 'cj/flyspell-goto-previous-misspelling) + (lambda (&rest _) nil))) + (with-temp-buffer + (text-mode) + (cj/flyspell-then-abbrev nil))) + (should on-called) + (should-not scan-called))) + (provide 'test-flyspell-and-abbrev) ;;; test-flyspell-and-abbrev.el ends here diff --git a/tests/test-font-config.el b/tests/test-font-config.el index 393a7758..4df2b41a 100644 --- a/tests/test-font-config.el +++ b/tests/test-font-config.el @@ -93,5 +93,47 @@ ((symbol-function 'set-fontset-font) (lambda (&rest _) t))) (should (progn (cj/setup-emoji-fontset) t)))) +;;; cj/set-emojify-display-style + +(defvar emojify-display-style) + +(ert-deftest test-font-config-emojify-display-style-image-on-gui () + "Normal: on a GUI frame the emoji display style is `image'." + (skip-unless test-font-config--available) + (require 'font-config) + (let ((emojify-display-style nil)) + (cl-letf (((symbol-function 'env-gui-p) (lambda (&rest _) t))) + (cj/set-emojify-display-style) + (should (eq emojify-display-style 'image))))) + +(ert-deftest test-font-config-emojify-display-style-unicode-without-gui () + "Boundary: without a GUI the emoji display style is `unicode'." + (skip-unless test-font-config--available) + (require 'font-config) + (let ((emojify-display-style nil)) + (cl-letf (((symbol-function 'env-gui-p) (lambda (&rest _) nil))) + (cj/set-emojify-display-style) + (should (eq emojify-display-style 'unicode))))) + +;;; cj/display-available-fonts + +(ert-deftest test-font-config-display-available-fonts-second-call-no-error () + "Error: a second invocation does not signal on the read-only buffer." + (skip-unless test-font-config--available) + (require 'font-config) + (cl-letf (((symbol-function 'font-family-list) + (lambda (&rest _) '("Fixture Font A" "Fixture Font B")))) + (unwind-protect + (progn + (cj/display-available-fonts) + ;; The first call ends in `special-mode' (read-only); the second must + ;; not signal when it erases and rewrites the buffer. + (cj/display-available-fonts) + (with-current-buffer "*Available Fonts*" + (should (> (buffer-size) 0)) + (should buffer-read-only))) + (when (get-buffer "*Available Fonts*") + (kill-buffer "*Available Fonts*"))))) + (provide 'test-font-config) ;;; test-font-config.el ends here diff --git a/tests/test-keyboard-compat-setup.el b/tests/test-keyboard-compat-setup.el index 1c5cd434..a23e24e1 100644 --- a/tests/test-keyboard-compat-setup.el +++ b/tests/test-keyboard-compat-setup.el @@ -61,6 +61,14 @@ string can return a meta-prefix event count rather than nil.)" (cj/keyboard-compat-terminal-setup) (should (equal input-decode-map (make-sparse-keymap))))) +(ert-deftest test-keyboard-compat-terminal-setup-on-tty-setup-hook () + "Normal: terminal setup is registered on `tty-setup-hook', which runs for each +new tty frame. `input-decode-map' is terminal-local, so `emacs-startup-hook' +\(once, at daemon start, with no tty) leaves every later `emacsclient -t' frame +without the arrow-key decodings. The GUI half already frame-scopes itself." + (should (memq 'cj/keyboard-compat-terminal-setup tty-setup-hook)) + (should-not (memq 'cj/keyboard-compat-terminal-setup emacs-startup-hook))) + ;; -------------------------- cj/keyboard-compat-gui-setup --------------------- (defmacro test-kbc--gui (gui-p &rest body) @@ -71,18 +79,20 @@ string can return a meta-prefix event count rather than nil.)" ,@body))) (defconst test-kbc--meta-shift-letters - '(?o ?m ?y ?f ?w ?e ?l ?r ?v ?h ?t ?z ?u ?d ?i ?c ?b ?k) - "The 18 letters whose M-<UPPER> form is translated to M-S-<lower> in GUI mode.") + '(?o ?m ?y ?f ?w ?e ?l ?r ?v ?h ?t ?z ?u ?d ?i ?c ?b) + "The 17 letters whose M-<UPPER> form is translated to M-S-<lower> in GUI mode.") (ert-deftest test-keyboard-compat-gui-setup-translates-spot-checks () - "Normal: in GUI mode, M-O -> M-S-o and M-K -> M-S-k (sampled)." + "Normal: in GUI mode, M-O -> M-S-o and M-B -> M-S-b (sampled). +M-K is no longer translated: show-kill-ring, its only consumer, was retired." (test-kbc--gui t (cj/keyboard-compat-gui-setup) (should (equal (lookup-key key-translation-map (kbd "M-O")) (kbd "M-S-o"))) - (should (equal (lookup-key key-translation-map (kbd "M-K")) (kbd "M-S-k"))) - (should (equal (lookup-key key-translation-map (kbd "M-D")) (kbd "M-S-d"))))) + (should (equal (lookup-key key-translation-map (kbd "M-B")) (kbd "M-S-b"))) + (should (equal (lookup-key key-translation-map (kbd "M-D")) (kbd "M-S-d"))) + (should-not (lookup-key key-translation-map (kbd "M-K"))))) -(ert-deftest test-keyboard-compat-gui-setup-translates-all-eighteen () +(ert-deftest test-keyboard-compat-gui-setup-translates-all-seventeen () "Normal: every documented M-<UPPER> maps to its M-S-<lower> form." (test-kbc--gui t (cj/keyboard-compat-gui-setup) diff --git a/tests/test-ledger-config.el b/tests/test-ledger-config.el new file mode 100644 index 00000000..5224c184 --- /dev/null +++ b/tests/test-ledger-config.el @@ -0,0 +1,70 @@ +;;; test-ledger-config.el --- Characterization tests for ledger-config -*- lexical-binding: t; -*- + +;;; Commentary: +;; Captures the behavior ledger-config.el has today, before any guardrail work +;; changes it. See docs/design/2026-07-10-ledger-config-audit.org for the audit +;; these tests pin. +;; +;; The clean-on-save helpers are defined in the `use-package' `:preface', which +;; use-package emits unconditionally, so they exist under `make test' even though +;; ledger-mode itself never loads there (no `package-initialize' in the test run). +;; +;; `ledger-mode-clean-buffer' is stubbed: it is the boundary this config delegates +;; to, and it rewrites the whole buffer. What these tests pin is whether our hook +;; calls it, not what it does. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ledger-config) + +(ert-deftest test-ledger-config-clean-before-save-cleans-when-enabled () + "Normal: with `cj/ledger-clean-on-save' set, the save hook cleans the buffer." + (let ((called 0) + (cj/ledger-clean-on-save t)) + (cl-letf (((symbol-function 'ledger-mode-clean-buffer) + (lambda (&rest _) (setq called (1+ called))))) + (cj/ledger--clean-before-save)) + (should (= 1 called)))) + +(ert-deftest test-ledger-config-clean-before-save-skips-when-disabled () + "Boundary: with `cj/ledger-clean-on-save' nil, the save hook does nothing." + (let ((called 0) + (cj/ledger-clean-on-save nil)) + (cl-letf (((symbol-function 'ledger-mode-clean-buffer) + (lambda (&rest _) (setq called (1+ called))))) + (cj/ledger--clean-before-save)) + (should (= 0 called)))) + +(ert-deftest test-ledger-config-clean-before-save-demotes-errors () + "Error: a failing clean does not signal, so the file still saves. +This is the current contract. It also means a clean that fails partway +leaves the buffer in whatever state it reached, because nothing rolls back." + (let ((cj/ledger-clean-on-save t) + (inhibit-message t)) + (cl-letf (((symbol-function 'ledger-mode-clean-buffer) + (lambda (&rest _) (error "boom")))) + (should (progn (cj/ledger--clean-before-save) t))))) + +(ert-deftest test-ledger-config-enable-clean-on-save-is-buffer-local () + "Normal: the hook installs buffer-locally, not globally." + (let ((global-before (default-value 'before-save-hook))) + (with-temp-buffer + (cj/ledger--enable-clean-on-save) + (should (memq #'cj/ledger--clean-before-save before-save-hook)) + (should-not (memq #'cj/ledger--clean-before-save + (default-value 'before-save-hook)))) + (should (equal global-before (default-value 'before-save-hook))))) + +(ert-deftest test-ledger-config-clean-on-save-defaults-on () + "Normal: clean-on-save ships enabled. +Pinned because the audit questions whether a whole-buffer sort belongs on +every save of a financial file. If that default flips, this test should +fail and be updated deliberately." + (should (eq t (default-value 'cj/ledger-clean-on-save)))) + +(provide 'test-ledger-config) +;;; test-ledger-config.el ends here diff --git a/tests/test-local-repository--car-member.el b/tests/test-local-repository--car-member.el deleted file mode 100644 index 30ae58c6..00000000 --- a/tests/test-local-repository--car-member.el +++ /dev/null @@ -1,58 +0,0 @@ -;;; test-local-repository--car-member.el --- Tests for localrepo--car-member -*- lexical-binding: t -*- - -;;; Commentary: -;; Tests for `localrepo--car-member' in local-repository.el — the predicate -;; localrepo-initialize uses to check whether an archive id is already -;; registered in package-archives / package-archive-priorities. - -;;; Code: - -(require 'ert) -(require 'local-repository) - -;;; Normal Cases - -(ert-deftest test-local-repository-localrepo--car-member-found () - "Normal: VALUE present as a car returns the matching tail (non-nil)." - (should (equal (localrepo--car-member 'b '((a . 1) (b . 2) (c . 3))) - '(b c)))) - -(ert-deftest test-local-repository-localrepo--car-member-not-found () - "Normal: VALUE absent from every car returns nil." - (should-not (localrepo--car-member 'z '((a . 1) (b . 2))))) - -(ert-deftest test-local-repository-localrepo--car-member-string-car () - "Normal: car comparison uses `equal', so string keys match by value." - (should (localrepo--car-member "localrepo" - '(("gnu" . "url1") ("localrepo" . "url2"))))) - -;;; Boundary Cases - -(ert-deftest test-local-repository-localrepo--car-member-empty-list () - "Boundary: an empty list never matches." - (should-not (localrepo--car-member 'a nil))) - -(ert-deftest test-local-repository-localrepo--car-member-single-match () - "Boundary: a single-element list whose car matches returns non-nil." - (should (localrepo--car-member 'only '((only . 1))))) - -(ert-deftest test-local-repository-localrepo--car-member-single-no-match () - "Boundary: a single-element list whose car differs returns nil." - (should-not (localrepo--car-member 'x '((only . 1))))) - -(ert-deftest test-local-repository-localrepo--car-member-nil-value-with-nil-car () - "Boundary: a nil VALUE matches a cons whose car is nil." - (should (localrepo--car-member nil '((nil . 1) (a . 2))))) - -(ert-deftest test-local-repository-localrepo--car-member-nil-value-no-nil-car () - "Boundary: a nil VALUE with no nil car returns nil." - (should-not (localrepo--car-member nil '((a . 1) (b . 2))))) - -;;; Error Cases - -(ert-deftest test-local-repository-localrepo--car-member-non-cons-element () - "Error: a non-cons element makes `car' signal wrong-type-argument." - (should-error (localrepo--car-member 'x '(1 2)) :type 'wrong-type-argument)) - -(provide 'test-local-repository--car-member) -;;; test-local-repository--car-member.el ends here diff --git a/tests/test-local-repository.el b/tests/test-local-repository.el new file mode 100644 index 00000000..132f8dc4 --- /dev/null +++ b/tests/test-local-repository.el @@ -0,0 +1,32 @@ +;;; test-local-repository.el --- Tests for the local-repository update command -*- lexical-binding: t; -*- + +;;; Commentary: +;; `cj/update-localrepo-repository' refreshes the checked-in local package +;; archive at `localrepo-location' (owned by early-init.el) via elpa-mirror. +;; The elpa-mirror call is mocked at the boundary. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'local-repository) + +;; localrepo-location is a defconst in early-init.el, which `make test' never +;; loads. Declare it special here so the `let' below binds it dynamically and +;; the module reads that value. +(defvar localrepo-location nil) + +(ert-deftest test-local-repository-update-targets-early-init-location () + "Normal: the mirror update targets `localrepo-location', the single archive +path early-init.el owns, not a divergent module-local copy." + (let ((localrepo-location "/tmp/test-localrepo/") + (captured nil)) + (cl-letf (((symbol-function 'elpamr-create-mirror-for-installed) + (lambda (dir &rest _) (setq captured dir)))) + (cj/update-localrepo-repository) + (should (equal captured "/tmp/test-localrepo/"))))) + +(provide 'test-local-repository) +;;; test-local-repository.el ends here diff --git a/tests/test-lorem-optimum.el b/tests/test-lorem-optimum.el index f928c972..b7d97a8e 100644 --- a/tests/test-lorem-optimum.el +++ b/tests/test-lorem-optimum.el @@ -253,5 +253,26 @@ an empty string, not an error." (let ((cj/lipsum-chain (cj/markov-chain-create))) (should (equal "" (cj/lipsum-title))))) +;;; cj/lipsum entry point + +(ert-deftest test-lipsum-returns-string-with-populated-chain () + "Normal: cj/lipsum returns a non-empty string when the chain is trained." + (let ((cj/lipsum-chain + (test-learn "Lorem ipsum dolor sit amet consectetur adipiscing elit"))) + (let ((result (cj/lipsum 5))) + (should (stringp result)) + (should (> (length result) 0))))) + +(ert-deftest test-lipsum-empty-chain-signals-user-error () + "Error: cj/lipsum on an empty chain signals a user-error naming the fix, +rather than returning nil and letting cj/lipsum-insert do (insert nil), +which raises a cryptic wrong-type error far from the cause." + (let ((cj/lipsum-chain (cj/markov-chain-create))) + (should-error (cj/lipsum 5) :type 'user-error))) + +(ert-deftest test-lipsum-is-interactive-command () + "Normal: cj/lipsum is a command, as its Commentary (M-x cj/lipsum) advertises." + (should (commandp 'cj/lipsum))) + (provide 'test-lorem-optimum) ;;; test-lorem-optimum.el ends here diff --git a/tests/test-mail-config--account-search-queries.el b/tests/test-mail-config--account-search-queries.el index 9f1b6b3e..02a74692 100644 --- a/tests/test-mail-config--account-search-queries.el +++ b/tests/test-mail-config--account-search-queries.el @@ -38,9 +38,12 @@ (ert-deftest test-mail-make-account-map-closures-capture-distinct-queries () "Normal: each binding runs its own account-scoped search (no closure leak). -mu4e-search is mocked to capture the query each command passes." +mu4e-search is mocked to capture the query each command passes; require is +mocked so the commands' mu4e load never pulls the real package in batch." (let ((searched '())) - (cl-letf (((symbol-function 'mu4e-search) + (cl-letf (((symbol-function 'require) + (lambda (feature &rest _) feature)) + ((symbol-function 'mu4e-search) (lambda (q) (push q searched)))) (let ((map (cj/--mail-make-account-map "dmail"))) (funcall (keymap-lookup map "i")) @@ -49,5 +52,20 @@ mu4e-search is mocked to capture the query each command passes." (should (member "maildir:/dmail/INBOX AND flag:unread AND NOT flag:trashed" searched)))) +(ert-deftest test-mail-make-account-map-loads-mu4e-before-search () + "Error: a nav command loads mu4e before it calls `mu4e-search'. +The C-; e maps register eagerly at startup, but `mu4e-search' carries no +autoload cookie, so a nav key pressed before mu4e's first launch signaled +void-function. The command must require mu4e first, then search." + (let ((events '())) + (cl-letf (((symbol-function 'require) + (lambda (feature &rest _) (push (list :require feature) events) feature)) + ((symbol-function 'mu4e-search) + (lambda (q) (push (list :search q) events)))) + (funcall (keymap-lookup (cj/--mail-make-account-map "cmail") "i"))) + (should (equal (nreverse events) + '((:require mu4e) + (:search "maildir:/cmail/INBOX")))))) + (provide 'test-mail-config--account-search-queries) ;;; test-mail-config--account-search-queries.el ends here diff --git a/tests/test-mail-config-transport.el b/tests/test-mail-config-transport.el index 0240102a..9329ac70 100644 --- a/tests/test-mail-config-transport.el +++ b/tests/test-mail-config-transport.el @@ -66,6 +66,23 @@ EXECUTABLES is an alist of program name strings to executable paths." (should (equal test-mail-config--warnings '((mail-config . "msmtp not found; SMTP mail sending unavailable"))))))) +(ert-deftest test-mail-config-transport-msmtp-missing-sets-descriptive-fallback () + "Error: with msmtp absent, the send functions get a descriptive fallback. +The old behavior left `message-send-mail-function' nil (the top-level defvar +pre-empts message.el's default), so the first send died with \"invalid +function: nil\". The fallback must be installed on both send variables and +must signal a `user-error' that names msmtp." + (test-mail-config--with-executables nil + (let (send-mail-function message-send-mail-function) + (cj/mail-configure-smtpmail) + (should (eq send-mail-function #'cj/mail--send-mail-unavailable)) + (should (eq message-send-mail-function #'cj/mail--send-mail-unavailable)) + (should-error (cj/mail--send-mail-unavailable) :type 'user-error) + (condition-case err + (cj/mail--send-mail-unavailable) + (user-error + (should (string-match-p "msmtp" (cadr err)))))))) + (ert-deftest test-mail-config-transport-mbsync-present-builds-command () "When mbsync exists, build the mu4e sync command." (test-mail-config--with-executables '(("mbsync" . "/usr/bin/mbsync")) diff --git a/tests/test-mu4e-attachments.el b/tests/test-mu4e-attachments.el index 0a780977..986c2746 100644 --- a/tests/test-mu4e-attachments.el +++ b/tests/test-mu4e-attachments.el @@ -75,6 +75,56 @@ so this fails the same way whether or not mu4e's MIME support is loadable." (should-error (cj/mu4e--save-attachment-part part "/downloads") :type 'user-error))) +(ert-deftest test-mu4e-attachments-save-part-errors-on-stale-handle () + "Error: a handle whose MIME buffer was killed fails with a clear error. +The selection buffer captures handles when it opens; a real MIME handle's +car is the buffer holding the part's bytes, and viewing another message +kills it. Saving through it must signal a `user-error' naming the file, +not die in `mm-save-part-to-file' (or save another message's bytes)." + (let* ((dead (generate-new-buffer "stale-mime-part")) + (part (test-mu4e-attachments--part "invoice.pdf" 3 (list dead)))) + (kill-buffer dead) + (should-error (cj/mu4e--save-attachment-part part "/downloads") + :type 'user-error) + (condition-case err + (cj/mu4e--save-attachment-part part "/downloads") + (user-error (should (string-match-p "invoice\\.pdf" (cadr err))))))) + +(ert-deftest test-mu4e-attachments-save-part-live-buffer-handle-saves () + "Normal: a handle whose MIME buffer is alive saves normally." + (let* ((live (generate-new-buffer "live-mime-part")) + (part (test-mu4e-attachments--part "invoice.pdf" 3 (list live))) + (mu4e-uniquify-save-file-name-function #'identity) + saved) + (unwind-protect + (cl-letf (((symbol-function 'mu4e-join-paths) + (lambda (&rest pieces) (mapconcat #'identity pieces "/"))) + ((symbol-function 'mm-save-part-to-file) + (lambda (_handle path) (setq saved path)))) + (should (equal (cj/mu4e--save-attachment-part part "/downloads") + "/downloads/invoice.pdf")) + (should (equal saved "/downloads/invoice.pdf"))) + (kill-buffer live)))) + +(ert-deftest test-mu4e-attachments-save-parts-mid-batch-failure-propagates () + "Error: a mid-batch save failure propagates; earlier parts stay saved. +Characterizes the batch path: no silent skip of the failing part, and the +files already written are not rolled back." + (let ((parts (list (test-mu4e-attachments--part "a.pdf" 1) + (test-mu4e-attachments--part "b.pdf" 2) + (test-mu4e-attachments--part "c.pdf" 3))) + (saved '())) + (cl-letf (((symbol-function 'cj/mu4e--save-attachment-part) + (lambda (part _dir) + (let ((name (plist-get part :filename))) + (when (equal name "b.pdf") + (user-error "Stale handle: %s" name)) + (push name saved) + name)))) + (should-error (cj/mu4e--save-attachment-parts parts "/downloads") + :type 'user-error)) + (should (equal (nreverse saved) '("a.pdf"))))) + (ert-deftest test-mu4e-attachments-save-all-prompts-once () "Normal: the save-all command prompts for a directory once and saves all parts." (let ((parts (list (test-mu4e-attachments--part "a.pdf" 1) diff --git a/tests/test-music-config--art-cache-key.el b/tests/test-music-config--art-cache-key.el index 0ef82a21..bc869808 100644 --- a/tests/test-music-config--art-cache-key.el +++ b/tests/test-music-config--art-cache-key.el @@ -50,5 +50,20 @@ (should (string= (cj/music-art--cache-key track entries) (concat "url-" (sha1 url)))))) +(ert-deftest test-music-config--art-cache-key-normal-track-property () + "Normal: a queued lookup station carries its uuid as a track property and +needs no entries at all." + (let ((track (emms-track 'url "https://ck4.example.net/live"))) + (emms-track-set track 'radio-uuid "prop-uuid") + (should (string= (cj/music-art--cache-key track nil) "prop-uuid")))) + +(ert-deftest test-music-config--art-cache-key-normal-property-beats-entries () + "Normal: the track property wins over a conflicting entries uuid." + (let* ((url "https://ck5.example.net/live") + (track (emms-track 'url url)) + (entries (list (list url :name "X" :uuid "entries-uuid" :favicon nil)))) + (emms-track-set track 'radio-uuid "prop-uuid") + (should (string= (cj/music-art--cache-key track entries) "prop-uuid")))) + (provide 'test-music-config--art-cache-key) ;;; test-music-config--art-cache-key.el ends here diff --git a/tests/test-music-config--art-favicon-url.el b/tests/test-music-config--art-favicon-url.el index d9759ab3..9e7b92f8 100644 --- a/tests/test-music-config--art-favicon-url.el +++ b/tests/test-music-config--art-favicon-url.el @@ -48,5 +48,22 @@ (let ((track (emms-track 'file "/music/x.flac"))) (should (null (cj/music-art--favicon-url track nil))))) +(ert-deftest test-music-config--art-favicon-url-normal-track-property () + "Normal: a queued lookup station carries its favicon as a track property." + (let ((track (emms-track 'url "https://fp.example.net/live"))) + (emms-track-set track 'radio-favicon "https://fp.example.net/icon.png") + (should (string= (cj/music-art--favicon-url track nil) + "https://fp.example.net/icon.png")))) + +(ert-deftest test-music-config--art-favicon-url-normal-property-beats-entries () + "Normal: the track property wins over a conflicting entries favicon." + (let* ((url "https://fp2.example.net/live") + (track (emms-track 'url url)) + (entries (list (list url :name "X" :uuid nil + :favicon "https://entries.example/e.png")))) + (emms-track-set track 'radio-favicon "https://prop.example/p.png") + (should (string= (cj/music-art--favicon-url track entries) + "https://prop.example/p.png")))) + (provide 'test-music-config--art-favicon-url) ;;; test-music-config--art-favicon-url.el ends here diff --git a/tests/test-music-config--bar-string.el b/tests/test-music-config--bar-string.el new file mode 100644 index 00000000..bb6ac96f --- /dev/null +++ b/tests/test-music-config--bar-string.el @@ -0,0 +1,50 @@ +;;; test-music-config--bar-string.el --- Tests for the block progress bar -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for `cj/music--bar-string': render a WIDTH-cell block bar from a +;; filled-cell count (from `cj/music--bar-fill'), or an "on air" marker when the +;; fill is `indeterminate' (a live stream with no duration). Face-carrying +;; text; the tests assert the rendered length and content, not the faces. +;; +;;; Code: + +(require 'ert) + +(defvar-keymap cj/custom-keymap :doc "Stub keymap for testing") + +(let ((emms-dir (car (file-expand-wildcards + (expand-file-name "elpa/emms-*" user-emacs-directory))))) + (when emms-dir (add-to-list 'load-path emms-dir))) + +(require 'emms) +(require 'music-config) + +(ert-deftest test-music-config--bar-string-normal-half () + "Normal: 10 of 20 cells filled renders a 20-cell bar." + (let ((s (substring-no-properties (cj/music--bar-string 10 20)))) + (should (= (length s) 20)) + (should (= (cl-count ?█ s) 10)) + (should (= (cl-count ?░ s) 10)))) + +(ert-deftest test-music-config--bar-string-boundary-empty () + "Boundary: zero fill is all empty cells." + (let ((s (substring-no-properties (cj/music--bar-string 0 20)))) + (should (= (cl-count ?█ s) 0)) + (should (= (cl-count ?░ s) 20)))) + +(ert-deftest test-music-config--bar-string-boundary-full () + "Boundary: full fill is all filled cells." + (let ((s (substring-no-properties (cj/music--bar-string 20 20)))) + (should (= (cl-count ?█ s) 20)) + (should (= (cl-count ?░ s) 0)))) + +(ert-deftest test-music-config--bar-string-indeterminate-on-air () + "Error/indeterminate: a stream renders an on-air marker, not a bar." + (let ((s (substring-no-properties (cj/music--bar-string 'indeterminate 20)))) + (should (string-match-p "on air" s)) + (should (= (cl-count ?█ s) 0)))) + +(provide 'test-music-config--bar-string) +;;; test-music-config--bar-string.el ends here diff --git a/tests/test-music-config--m3u-text.el b/tests/test-music-config--m3u-text.el new file mode 100644 index 00000000..14c9f2bf --- /dev/null +++ b/tests/test-music-config--m3u-text.el @@ -0,0 +1,93 @@ +;;; test-music-config--m3u-text.el --- playlist .m3u emitter tests -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; The custom .m3u emitter behind playlist save. The stock EMMS m3u writer +;; emits bare URLs, which would throw away a station's name/uuid/favicon on +;; save; this emitter writes the same #EXTINF / #RADIOBROWSERUUID / +;; #RADIOBROWSERFAVICON lines the parser (`cj/music--m3u-entries') reads, so +;; save -> load round-trips a station's display name and cover-art metadata. +;; Metadata comes from track properties first, the m3u-scan entries as +;; fallback (a loaded legacy playlist has entries but no properties). + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module. +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +(let ((emms-dir (car (file-expand-wildcards + (expand-file-name "elpa/emms-*" user-emacs-directory))))) + (when emms-dir (add-to-list 'load-path emms-dir))) + +(require 'emms) +(require 'music-config) + +(declare-function cj/music--m3u-text "music-config" (tracks entries)) +(declare-function cj/music-radio--station-track "music-config" (st)) + +(ert-deftest test-music-m3u-text-normal-file-track-bare-path () + "Normal: a file track is a bare absolute path under the #EXTM3U header." + (let ((text (cj/music--m3u-text (list (emms-track 'file "/music/a.flac")) nil))) + (should (string-prefix-p "#EXTM3U\n" text)) + (should (string-match-p "^/music/a\\.flac$" text)))) + +(ert-deftest test-music-m3u-text-normal-url-track-from-properties () + "Normal: a url track with properties emits uuid, favicon, EXTINF, and url." + (let* ((track (cj/music-radio--station-track + '(:name "Groove Salad" :url "https://ck.somafm.com/gs" + :stationuuid "uuid-1" :favicon "https://somafm.com/i.png"))) + (text (cj/music--m3u-text (list track) nil))) + (should (string-match-p "^#RADIOBROWSERUUID:uuid-1$" text)) + (should (string-match-p "^#RADIOBROWSERFAVICON:https://somafm\\.com/i\\.png$" text)) + (should (string-match-p "^#EXTINF:-1,Groove Salad$" text)) + (should (string-match-p "^https://ck\\.somafm\\.com/gs$" text)))) + +(ert-deftest test-music-m3u-text-normal-url-track-from-entries-fallback () + "Normal: a propertyless url track (a loaded legacy playlist) resolves its +metadata from the ENTRIES alist." + (let* ((url "https://legacy.example/stream") + (track (emms-track 'url url)) + (entries (list (list url :name "Legacy FM" :uuid "uuid-9" + :favicon "https://legacy.example/f.ico"))) + (text (cj/music--m3u-text (list track) entries))) + (should (string-match-p "^#RADIOBROWSERUUID:uuid-9$" text)) + (should (string-match-p "^#EXTINF:-1,Legacy FM$" text)))) + +(ert-deftest test-music-m3u-text-boundary-url-track-no-metadata () + "Boundary: a url track with neither properties nor entries still gets an +EXTINF label (the tidied host) and no uuid/favicon lines." + (let ((text (cj/music--m3u-text + (list (emms-track 'url "https://ice6.somafm.com/live")) nil))) + (should (string-match-p "^#EXTINF:-1,somafm\\.com$" text)) + (should-not (string-match-p "RADIOBROWSERUUID" text)) + (should-not (string-match-p "RADIOBROWSERFAVICON" text)))) + +(ert-deftest test-music-m3u-text-normal-mixed-order-preserved () + "Normal: a mixed queue keeps its track order in the file." + (let* ((f (emms-track 'file "/music/b.mp3")) + (u (cj/music-radio--station-track '(:name "S" :url "https://s.example/x"))) + (text (cj/music--m3u-text (list f u) nil))) + (should (< (string-match "^/music/b\\.mp3$" text) + (string-match "^https://s\\.example/x$" text))))) + +(ert-deftest test-music-m3u-text-round-trip-through-parser () + "Normal: the parser recovers name, uuid, and favicon from emitted text." + (let* ((track (cj/music-radio--station-track + '(:name "Round Trip" :url "https://rt.example/s" + :stationuuid "uuid-rt" :favicon "https://rt.example/f.png"))) + (entries (cj/music--m3u-entries (cj/music--m3u-text (list track) nil))) + (meta (cdr (assoc "https://rt.example/s" entries)))) + (should (equal (plist-get meta :name) "Round Trip")) + (should (equal (plist-get meta :uuid) "uuid-rt")) + (should (equal (plist-get meta :favicon) "https://rt.example/f.png")))) + +(ert-deftest test-music-m3u-text-boundary-empty-playlist-header-only () + "Boundary: an empty track list is just the #EXTM3U header." + (should (equal (cj/music--m3u-text nil nil) "#EXTM3U\n"))) + +(provide 'test-music-config--m3u-text) +;;; test-music-config--m3u-text.el ends here diff --git a/tests/test-music-config--playlist-dock.el b/tests/test-music-config--playlist-dock.el new file mode 100644 index 00000000..69d24cc3 --- /dev/null +++ b/tests/test-music-config--playlist-dock.el @@ -0,0 +1,74 @@ +;;; test-music-config--playlist-dock.el --- Tests for the F10 playlist dock -*- lexical-binding: t; -*- + +;;; Commentary: +;; The F10 playlist always docks at the bottom, whatever the frame's shape. +;; It used to pick `right' on a wide frame via `cj/preferred-dock-direction', +;; which produced an unwanted three-way split on a wide terminal. +;; +;; `cj/side-window-display' is the window-system boundary here, so it is the +;; thing stubbed (an ordinary defun -- safe to `cl-letf', unlike the frame-* +;; subrs). Stubbing it lets these tests assert what the toggle *asks for* +;; without needing a live frame under `--batch'. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'music-config) + +(defmacro test-music-dock--with-captured-side (captured &rest body) + "Run BODY with the playlist display path stubbed, recording its args in CAPTURED. +CAPTURED is set to a plist of :side, :size-var and :default-size." + (declare (indent 1)) + `(let ((buffer (generate-new-buffer " *test-playlist*"))) + (unwind-protect + (cl-letf (((symbol-function 'cj/emms--setup) (lambda (&rest _) nil)) + ((symbol-function 'cj/music--ensure-playlist-buffer) + (lambda (&rest _) buffer)) + ((symbol-function 'emms-playlist-mode-center-current) + (lambda (&rest _) nil)) + ((symbol-function 'cj/side-window-display) + (lambda (_buf side size-var default-size) + (setq ,captured (list :side side :size-var size-var + :default-size default-size)) + (selected-window)))) + ,@body) + (kill-buffer buffer)))) + +(ert-deftest test-music-config-playlist-toggle-docks-bottom-on-wide-frame () + "Normal: a wide frame still docks the playlist at the bottom. +A wide frame used to dock right, splitting the frame three ways." + (let (captured) + (test-music-dock--with-captured-side captured + (cl-letf (((symbol-function 'frame-width) (lambda (&rest _) 400))) + (cj/music-playlist-toggle))) + (should (eq 'bottom (plist-get captured :side))))) + +(ert-deftest test-music-config-playlist-toggle-docks-bottom-on-narrow-frame () + "Boundary: a narrow frame docks at the bottom, as it always did." + (let (captured) + (test-music-dock--with-captured-side captured + (cl-letf (((symbol-function 'frame-width) (lambda (&rest _) 40))) + (cj/music-playlist-toggle))) + (should (eq 'bottom (plist-get captured :side))))) + +(ert-deftest test-music-config-playlist-toggle-uses-height-memory () + "Normal: the bottom dock carries the height fraction and its memory var." + (let (captured) + (test-music-dock--with-captured-side captured + (cj/music-playlist-toggle)) + (should (eq 'cj/--music-playlist-height (plist-get captured :size-var))) + (should (= cj/music-playlist-window-height (plist-get captured :default-size))))) + +(ert-deftest test-music-config-playlist-dock-has-no-width-knobs () + "Error: the right-dock width variables are gone, not merely unused. +A stale `cj/music-playlist-window-width' would read as a live knob that +silently does nothing." + (should-not (boundp 'cj/music-playlist-window-width)) + (should-not (boundp 'cj/--music-playlist-width)) + (should-not (fboundp 'cj/--music-playlist-side))) + +(provide 'test-music-config--playlist-dock) +;;; test-music-config--playlist-dock.el ends here diff --git a/tests/test-music-config--playlist-side.el b/tests/test-music-config--playlist-side.el deleted file mode 100644 index f4969469..00000000 --- a/tests/test-music-config--playlist-side.el +++ /dev/null @@ -1,45 +0,0 @@ -;;; test-music-config--playlist-side.el --- Tests for the F10 dock-side helper -*- lexical-binding: t; -*- - -;;; Commentary: -;; `cj/--music-playlist-side' maps the shared dock rule's verdict to a -;; `display-buffer-in-side-window' side: `right' stays `right', anything -;; else becomes `bottom'. The decision itself lives in -;; `cj/preferred-dock-direction' (tested in test-cj-window-geometry-lib.el); -;; here we stub it (an ordinary defun -- safe to `cl-letf', unlike the -;; frame-* subrs) to prove the mapping and that the width fraction is -;; passed through. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'music-config) - -(ert-deftest test-music-config--playlist-side-right-verdict-is-right () - "Normal: a `right' verdict from the dock rule docks the playlist right." - (cl-letf (((symbol-function 'cj/preferred-dock-direction) - (lambda (&rest _) 'right))) - (should (eq (cj/--music-playlist-side) 'right)))) - -(ert-deftest test-music-config--playlist-side-below-verdict-is-bottom () - "Normal: a `below' verdict maps to the `bottom' side window." - (cl-letf (((symbol-function 'cj/preferred-dock-direction) - (lambda (&rest _) 'below))) - (should (eq (cj/--music-playlist-side) 'bottom)))) - -(ert-deftest test-music-config--playlist-side-passes-width-fraction () - "Normal: the playlist's width fraction reaches the dock rule." - (let ((cj/music-playlist-window-width 0.4) - captured) - (cl-letf (((symbol-function 'cj/preferred-dock-direction) - (lambda (cols frac &rest _) - (setq captured (list cols frac)) - 'below))) - (cj/--music-playlist-side) - (should (= (nth 1 captured) 0.4)) - (should (integerp (nth 0 captured)))))) - -(provide 'test-music-config--playlist-side) -;;; test-music-config--playlist-side.el ends here diff --git a/tests/test-music-config--radio-station-track.el b/tests/test-music-config--radio-station-track.el new file mode 100644 index 00000000..5816c776 --- /dev/null +++ b/tests/test-music-config--radio-station-track.el @@ -0,0 +1,135 @@ +;;; test-music-config--radio-station-track.el --- station->track + enqueue tests -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; The queue-first radio model: a picked station becomes an EMMS url track +;; carrying its metadata as track properties (info-title, radio-uuid, +;; radio-favicon) instead of being written to an .m3u. Covers the pure +;; station->track builder and the enqueue-and-play buffer mechanics (with +;; playback mocked at the EMMS boundary). + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +;; Stub dependencies before loading the module. +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +(let ((emms-dir (car (file-expand-wildcards + (expand-file-name "elpa/emms-*" user-emacs-directory))))) + (when emms-dir (add-to-list 'load-path emms-dir))) + +(require 'emms) +(require 'music-config) + +(declare-function cj/music-radio--station-track "music-config" (st)) +(declare-function cj/music-radio--enqueue-and-play "music-config" (tracks)) + +;;; --------------------------- station-track ----------------------------------- + +(ert-deftest test-music-radio-station-track-normal () + "Normal: a full station plist yields a url track with all three properties." + (let ((track (cj/music-radio--station-track + '(:name "Adroit Jazz Underground" + :url_resolved "https://icecast.walmradio.com:8443/jazz" + :stationuuid "ea8059be-d119" + :favicon "https://walmradio.com/icon.png")))) + (should (eq (emms-track-type track) 'url)) + (should (equal (emms-track-name track) "https://icecast.walmradio.com:8443/jazz")) + (should (equal (emms-track-get track 'info-title) "Adroit Jazz Underground")) + (should (equal (emms-track-get track 'radio-uuid) "ea8059be-d119")) + (should (equal (emms-track-get track 'radio-favicon) "https://walmradio.com/icon.png")))) + +(ert-deftest test-music-radio-station-track-no-url-is-nil () + "Error: a station with no stream URL yields nil, not a broken track." + (should-not (cj/music-radio--station-track '(:name "No URL" :url_resolved "" :url "")))) + +(ert-deftest test-music-radio-station-track-boundary-no-name () + "Boundary: a nameless station falls back to \"Radio\" for its title." + (let ((track (cj/music-radio--station-track '(:url "https://s.example/live")))) + (should (equal (emms-track-get track 'info-title) "Radio")))) + +(ert-deftest test-music-radio-station-track-boundary-newline-name-stripped () + "Boundary: newlines in an external station name are flattened to spaces." + (let ((track (cj/music-radio--station-track + '(:name "Line\nBreak" :url "https://s.example/live")))) + (should (equal (emms-track-get track 'info-title) "Line Break")))) + +(ert-deftest test-music-radio-station-track-boundary-empty-uuid-favicon-absent () + "Boundary: empty-string uuid/favicon are treated as absent, not stored." + (let ((track (cj/music-radio--station-track + '(:name "X" :url "https://s.example/live" :stationuuid "" :favicon "")))) + (should-not (emms-track-get track 'radio-uuid)) + (should-not (emms-track-get track 'radio-favicon)))) + +;;; ------------------------- enqueue-and-play ---------------------------------- + +(defmacro test-music-radio--with-playlist (&rest body) + "Run BODY with a fresh, uniquely named playlist buffer and playback mocked." + `(let* ((cj/music-playlist-buffer-name + (generate-new-buffer-name "*test-radio-enqueue*")) + (emms-player-playing-p nil) + (started 0) (stopped 0)) + (unwind-protect + (cl-letf (((symbol-function 'emms-start) + (lambda () (setq started (1+ started)))) + ((symbol-function 'emms-stop) + (lambda () (setq stopped (1+ stopped))))) + ,@body) + (when (get-buffer cj/music-playlist-buffer-name) + (kill-buffer cj/music-playlist-buffer-name))))) + +(ert-deftest test-music-radio-enqueue-and-play-normal-appends-and-plays () + "Normal: tracks land in the playlist buffer in order; the first is selected +and playback starts." + (test-music-radio--with-playlist + (let ((t1 (cj/music-radio--station-track '(:name "One" :url "https://one.example/a"))) + (t2 (cj/music-radio--station-track '(:name "Two" :url "https://two.example/b")))) + (cj/music-radio--enqueue-and-play (list t1 t2)) + (with-current-buffer cj/music-playlist-buffer-name + (let ((names '())) + (save-excursion + (goto-char (point-min)) + (while (not (eobp)) + (when-let ((tr (emms-playlist-track-at (point)))) + (push (emms-track-name tr) names)) + (forward-line 1))) + (should (equal (nreverse names) + '("https://one.example/a" "https://two.example/b")))) + (should (equal (emms-track-name (emms-playlist-selected-track)) + "https://one.example/a"))) + (should (= started 1))))) + +(ert-deftest test-music-radio-enqueue-and-play-boundary-appends-after-existing () + "Boundary: an existing queue is kept; new tracks append and the first NEW +track is the one selected." + (test-music-radio--with-playlist + (let ((old (cj/music-radio--station-track '(:name "Old" :url "https://old.example/x"))) + (new (cj/music-radio--station-track '(:name "New" :url "https://new.example/y")))) + (cj/music-radio--enqueue-and-play (list old)) + (cj/music-radio--enqueue-and-play (list new)) + (with-current-buffer cj/music-playlist-buffer-name + (should (equal (emms-track-name (emms-playlist-selected-track)) + "https://new.example/y")))))) + +(ert-deftest test-music-radio-enqueue-and-play-interrupts-current-playback () + "Normal: when something is playing, enqueue stops it before starting." + (test-music-radio--with-playlist + (let ((emms-player-playing-p t) + (tr (cj/music-radio--station-track '(:name "Z" :url "https://z.example/s")))) + (cj/music-radio--enqueue-and-play (list tr)) + (should (= stopped 1)) + (should (= started 1))))) + +(ert-deftest test-music-radio-enqueue-and-play-boundary-nil-is-no-op () + "Boundary: an empty track list does nothing — no buffer churn, no playback." + (test-music-radio--with-playlist + (cj/music-radio--enqueue-and-play nil) + (should (= started 0)) + (should (= stopped 0)))) + +(provide 'test-music-config--radio-station-track) +;;; test-music-config--radio-station-track.el ends here diff --git a/tests/test-music-config--radio.el b/tests/test-music-config--radio.el index 56a9c2dc..3923a599 100644 --- a/tests/test-music-config--radio.el +++ b/tests/test-music-config--radio.el @@ -3,11 +3,12 @@ ;; Author: Craig Jennings <c@cjennings.net> ;; ;;; Commentary: -;; Phase 1 of the radio-browser lookup (spec docs/specs/2026-07-06-radio-browser-lookup-spec.org): -;; the pure pieces behind the search command. Parsing a recorded JSON response, -;; picking a station's stream URL, emitting the .m3u, formatting the marginalia -;; annotation (Variant B: codec/bitrate/country/votes/tags), disambiguating a -;; colliding filename, and building the search URL. The network GET and the +;; The pure pieces behind the radio-browser search command (spec +;; docs/specs/2026-07-06-radio-browser-lookup-spec.org): parsing a recorded +;; JSON response, picking a station's stream URL, formatting the marginalia +;; annotation (Variant B: codec/bitrate/country/votes/tags), and building the +;; search URL. The station->track builder and the queue mechanics live in +;; test-music-config--radio-station-track.el; the network GET and the ;; interactive command are exercised in the daemon, not here. ;;; Code: @@ -22,10 +23,8 @@ (declare-function cj/music-radio--parse-search "music-config" (json-text)) (declare-function cj/music-radio--station-url "music-config" (st)) -(declare-function cj/music-radio--station-m3u "music-config" (st)) (declare-function cj/music-radio--tags-snippet "music-config" (tags n)) (declare-function cj/music-radio--format-candidate "music-config" (st)) -(declare-function cj/music-radio--disambiguate-name "music-config" (name uuid taken)) (declare-function cj/music-radio--search-url "music-config" (server query)) (defconst test-music-radio--fixture @@ -62,49 +61,20 @@ ;;; --------------------------- station-url ------------------------------------ (ert-deftest test-music-radio-station-url-resolved () - "Normal: url_resolved is preferred." + "Normal: url_resolved wins when present." (should (equal (cj/music-radio--station-url (test-music-radio--first)) "https://icecast.walmradio.com:8443/jazz"))) (ert-deftest test-music-radio-station-url-fallback-to-url () - "Boundary: empty url_resolved falls back to url." + "Boundary: an empty url_resolved falls back to url." (should (equal (cj/music-radio--station-url - '(:url_resolved "" :url "http://example.test/stream")) - "http://example.test/stream"))) + '(:url_resolved "" :url "http://fallback.test/stream")) + "http://fallback.test/stream"))) (ert-deftest test-music-radio-station-url-none () "Error: neither url_resolved nor url yields nil." (should-not (cj/music-radio--station-url '(:url_resolved "" :url "")))) -;;; --------------------------- station-m3u ------------------------------------ - -(ert-deftest test-music-radio-station-m3u-shape () - "Normal: the .m3u carries #EXTM3U, the UUID, #EXTINF, and the stream URL." - (let ((m3u (cj/music-radio--station-m3u (test-music-radio--first)))) - (should (string-prefix-p "#EXTM3U\n" m3u)) - (should (string-match-p "#RADIOBROWSERUUID:ea8059be-d119-4de3-b27b-0d9bd6aedb17" m3u)) - (should (string-match-p "#EXTINF:1,Adroit Jazz Underground" m3u)) - (should (string-match-p "https://icecast.walmradio.com:8443/jazz" m3u)))) - -(ert-deftest test-music-radio-station-m3u-no-url-is-nil () - "Error: a station with no usable stream URL emits nil (not a broken file)." - (should-not (cj/music-radio--station-m3u '(:name "Broken" :url_resolved "" :url "")))) - -(ert-deftest test-music-radio-station-m3u-captures-favicon () - "Normal: a station with a favicon writes a #RADIOBROWSERFAVICON line so the -cover-art layer needs no lookup later." - (let ((m3u (cj/music-radio--station-m3u - '(:name "Art Radio" :url_resolved "https://art.example/live" - :stationuuid "u-art" :favicon "https://cdn.example/art.png")))) - (should (string-match-p "#RADIOBROWSERFAVICON:https://cdn.example/art.png" m3u)))) - -(ert-deftest test-music-radio-station-m3u-no-favicon-omits-line () - "Boundary: a station with an empty favicon writes no #RADIOBROWSERFAVICON line." - (let ((m3u (cj/music-radio--station-m3u - '(:name "Plain" :url_resolved "https://plain.example/live" - :stationuuid "u-plain" :favicon "")))) - (should-not (string-match-p "#RADIOBROWSERFAVICON" m3u)))) - ;;; --------------------------- tags-snippet ----------------------------------- (ert-deftest test-music-radio-tags-snippet-takes-first-n () @@ -126,21 +96,6 @@ cover-art layer needs no lookup later." (should (string-match-p "174208" ann)) (should (string-match-p "bebop" ann)))) -;;; --------------------------- disambiguate-name ------------------------------ - -(ert-deftest test-music-radio-disambiguate-name-unique () - "Normal: a name not already taken keeps its safe basename." - (should (equal (cj/music-radio--disambiguate-name "Jazz Radio" "uuid1234" '()) - "Jazz_Radio"))) - -(ert-deftest test-music-radio-disambiguate-name-collision () - "Boundary: a colliding name gets a UUID fragment appended, making it unique." - (let ((first (cj/music-radio--disambiguate-name "Jazz Radio" "aaaabbbbcccc" '()))) - (should (equal first "Jazz_Radio")) - (should-not (equal (cj/music-radio--disambiguate-name "Jazz Radio" "aaaabbbbcccc" - (list first)) - first)))) - ;;; --------------------------- search-url ------------------------------------- (ert-deftest test-music-radio-search-url-encodes-query-and-limit () @@ -158,7 +113,6 @@ cover-art layer needs no lookup later." (should-not (string-match-p "name=ambient" u)))) (declare-function cj/music-radio--candidates "music-config" (stations)) -(declare-function cj/music-radio--write-stations "music-config" (stations dir)) ;;; --------------------------- candidates (dedup) ----------------------------- @@ -180,41 +134,5 @@ cover-art layer needs no lookup later." (should (= (length cands) 2)) (should (= (length (delete-dups (copy-sequence keys))) 2)))) -;;; --------------------------- write-stations --------------------------------- - -(ert-deftest test-music-radio-write-stations-writes-and-skips () - "Normal + Error: a station with a URL is written; one with no URL is skipped and named." - (let ((dir (make-temp-file "radio-write-" t))) - (unwind-protect - (let* ((stations (list (test-music-radio--first) - '(:name "No URL Here" :url_resolved "" :url ""))) - (result (cj/music-radio--write-stations stations dir))) - (should (= (length (plist-get result :written)) 1)) - (should (member "No URL Here" (plist-get result :skipped))) - (should (file-exists-p (car (plist-get result :written))))) - (delete-directory dir t)))) - -(ert-deftest test-music-radio-write-stations-radio-suffix () - "Normal: a written filename carries the -Radio suffix before .m3u." - (let ((dir (make-temp-file "radio-write-" t))) - (unwind-protect - (let* ((result (cj/music-radio--write-stations (list (test-music-radio--first)) dir)) - (path (car (plist-get result :written)))) - (should (string-suffix-p "-Radio.m3u" path)) - (should (string-match-p "Adroit_Jazz_Underground-Radio\\.m3u\\'" path))) - (delete-directory dir t)))) - -(ert-deftest test-music-radio-write-stations-collision-writes-two-files () - "Boundary: two same-named stations in one run write two distinct files, no overwrite." - (let ((dir (make-temp-file "radio-write-" t))) - (unwind-protect - (let* ((stations '((:name "Same Name" :url_resolved "http://a.test/s" :stationuuid "aaaa1111") - (:name "Same Name" :url_resolved "http://b.test/s" :stationuuid "bbbb2222"))) - (result (cj/music-radio--write-stations stations dir)) - (written (plist-get result :written))) - (should (= (length written) 2)) - (should-not (equal (nth 0 written) (nth 1 written)))) - (delete-directory dir t)))) - (provide 'test-music-config--radio) ;;; test-music-config--radio.el ends here diff --git a/tests/test-music-config--safe-filename.el b/tests/test-music-config--safe-filename.el deleted file mode 100644 index 8105ee15..00000000 --- a/tests/test-music-config--safe-filename.el +++ /dev/null @@ -1,97 +0,0 @@ -;;; test-music-config--safe-filename.el --- Tests for filename sanitization -*- coding: utf-8; lexical-binding: t; -*- -;; -;; Author: Craig Jennings <c@cjennings.net> -;; -;;; Commentary: -;; Unit tests for cj/music--safe-filename function. -;; Tests the pure helper that sanitizes filenames by replacing invalid chars. -;; -;; Test organization: -;; - Normal Cases: Valid filenames unchanged, spaces replaced -;; - Boundary Cases: Special chars, unicode, slashes, consecutive invalid chars -;; - Error Cases: Nil input -;; -;;; Code: - -(require 'ert) - -;; Stub missing dependencies before loading music-config -(defvar-keymap cj/custom-keymap - :doc "Stub keymap for testing") - -;; Load production code -(require 'music-config) - -;;; Normal Cases - -(ert-deftest test-music-config--safe-filename-normal-alphanumeric-unchanged () - "Validate alphanumeric filename remains unchanged." - (should (string= (cj/music--safe-filename "MyPlaylist123") - "MyPlaylist123"))) - -(ert-deftest test-music-config--safe-filename-normal-with-hyphens-unchanged () - "Validate filename with hyphens remains unchanged." - (should (string= (cj/music--safe-filename "my-playlist-name") - "my-playlist-name"))) - -(ert-deftest test-music-config--safe-filename-normal-with-underscores-unchanged () - "Validate filename with underscores remains unchanged." - (should (string= (cj/music--safe-filename "my_playlist_name") - "my_playlist_name"))) - -(ert-deftest test-music-config--safe-filename-normal-spaces-replaced () - "Validate spaces are replaced with underscores." - (should (string= (cj/music--safe-filename "My Favorite Songs") - "My_Favorite_Songs"))) - -;;; Boundary Cases - -(ert-deftest test-music-config--safe-filename-boundary-special-chars-replaced () - "Validate special characters are replaced with underscores." - (should (string= (cj/music--safe-filename "playlist@#$%^&*()") - "playlist_________"))) - -(ert-deftest test-music-config--safe-filename-boundary-unicode-replaced () - "Validate unicode characters are replaced with underscores." - (should (string= (cj/music--safe-filename "中文歌曲") - "____"))) - -(ert-deftest test-music-config--safe-filename-boundary-mixed-valid-invalid () - "Validate mixed valid and invalid characters." - (should (string= (cj/music--safe-filename "Rock & Roll") - "Rock___Roll"))) - -(ert-deftest test-music-config--safe-filename-boundary-dots-replaced () - "Validate dots are replaced with underscores." - (should (string= (cj/music--safe-filename "my.playlist.name") - "my_playlist_name"))) - -(ert-deftest test-music-config--safe-filename-boundary-slashes-replaced () - "Validate slashes are replaced with underscores." - (should (string= (cj/music--safe-filename "folder/file") - "folder_file"))) - -(ert-deftest test-music-config--safe-filename-boundary-consecutive-invalid-chars () - "Validate consecutive invalid characters each become underscores." - (should (string= (cj/music--safe-filename "test!!!name") - "test___name"))) - -(ert-deftest test-music-config--safe-filename-boundary-empty-string-unchanged () - "Validate empty string remains unchanged." - (should (string= (cj/music--safe-filename "") - ""))) - -(ert-deftest test-music-config--safe-filename-boundary-only-invalid-chars () - "Validate string with only invalid characters becomes all underscores." - (should (string= (cj/music--safe-filename "!@#$%") - "_____"))) - -;;; Error Cases - -(ert-deftest test-music-config--safe-filename-error-nil-input-signals-error () - "Validate nil input signals error." - (should-error (cj/music--safe-filename nil) - :type 'wrong-type-argument)) - -(provide 'test-music-config--safe-filename) -;;; test-music-config--safe-filename.el ends here diff --git a/tests/test-music-config--save-helpers.el b/tests/test-music-config--save-helpers.el new file mode 100644 index 00000000..ffb0a477 --- /dev/null +++ b/tests/test-music-config--save-helpers.el @@ -0,0 +1,90 @@ +;;; test-music-config--save-helpers.el --- save default-name + directory tests -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; The pure helpers behind the queue-first save flow: which name the save +;; prompt pre-fills (`cj/music--save-default-name') and which directory the +;; file lands in (`cj/music--save-directory'). An all-stream queue saves into +;; the radio playlist home (`cj/music-radio-save-dir'); anything else saves +;; into `cj/music-m3u-root'. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module. +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +(let ((emms-dir (car (file-expand-wildcards + (expand-file-name "elpa/emms-*" user-emacs-directory))))) + (when emms-dir (add-to-list 'load-path emms-dir))) + +(require 'emms) +(require 'music-config) + +(declare-function cj/music--save-default-name "music-config" (tracks file entries)) +(declare-function cj/music--save-directory "music-config" (tracks)) +(declare-function cj/music-radio--station-track "music-config" (st)) + +;;; --------------------------- save-default-name ------------------------------- + +(ert-deftest test-music-save-default-name-normal-associated-file-wins () + "Normal: an associated playlist file names the save, station or not." + (let ((tr (cj/music-radio--station-track '(:name "S" :url "https://s.example/x")))) + (should (equal (cj/music--save-default-name (list tr) "/pl/jazz.m3u" nil) + "jazz")))) + +(ert-deftest test-music-save-default-name-normal-station-title () + "Normal: with no file, the first url track's station name pre-fills." + (let ((tr (cj/music-radio--station-track + '(:name "Groove Salad" :url "https://gs.example/x")))) + (should (equal (cj/music--save-default-name (list tr) nil nil) + "Groove Salad")))) + +(ert-deftest test-music-save-default-name-normal-first-url-track-wins () + "Normal: a file track ahead of the station doesn't block the station name; +the first URL track with a name wins." + (let ((f (emms-track 'file "/music/a.flac")) + (tr (cj/music-radio--station-track + '(:name "Second Pick" :url "https://sp.example/x")))) + (should (equal (cj/music--save-default-name (list f tr) nil nil) + "Second Pick")))) + +(ert-deftest test-music-save-default-name-normal-entries-fallback () + "Normal: a propertyless url track resolves its name from ENTRIES." + (let* ((url "https://legacy.example/s") + (tr (emms-track 'url url)) + (entries (list (list url :name "Legacy FM" :uuid nil :favicon nil)))) + (should (equal (cj/music--save-default-name (list tr) nil entries) + "Legacy FM")))) + +(ert-deftest test-music-save-default-name-boundary-no-candidates-nil () + "Boundary: no file, no url tracks -> nil (caller falls back to a timestamp)." + (should-not (cj/music--save-default-name + (list (emms-track 'file "/music/a.flac")) nil nil)) + (should-not (cj/music--save-default-name nil nil nil))) + +;;; ---------------------------- save-directory --------------------------------- + +(ert-deftest test-music-save-directory-normal-all-streams-radio-dir () + "Normal: an all-stream queue saves into the radio playlist dir." + (let ((u1 (emms-track 'url "https://a.example/1")) + (u2 (emms-track 'url "https://b.example/2"))) + (should (equal (cj/music--save-directory (list u1 u2)) + cj/music-radio-save-dir)))) + +(ert-deftest test-music-save-directory-normal-mixed-goes-to-m3u-root () + "Normal: any non-stream track routes the save to the music playlist root." + (let ((u (emms-track 'url "https://a.example/1")) + (f (emms-track 'file "/music/a.flac"))) + (should (equal (cj/music--save-directory (list u f)) + cj/music-m3u-root)))) + +(ert-deftest test-music-save-directory-boundary-empty-goes-to-m3u-root () + "Boundary: an empty queue defaults to the music playlist root." + (should (equal (cj/music--save-directory nil) cj/music-m3u-root))) + +(provide 'test-music-config--save-helpers) +;;; test-music-config--save-helpers.el ends here diff --git a/tests/test-music-config-create-radio-station.el b/tests/test-music-config-create-radio-station.el index 1f4365a4..7d9adbed 100644 --- a/tests/test-music-config-create-radio-station.el +++ b/tests/test-music-config-create-radio-station.el @@ -1,153 +1,130 @@ -;;; test-music-config-create-radio-station.el --- Tests for radio station creation -*- coding: utf-8; lexical-binding: t; -*- +;;; test-music-config-create-radio-station.el --- Tests for manual radio-station entry -*- coding: utf-8; lexical-binding: t; -*- ;; ;; Author: Craig Jennings <c@cjennings.net> ;; ;;; Commentary: -;; Unit tests for cj/music-create-radio-station function. -;; Tests M3U file creation for radio stations with stream URLs. +;; Unit tests for cj/music-create-radio-station under the queue-first model: +;; a hand-entered name + URL becomes a url track in the playlist queue (with +;; the name as its title property) and playback starts. Nothing is written +;; to disk — saving is the normal playlist-save flow. ;; ;; Test organization: -;; - Normal Cases: Standard creation, EXTM3U format, safe filename -;; - Boundary Cases: Unicode name, complex URL, overwrite confirmed -;; - Error Cases: Empty name, empty URL, overwrite declined -;; +;; - Normal Cases: track queued with title, playback started, no file written +;; - Boundary Cases: unicode name preserved verbatim +;; - Error Cases: empty name, empty URL + ;;; Code: (require 'ert) -(require 'testutil-general) +(require 'cl-lib) ;; Stub missing dependencies before loading music-config (defvar-keymap cj/custom-keymap :doc "Stub keymap for testing") -;; Load production code -(require 'music-config) - -;;; Setup & Teardown +(let ((emms-dir (car (file-expand-wildcards + (expand-file-name "elpa/emms-*" user-emacs-directory))))) + (when emms-dir (add-to-list 'load-path emms-dir))) -(defun test-music-config-create-radio-station-setup () - "Setup test environment with temp directory for M3U output." - (cj/create-test-base-dir) - (cj/create-test-subdirectory "radio-playlists")) +(require 'emms) +(require 'music-config) -(defun test-music-config-create-radio-station-teardown () - "Clean up test environment." - (cj/delete-test-base-dir)) +(defmacro test-music-create-radio--with-env (&rest body) + "Run BODY with a fresh playlist buffer, playback mocked, messages captured." + `(let* ((cj/music-playlist-buffer-name + (generate-new-buffer-name "*test-create-radio*")) + (emms-player-playing-p nil) + (started 0) (msg nil)) + (ignore started msg) + (unwind-protect + (cl-letf (((symbol-function 'emms-start) + (lambda () (setq started (1+ started)))) + ((symbol-function 'emms-stop) #'ignore) + ((symbol-function 'message) + (lambda (fmt &rest args) + (when fmt (setq msg (apply #'format fmt args)))))) + ,@body) + (when (get-buffer cj/music-playlist-buffer-name) + (kill-buffer cj/music-playlist-buffer-name))))) + +(defun test-music-create-radio--queued-tracks () + "Track objects currently in the test playlist buffer." + (let ((tracks '())) + (with-current-buffer cj/music-playlist-buffer-name + (save-excursion + (goto-char (point-min)) + (while (not (eobp)) + (when-let ((tr (emms-playlist-track-at (point)))) + (push tr tracks)) + (forward-line 1)))) + (nreverse tracks))) ;;; Normal Cases -(ert-deftest test-music-config-create-radio-station-normal-creates-m3u-file () - "Creating a radio station produces an M3U file in the music root." - (let ((test-dir (test-music-config-create-radio-station-setup))) - (unwind-protect - (let ((cj/music-m3u-root test-dir)) - (cj/music-create-radio-station "Jazz FM" "http://stream.jazzfm.com/radio") - (let ((expected-file (expand-file-name "Jazz_FM_Radio.m3u" test-dir))) - (should (file-exists-p expected-file)))) - (test-music-config-create-radio-station-teardown)))) - -(ert-deftest test-music-config-create-radio-station-normal-extm3u-format () - "Created file contains EXTM3U header, EXTINF with station name, and URL." - (let ((test-dir (test-music-config-create-radio-station-setup))) +(ert-deftest test-music-config-create-radio-station-normal-queues-track () + "Normal: name+url queues a url track carrying the name, and playback starts." + (test-music-create-radio--with-env + (cj/music-create-radio-station "Jazz FM" "http://stream.jazzfm.com/radio") + (let ((tracks (test-music-create-radio--queued-tracks))) + (should (= (length tracks) 1)) + (should (eq (emms-track-type (car tracks)) 'url)) + (should (equal (emms-track-name (car tracks)) "http://stream.jazzfm.com/radio")) + (should (equal (emms-track-get (car tracks) 'info-title) "Jazz FM"))) + (should (= started 1)) + (should (string-match-p "Jazz FM" msg)))) + +(ert-deftest test-music-config-create-radio-station-normal-writes-no-file () + "Normal: nothing lands on disk — saving is the playlist-save flow's job." + (let ((tmp (file-name-as-directory (make-temp-file "cj-radio-nofile-" t)))) (unwind-protect - (let ((cj/music-m3u-root test-dir)) - (cj/music-create-radio-station "Jazz FM" "http://stream.jazzfm.com/radio") - (let ((content (with-temp-buffer - (insert-file-contents - (expand-file-name "Jazz_FM_Radio.m3u" test-dir)) - (buffer-string)))) - (should (string-match-p "^#EXTM3U" content)) - (should (string-match-p "#EXTINF:-1,Jazz FM" content)) - (should (string-match-p "http://stream.jazzfm.com/radio" content)))) - (test-music-config-create-radio-station-teardown)))) - -(ert-deftest test-music-config-create-radio-station-normal-safe-filename () - "Station name with special characters produces filesystem-safe filename." - (let ((test-dir (test-music-config-create-radio-station-setup))) - (unwind-protect - (let ((cj/music-m3u-root test-dir)) - (cj/music-create-radio-station "Rock & Roll 101.5" "http://example.com/stream") - ;; Spaces and special chars replaced with underscores - (let ((expected-file (expand-file-name "Rock___Roll_101_5_Radio.m3u" test-dir))) - (should (file-exists-p expected-file)))) - (test-music-config-create-radio-station-teardown)))) + (test-music-create-radio--with-env + (let ((cj/music-m3u-root tmp) + (cj/music-radio-save-dir tmp)) + (cj/music-create-radio-station "NPR" "https://example.test/stream") + (should-not (directory-files tmp nil "\\.m3u\\'")))) + (delete-directory tmp t)))) ;;; Boundary Cases -(ert-deftest test-music-config-create-radio-station-boundary-unicode-name-safe-filename () - "Unicode station name produces safe filename while preserving name in EXTINF." - (let ((test-dir (test-music-config-create-radio-station-setup))) - (unwind-protect - (let ((cj/music-m3u-root test-dir)) - (cj/music-create-radio-station "Klassik Radio" "http://example.com/stream") - ;; Name is all ASCII-safe, so filename uses it directly - (should (file-exists-p (expand-file-name "Klassik_Radio_Radio.m3u" test-dir))) - ;; Original name preserved in EXTINF inside the file - (let ((content (with-temp-buffer - (insert-file-contents - (expand-file-name "Klassik_Radio_Radio.m3u" test-dir)) - (buffer-string)))) - (should (string-match-p "Klassik Radio" content)))) - (test-music-config-create-radio-station-teardown)))) - -(ert-deftest test-music-config-create-radio-station-boundary-url-with-query-params () - "Complex URL with query parameters preserved in file content." - (let ((test-dir (test-music-config-create-radio-station-setup))) - (unwind-protect - (let ((cj/music-m3u-root test-dir) - (url "https://stream.example.com/radio?format=mp3&quality=320&token=abc123")) - (cj/music-create-radio-station "Test Radio" url) - (let ((content (with-temp-buffer - (insert-file-contents - (expand-file-name "Test_Radio_Radio.m3u" test-dir)) - (buffer-string)))) - (should (string-match-p (regexp-quote url) content)))) - (test-music-config-create-radio-station-teardown)))) - -(ert-deftest test-music-config-create-radio-station-boundary-overwrite-confirmed () - "Overwriting existing file when user confirms succeeds." - (let ((test-dir (test-music-config-create-radio-station-setup))) - (unwind-protect - (let ((cj/music-m3u-root test-dir)) - ;; Create initial file - (cj/music-create-radio-station "MyRadio" "http://old.url/stream") - (let ((file (expand-file-name "MyRadio_Radio.m3u" test-dir))) - (should (file-exists-p file)) - ;; Overwrite with user confirming - (cl-letf (((symbol-function 'yes-or-no-p) (lambda (_prompt) t))) - (cj/music-create-radio-station "MyRadio" "http://new.url/stream")) - ;; File should now contain new URL - (let ((content (with-temp-buffer - (insert-file-contents file) - (buffer-string)))) - (should (string-match-p "http://new.url/stream" content)) - (should-not (string-match-p "http://old.url/stream" content))))) - (test-music-config-create-radio-station-teardown)))) +(ert-deftest test-music-config-create-radio-station-boundary-unicode-name () + "Boundary: a unicode name is kept verbatim on the track (no filename munging)." + (test-music-create-radio--with-env + (cj/music-create-radio-station "Café Del Mar ☕" "https://cafe.example/stream") + (should (equal (emms-track-get (car (test-music-create-radio--queued-tracks)) + 'info-title) + "Café Del Mar ☕")))) + +;;; Keymap + +(ert-deftest test-music-config-radio-map-prefix-mirrors-playlist-keys () + "Normal: C-; m r is a radio prefix whose n/t/m mirror the playlist buffer." + (let ((map (lookup-key cj/music-map "r"))) + (should (keymapp map)) + (should (eq (lookup-key map "n") 'cj/music-radio-search-by-name)) + (should (eq (lookup-key map "t") 'cj/music-radio-search-by-tag)) + (should (eq (lookup-key map "m") 'cj/music-create-radio-station)))) + +(ert-deftest test-music-config-menu-map-lowercase-keys () + "Normal: the menu's former uppercase keys live on lowercase homes, and the +playlist buffer saves on v." + (should (eq (lookup-key cj/music-map "v") 'cj/music-playlist-show)) + (should (eq (lookup-key cj/music-map "u") 'emms-shuffle)) + (should (eq (lookup-key cj/music-map "l") 'emms-toggle-repeat-playlist)) + (should-not (lookup-key cj/music-map "R")) + (should-not (lookup-key cj/music-map "M")) + (should-not (lookup-key cj/music-map "Z")) + (should (eq (lookup-key emms-playlist-mode-map "v") 'cj/music-playlist-save)) + (should-not (eq (lookup-key emms-playlist-mode-map "w") 'cj/music-playlist-save))) ;;; Error Cases (ert-deftest test-music-config-create-radio-station-error-empty-name-signals-user-error () - "Empty station name signals user-error." - (should-error (cj/music-create-radio-station "" "http://example.com/stream") - :type 'user-error)) + "Error: empty name signals user-error." + (should-error (cj/music-create-radio-station "" "https://x") :type 'user-error)) (ert-deftest test-music-config-create-radio-station-error-empty-url-signals-user-error () - "Empty URL signals user-error." - (should-error (cj/music-create-radio-station "Test Radio" "") - :type 'user-error)) - -(ert-deftest test-music-config-create-radio-station-error-overwrite-declined-signals-user-error () - "Declining overwrite signals user-error." - (let ((test-dir (test-music-config-create-radio-station-setup))) - (unwind-protect - (let ((cj/music-m3u-root test-dir)) - ;; Create initial file - (cj/music-create-radio-station "MyRadio" "http://old.url/stream") - ;; Decline overwrite - (cl-letf (((symbol-function 'yes-or-no-p) (lambda (_prompt) nil))) - (should-error (cj/music-create-radio-station "MyRadio" "http://new.url/stream") - :type 'user-error))) - (test-music-config-create-radio-station-teardown)))) + "Error: empty URL signals user-error." + (should-error (cj/music-create-radio-station "NPR" "") :type 'user-error)) (provide 'test-music-config-create-radio-station) ;;; test-music-config-create-radio-station.el ends here diff --git a/tests/test-music-config-more-commands.el b/tests/test-music-config-more-commands.el index c351c1f1..530aa379 100644 --- a/tests/test-music-config-more-commands.el +++ b/tests/test-music-config-more-commands.el @@ -9,7 +9,9 @@ ;; cj/music-playlist-edit ;; cj/music-playlist-toggle ;; cj/music-playlist-show -;; cj/music-create-radio-station +;; +;; cj/music-create-radio-station lives in +;; test-music-config-create-radio-station.el. ;;; Code: @@ -17,12 +19,21 @@ (require 'cl-lib) (add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Stub dependencies before loading the module. +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +(let ((emms-dir (car (file-expand-wildcards + (expand-file-name "elpa/emms-*" user-emacs-directory))))) + (when emms-dir (add-to-list 'load-path emms-dir))) + +(require 'emms) (require 'music-config) ;; Top-level defvars so let-binds reach the dynamic var under lexical ;; scope. (defvar cj/music-playlist-file nil) -(defvar emms-source-playlist-ask-before-overwrite t) ;;; cj/music-playlist-load @@ -56,28 +67,87 @@ ;;; cj/music-playlist-save -(ert-deftest test-music-playlist-save-writes-fresh-name () - "Normal: save with a fresh name writes via emms-playlist-save." - (let* ((tmp (file-name-as-directory (make-temp-file "cj-music-save-" t))) - (cj/music-m3u-root tmp) - saved-args msg) - (unwind-protect - (cl-letf (((symbol-function 'cj/music--get-m3u-basenames) - (lambda () nil)) - ((symbol-function 'completing-read) - (lambda (&rest _) "fresh")) - ((symbol-function 'cj/music--ensure-playlist-buffer) - (lambda () (current-buffer))) - ((symbol-function 'emms-playlist-save) - (lambda (fmt path) (setq saved-args (list fmt path)))) - ((symbol-function 'cj/music--sync-playlist-file) #'ignore) - ((symbol-function 'message) - (lambda (fmt &rest args) (setq msg (apply #'format fmt args))))) - (cj/music-playlist-save)) - (delete-directory tmp t)) - (should (equal (car saved-args) 'm3u)) - (should (string-match-p "fresh\\.m3u\\'" (cadr saved-args))) - (should (string-match-p "Saved playlist" msg)))) +(defmacro test-music-save--with-env (&rest body) + "Run BODY with a fresh playlist buffer and temp save directories. +Binds TMP-M3U and TMP-RADIO (both cleaned up) and captures completing-read's +INITIAL argument in CR-INITIAL." + `(let* ((cj/music-playlist-buffer-name + (generate-new-buffer-name "*test-save*")) + (tmp-m3u (file-name-as-directory (make-temp-file "cj-save-m3u-" t))) + (tmp-radio (file-name-as-directory (make-temp-file "cj-save-radio-" t))) + (cj/music-m3u-root tmp-m3u) + (cj/music-radio-save-dir tmp-radio) + (cr-initial 'unset)) + (ignore cr-initial) + (unwind-protect + (cl-letf (((symbol-function 'message) #'ignore) + ((symbol-function 'completing-read) + (lambda (_prompt _coll &optional _pred _req initial _hist def &rest _) + (setq cr-initial initial) + (or initial def "fallback")))) + ,@body) + (when (get-buffer cj/music-playlist-buffer-name) + (kill-buffer cj/music-playlist-buffer-name)) + (delete-directory tmp-m3u t) + (delete-directory tmp-radio t)))) + +(defun test-music-save--queue (tracks) + "Put TRACKS into the (fresh) test playlist buffer." + (with-current-buffer (cj/music--ensure-playlist-buffer) + (save-excursion + (goto-char (point-max)) + (dolist (tr tracks) + (emms-playlist-insert-track tr))))) + +(ert-deftest test-music-playlist-save-station-queue-prefills-and-targets-radio-dir () + "Normal: an all-stream queue pre-fills the station name and saves into the +radio dir with the station metadata written." + (test-music-save--with-env + (let ((tr (emms-track 'url "https://gs.example/stream"))) + (emms-track-set tr 'info-title "Groove Salad") + (emms-track-set tr 'radio-uuid "uuid-gs") + (test-music-save--queue (list tr))) + (cj/music-playlist-save) + (should (equal cr-initial "Groove Salad")) + (let ((file (expand-file-name "Groove Salad.m3u" tmp-radio))) + (should (file-exists-p file)) + (with-temp-buffer + (insert-file-contents file) + (let ((text (buffer-string))) + (should (string-match-p "^#EXTINF:-1,Groove Salad$" text)) + (should (string-match-p "^#RADIOBROWSERUUID:uuid-gs$" text)) + (should (string-match-p "^https://gs\\.example/stream$" text))))) + (should-not (directory-files tmp-m3u nil "\\.m3u\\'")))) + +(ert-deftest test-music-playlist-save-file-queue-targets-m3u-root-no-prefill () + "Normal: a file queue saves into the music root with no station pre-fill." + (test-music-save--with-env + (test-music-save--queue (list (emms-track 'file "/music/a.flac"))) + (cj/music-playlist-save) + (should-not cr-initial) + (should (= (length (directory-files tmp-m3u nil "\\.m3u\\'")) 1)) + (should-not (directory-files tmp-radio nil "\\.m3u\\'")))) + +(ert-deftest test-music-playlist-save-associated-file-name-wins-over-station () + "Normal: a queue with an associated playlist file defaults to that name, +even when it contains stations." + (test-music-save--with-env + (let ((tr (emms-track 'url "https://gs.example/stream"))) + (emms-track-set tr 'info-title "Groove Salad") + (test-music-save--queue (list tr))) + (with-current-buffer (cj/music--ensure-playlist-buffer) + (setq cj/music-playlist-file (expand-file-name "morning.m3u" tmp-radio))) + (cj/music-playlist-save) + (should-not cr-initial) + (should (file-exists-p (expand-file-name "morning.m3u" tmp-radio))))) + +(ert-deftest test-music-playlist-save-error-empty-name () + "Error: an empty name at the prompt signals user-error, not a hidden .m3u." + (test-music-save--with-env + (test-music-save--queue (list (emms-track 'url "https://x.example/s"))) + (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) ""))) + (should-error (cj/music-playlist-save) :type 'user-error)) + (should-not (directory-files tmp-radio nil "m3u")))) ;;; cj/music-playlist-edit @@ -138,36 +208,5 @@ (should (eq switched buf)) (should msg))) -;;; cj/music-create-radio-station - -(ert-deftest test-music-create-radio-station-writes-m3u () - "Normal: with name+url, an EXTM3U-style file is written into music-m3u-root." - (let* ((tmp (file-name-as-directory (make-temp-file "cj-music-radio-" t))) - (cj/music-m3u-root tmp) - msg) - (unwind-protect - (progn - (cl-letf (((symbol-function 'message) - (lambda (fmt &rest args) (setq msg (apply #'format fmt args))))) - (cj/music-create-radio-station "NPR" "https://example.test/stream")) - (let ((file (expand-file-name "NPR_Radio.m3u" tmp))) - (should (file-exists-p file)) - (with-temp-buffer - (insert-file-contents file) - (let ((text (buffer-string))) - (should (string-match-p "#EXTM3U" text)) - (should (string-match-p "NPR" text)) - (should (string-match-p "https://example.test/stream" text)))))) - (delete-directory tmp t)) - (should (string-match-p "Created radio station" msg)))) - -(ert-deftest test-music-create-radio-station-rejects-empty-name () - "Error: an empty name is rejected with user-error." - (should-error (cj/music-create-radio-station "" "https://x") :type 'user-error)) - -(ert-deftest test-music-create-radio-station-rejects-empty-url () - "Error: an empty URL is rejected with user-error." - (should-error (cj/music-create-radio-station "NPR" "") :type 'user-error)) - (provide 'test-music-config-more-commands) ;;; test-music-config-more-commands.el ends here diff --git a/tests/test-org-capture-config--neutralize.el b/tests/test-org-capture-config--neutralize.el new file mode 100644 index 00000000..4a03830a --- /dev/null +++ b/tests/test-org-capture-config--neutralize.el @@ -0,0 +1,136 @@ +;;; test-org-capture-config--neutralize.el --- Popup neutralize-guard tests -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the "org-capture" popup neutralize guards. The popup frame opens +;; showing the daemon's last buffer; if a capture aborts before its UI paints, +;; the popup lingers on that buffer, and a live eat/vterm terminal shown there +;; clamps the real frame to the popup's rows. The guards repoint every +;; non-capture-UI window of the popup at *scratch*: one fires on frame creation +;; (after-make-frame-functions), one on any buffer change +;; (window-buffer-change-functions). +;; +;; The tests drive the real batch frame (renamed to "org-capture" and restored +;; in cleanup, the same idiom as the popup-window integration test) rather than +;; mocking frame primitives. `window-buffer-change-functions' only runs during +;; redisplay, so the module's own hook cannot fire mid-test in batch. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'org) +(require 'org-capture) +(require 'user-constants) +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'org-capture-config) + +(defmacro test-neutralize--with-popup-frame (buffer-name &rest body) + "Run BODY with the batch frame named \"org-capture\" showing BUFFER-NAME. +The frame name reverts to auto-naming and the test buffer is killed +afterward. BODY sees the buffer bound to `buf'." + (declare (indent 1)) + `(let ((buf (get-buffer-create ,buffer-name))) + (unwind-protect + (progn + (set-frame-parameter nil 'name "org-capture") + (delete-other-windows) + (set-window-buffer (selected-window) buf) + ,@body) + ;; Restore to nil, not the saved name: the batch frame's auto name is + ;; "F1", and Emacs refuses to set F<num>-shaped names explicitly. + ;; nil reverts to auto-naming (same idiom as the popup-window test). + (set-frame-parameter nil 'name nil) + (when (buffer-live-p buf) (kill-buffer buf))))) + +;;; cj/org-capture--neutralize-frame + +(ert-deftest test-org-capture-config-neutralize-frame-evicts-plain-buffer () + "Normal: a non-capture-UI buffer in the popup frame is repointed to *scratch*." + (test-neutralize--with-popup-frame "neutralize-plain.org" + (cj/org-capture--neutralize-frame (selected-frame)) + (should (equal (buffer-name (window-buffer (selected-window))) + "*scratch*")))) + +(ert-deftest test-org-capture-config-neutralize-frame-spares-capture-buffer () + "Normal: a CAPTURE-* buffer is capture UI and stays put." + (test-neutralize--with-popup-frame "CAPTURE-neutralize.org" + (cj/org-capture--neutralize-frame (selected-frame)) + (should (eq (window-buffer (selected-window)) buf)))) + +(ert-deftest test-org-capture-config-neutralize-frame-spares-select-menu () + "Normal: the *Org Select* template menu is capture UI and stays put." + (test-neutralize--with-popup-frame "*Org Select*" + (cj/org-capture--neutralize-frame (selected-frame)) + (should (eq (window-buffer (selected-window)) buf)))) + +(ert-deftest test-org-capture-config-neutralize-frame-scratch-untouched () + "Boundary: a window already on *scratch* is left alone (idempotent)." + (let ((scratch (get-buffer-create "*scratch*"))) + (unwind-protect + (progn + (set-frame-parameter nil 'name "org-capture") + (delete-other-windows) + (set-window-buffer (selected-window) scratch) + (cj/org-capture--neutralize-frame (selected-frame)) + (should (eq (window-buffer (selected-window)) scratch)) + ;; Second pass is also a no-op. + (cj/org-capture--neutralize-frame (selected-frame)) + (should (eq (window-buffer (selected-window)) scratch))) + (set-frame-parameter nil 'name nil)))) + +(ert-deftest test-org-capture-config-neutralize-frame-other-frame-untouched () + "Boundary: a frame not named \"org-capture\" is never neutralized." + (let ((buf (get-buffer-create "neutralize-other-frame.org"))) + (unwind-protect + (progn + (set-frame-parameter nil 'name "some-other-frame") + (delete-other-windows) + (set-window-buffer (selected-window) buf) + (cj/org-capture--neutralize-frame (selected-frame)) + (should (eq (window-buffer (selected-window)) buf))) + (set-frame-parameter nil 'name nil) + (when (buffer-live-p buf) (kill-buffer buf))))) + +(ert-deftest test-org-capture-config-neutralize-frame-dead-input-no-error () + "Error: nil and non-frame inputs are ignored without raising." + (should-not (cj/org-capture--neutralize-frame nil)) + (should-not (cj/org-capture--neutralize-frame 'not-a-frame))) + +;;; cj/org-capture--neutralize-new-frame (Guard 1 wrapper) + +(ert-deftest test-org-capture-config-neutralize-new-frame-delegates () + "Normal: the frame-creation wrapper neutralizes the popup frame." + (test-neutralize--with-popup-frame "neutralize-new-frame.org" + (cj/org-capture--neutralize-new-frame (selected-frame)) + (should (equal (buffer-name (window-buffer (selected-window))) + "*scratch*")))) + +;;; cj/org-capture--neutralize-on-buffer-change (Guard 2 wrapper) + +(ert-deftest test-org-capture-config-neutralize-on-buffer-change-window-arg () + "Normal: a window argument resolves to its frame and neutralizes it. +`window-buffer-change-functions' passes a window when buffer-local." + (test-neutralize--with-popup-frame "neutralize-window-arg.org" + (cj/org-capture--neutralize-on-buffer-change (selected-window)) + (should (equal (buffer-name (window-buffer (selected-window))) + "*scratch*")))) + +(ert-deftest test-org-capture-config-neutralize-on-buffer-change-frame-arg () + "Normal: a frame argument passes straight through. +`window-buffer-change-functions' passes a frame when global." + (test-neutralize--with-popup-frame "neutralize-frame-arg.org" + (cj/org-capture--neutralize-on-buffer-change (selected-frame)) + (should (equal (buffer-name (window-buffer (selected-window))) + "*scratch*")))) + +;;; Hook wiring + +(ert-deftest test-org-capture-config-neutralize-hooks-registered () + "Normal: both guards are installed on their hooks at module load." + (should (memq #'cj/org-capture--neutralize-new-frame + after-make-frame-functions)) + (should (memq #'cj/org-capture--neutralize-on-buffer-change + window-buffer-change-functions))) + +(provide 'test-org-capture-config--neutralize) +;;; test-org-capture-config--neutralize.el ends here diff --git a/tests/test-org-contacts-config-find.el b/tests/test-org-contacts-config-find.el new file mode 100644 index 00000000..60a5e713 --- /dev/null +++ b/tests/test-org-contacts-config-find.el @@ -0,0 +1,72 @@ +;;; test-org-contacts-config-find.el --- Tests for cj/org-contacts-find -*- lexical-binding: t; -*- + +;;; Commentary: +;; cj/org-contacts-find collects contact headings (name, position, and the +;; EMAIL/PHONE annotation) before prompting, then jumps to the selected +;; heading's stored position. These tests pin the collector helper and a +;; command-level smoke test: the jump must land on the heading, never on a +;; body line that merely mentions the name. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'org) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'org-contacts-config) + +;;; cj/--org-contacts-collect + +(ert-deftest test-org-contacts-collect-returns-name-position-info () + "Normal: collector returns heading name, heading position, and EMAIL/PHONE." + (with-temp-buffer + (org-mode) + (insert "* Alice\n:PROPERTIES:\n:EMAIL: alice@example.com\n:END:\n" + "some body text mentioning Bob\n" + "* Bob\n:PROPERTIES:\n:PHONE: 555-1234\n:END:\n") + (let* ((alist (cj/--org-contacts-collect (current-buffer))) + (alice (assoc "Alice" alist)) + (bob (assoc "Bob" alist))) + (should (equal (length alist) 2)) + (should (equal (nth 2 alice) "alice@example.com")) + (should (equal (nth 2 bob) "555-1234")) + ;; positions land on the headings, not the body line that mentions Bob + (should (save-excursion (goto-char (nth 1 alice)) (looking-at "\\* Alice"))) + (should (save-excursion (goto-char (nth 1 bob)) (looking-at "\\* Bob")))))) + +(ert-deftest test-org-contacts-collect-empty-buffer-returns-nil () + "Boundary: a buffer with no headings yields an empty alist." + (with-temp-buffer + (org-mode) + (should (null (cj/--org-contacts-collect (current-buffer)))))) + +(ert-deftest test-org-contacts-collect-heading-without-props-has-nil-info () + "Error: a heading with no EMAIL or PHONE yields nil info." + (with-temp-buffer + (org-mode) + (insert "* Carol\n") + (let ((alist (cj/--org-contacts-collect (current-buffer)))) + (should (equal (nth 2 (assoc "Carol" alist)) nil))))) + +;;; cj/org-contacts-find + +(ert-deftest test-org-contacts-find-jumps-to-heading-not-body () + "Normal: selecting a contact jumps to its heading, not a body mention." + (let ((contacts-file (make-temp-file "org-contacts-test-" nil ".org"))) + (unwind-protect + (progn + (with-temp-file contacts-file + (insert "* Alice\n:PROPERTIES:\n:EMAIL: alice@example.com\n:END:\n" + "note: call Bob about Alice\n" + "* Bob\n:PROPERTIES:\n:PHONE: 555-1234\n:END:\n")) + (cl-letf (((symbol-function 'completing-read) + (lambda (&rest _) "Bob")) + ((symbol-function 'org-fold-show-entry) #'ignore) + ((symbol-function 'org-reveal) #'ignore)) + (cj/org-contacts-find) + (should (looking-at "\\* Bob")))) + (delete-file contacts-file)))) + +(provide 'test-org-contacts-config-find) +;;; test-org-contacts-config-find.el ends here diff --git a/tests/test-org-drill-config-source.el b/tests/test-org-drill-config-source.el new file mode 100644 index 00000000..ccda99ef --- /dev/null +++ b/tests/test-org-drill-config-source.el @@ -0,0 +1,31 @@ +;;; test-org-drill-config-source.el --- Tests for org-drill source selection -*- lexical-binding: t; -*- + +;;; Commentary: +;; cj/--org-drill-source-keywords decides how use-package obtains org-drill: +;; a local dev checkout via :load-path when it exists, otherwise the upstream +;; repo via :vc. Without this, a machine lacking the checkout hits :demand t +;; against a nonexistent :load-path and drill fails to load entirely. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'org-drill-config) + +(ert-deftest test-org-drill-source-keywords-uses-load-path-when-checkout-exists () + "Normal: an existing checkout directory selects :load-path." + (let ((dir (make-temp-file "org-drill-checkout-" t))) + (unwind-protect + (should (equal (cj/--org-drill-source-keywords dir) + (list :load-path dir))) + (delete-directory dir t)))) + +(ert-deftest test-org-drill-source-keywords-falls-back-to-vc-when-absent () + "Error: a missing checkout falls back to a :vc install spec." + (let ((kws (cj/--org-drill-source-keywords "/nonexistent/org-drill-xyz"))) + (should (eq (car kws) :vc)) + (should (plist-get (nth 1 kws) :url)))) + +(provide 'test-org-drill-config-source) +;;; test-org-drill-config-source.el ends here diff --git a/tests/test-org-reveal-config-keymap.el b/tests/test-org-reveal-config-keymap.el new file mode 100644 index 00000000..17a21b19 --- /dev/null +++ b/tests/test-org-reveal-config-keymap.el @@ -0,0 +1,38 @@ +;;; test-org-reveal-config-keymap.el --- Tests for org-reveal-config prefix keymap -*- lexical-binding: t; -*- + +;;; Commentary: +;; The presentation commands are reached through `cj/reveal-map', a prefix +;; keymap registered under "C-; p" via `cj/register-prefix-map'. These +;; tests pin that structure so the module can't silently regress to raw +;; `global-set-key' calls (which carry a hidden load-order dependency on +;; keybindings.el establishing "C-;" as a prefix first). + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'keybindings) +(require 'org-reveal-config) + +(ert-deftest test-org-reveal-config-reveal-map-is-a-keymap () + "Normal: `cj/reveal-map' is a keymap." + (should (keymapp cj/reveal-map))) + +(ert-deftest test-org-reveal-config-reveal-map-bindings () + "Normal: each presentation command is reachable under `cj/reveal-map'." + (dolist (pair '(("SPC" . cj/reveal-present) + ("e" . cj/reveal-export) + ("p" . cj/reveal-preview-start) + ("s" . cj/reveal-preview-stop) + ("h" . cj/reveal-insert-header) + ("H" . cj/reveal-remove-headers) + ("n" . cj/reveal-new))) + (should (eq (keymap-lookup cj/reveal-map (car pair)) (cdr pair))))) + +(ert-deftest test-org-reveal-config-reveal-map-registered-under-p () + "Normal: `cj/reveal-map' is registered under \"p\" in `cj/custom-keymap'." + (should (eq (keymap-lookup cj/custom-keymap "p") cj/reveal-map))) + +(provide 'test-org-reveal-config-keymap) +;;; test-org-reveal-config-keymap.el ends here diff --git a/tests/test-org-roam-config-tag-and-find.el b/tests/test-org-roam-config-tag-and-find.el index f98d8af0..97a7a683 100644 --- a/tests/test-org-roam-config-tag-and-find.el +++ b/tests/test-org-roam-config-tag-and-find.el @@ -147,6 +147,17 @@ ;; The 4th arg is the subdir (should (equal (nth 3 args) "recipes/")))) +(ert-deftest test-org-roam-find-node-project-delegates-to-find-node () + "Normal: `find-node-project' uses Project tag, 'p' key, project.org template." + (let ((args nil)) + (cl-letf (((symbol-function 'cj/org-roam-find-node) + (lambda (&rest a) (setq args a)))) + (cj/org-roam-find-node-project)) + (should (equal (car args) "Project")) + (should (equal (cadr args) "p")) + ;; The 3rd arg is the template file, under the canonical roam-dir/templates/. + (should (string-suffix-p "templates/project.org" (nth 2 args))))) + ;;; cj/org-roam-node-insert-immediate (ert-deftest test-org-roam-node-insert-immediate-rebinds-templates-and-calls-insert () diff --git a/tests/test-prog-general-lsp.el b/tests/test-prog-general-lsp.el new file mode 100644 index 00000000..e61e9a17 --- /dev/null +++ b/tests/test-prog-general-lsp.el @@ -0,0 +1,148 @@ +;;; test-prog-general-lsp.el --- LSP config + helper tests -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the LSP configuration in prog-general.el, the single owner of +;; generic LSP policy (prog-lsp.el was folded in and removed 2026-07-10). +;; +;; Two layers: +;; - Load-time invariants that hold the moment the config loads, before any +;; server starts: lsp-enable-remote stays nil (so TRAMP files don't auto-start +;; a slow LSP), and no mode accrues a duplicate lsp-deferred entry. +;; - The pure helpers cj/lsp--add-file-watch-ignored-extras and +;; cj/lsp--remove-eldoc-provider-global, exercised directly with Normal / +;; Boundary / Error cases. +;; +;; The quiet-UI :config defaults (snippet off, symbol highlighting off, +;; idle-delay 0.5, ...) are deferred to lsp-mode's own load (see the make-test +;; no-package-initialize note in CLAUDE.md), so they aren't asserted here; the +;; daemon verifies them. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'use-package) + +;; Declare lsp-mode's defcustom / eldoc's hook as special so `let' binds them +;; dynamically. Real definitions load only when lsp-mode / eldoc activate; in +;; the test environment these provide the value cells the helpers read through. +(defvar lsp-file-watch-ignored-directories nil) +(defvar eldoc-documentation-functions nil) + +(defun lsp-eldoc-function (&rest _args) + "Stub lsp-mode Eldoc function for tests.") + +(require 'prog-general) + +;;; Load-time invariants + +(ert-deftest test-prog-general-lsp-enable-remote-nil () + "Normal: lsp-enable-remote is nil so LSP never auto-starts on TRAMP files." + (should (boundp 'lsp-enable-remote)) + (should (null lsp-enable-remote))) + +(ert-deftest test-prog-general-lsp-no-duplicate-mode-hook () + "Boundary: a mode never holds more than one lsp-deferred entry. +The per-language modules add lsp-deferred to their own mode hooks; add-hook +dedups identical symbols, and this pins that invariant so a future non-symbol +\(lambda) addition that breaks it gets caught." + (dolist (hook '(c-mode-hook python-mode-hook go-ts-mode-hook)) + (when (boundp hook) + (should (>= 1 (cl-count 'lsp-deferred (symbol-value hook))))))) + +;;; File-watch ignore helper — Normal + +(ert-deftest test-prog-general-lsp-file-watch-adds-all-patterns () + "Normal: every entry from `cj/lsp-file-watch-ignored-extras' lands in the list." + (let ((lsp-file-watch-ignored-directories nil)) + (cj/lsp--add-file-watch-ignored-extras) + (should (= (length lsp-file-watch-ignored-directories) + (length cj/lsp-file-watch-ignored-extras))) + (dolist (pattern cj/lsp-file-watch-ignored-extras) + (should (member pattern lsp-file-watch-ignored-directories))))) + +(ert-deftest test-prog-general-lsp-file-watch-extends-not-replaces () + "Normal: pre-existing entries (lsp-mode defaults) are preserved." + (let ((lsp-file-watch-ignored-directories + '("[/\\\\]\\.git\\'" "[/\\\\]\\.svn\\'" "[/\\\\]\\.idea\\'"))) + (cj/lsp--add-file-watch-ignored-extras) + (should (member "[/\\\\]\\.git\\'" lsp-file-watch-ignored-directories)) + (should (member "[/\\\\]\\.svn\\'" lsp-file-watch-ignored-directories)) + (should (member "[/\\\\]\\.idea\\'" lsp-file-watch-ignored-directories)) + (dolist (pattern cj/lsp-file-watch-ignored-extras) + (should (member pattern lsp-file-watch-ignored-directories))))) + +(ert-deftest test-prog-general-lsp-file-watch-key-patterns-present () + "Normal: specific expected directory names appear in the constant." + (dolist (name '("node_modules" "target" "__pycache__" ".venv" "venv" + "dist" "coverage" "test-results" "playwright-report" + ".terraform" ".ruff_cache" ".pytest_cache" ".mypy_cache")) + (should (cl-some (lambda (p) (string-match-p (regexp-quote name) p)) + cj/lsp-file-watch-ignored-extras)))) + +;;; File-watch ignore helper — Boundary + +(ert-deftest test-prog-general-lsp-file-watch-idempotent () + "Boundary: calling twice leaves each pattern present exactly once." + (let ((lsp-file-watch-ignored-directories nil)) + (cj/lsp--add-file-watch-ignored-extras) + (cj/lsp--add-file-watch-ignored-extras) + (should (= (length lsp-file-watch-ignored-directories) + (length cj/lsp-file-watch-ignored-extras))) + (dolist (pattern cj/lsp-file-watch-ignored-extras) + (should (= 1 (cl-count pattern lsp-file-watch-ignored-directories + :test #'equal)))))) + +(ert-deftest test-prog-general-lsp-file-watch-all-patterns-non-empty () + "Boundary: every pattern is a non-empty string." + (dolist (pattern cj/lsp-file-watch-ignored-extras) + (should (stringp pattern)) + (should (not (string-empty-p pattern))))) + +(ert-deftest test-prog-general-lsp-file-watch-all-patterns-valid-regex () + "Boundary: every pattern compiles as a valid Emacs regex." + (dolist (pattern cj/lsp-file-watch-ignored-extras) + (condition-case err + ;; string-match-p compiles the regex; invalid syntax raises invalid-regexp. + (string-match-p pattern "/some/sample/path") + (invalid-regexp + (ert-fail (format "Invalid regex %S: %s" + pattern (error-message-string err))))))) + +;;; File-watch ignore helper — Error + +(ert-deftest test-prog-general-lsp-file-watch-non-list-target () + "Error: non-list target value triggers `add-to-list' wrong-type-argument." + (let ((lsp-file-watch-ignored-directories "not-a-list")) + (should-error (cj/lsp--add-file-watch-ignored-extras) + :type 'wrong-type-argument))) + +;;; Eldoc provider helper + +(ert-deftest test-prog-general-lsp-eldoc-provider-removed-globally () + "Normal: remove lsp-mode's Eldoc provider from the global hook value. +The per-buffer removal this replaced raced lsp-mode's own buffer-local hook +population; removing globally before any LSP buffer attaches makes the absence +stick for every subsequent lsp-managed buffer." + (let ((eldoc-documentation-functions + (list #'lsp-eldoc-function 'eldoc-documentation-default))) + (cj/lsp--remove-eldoc-provider-global) + (should-not (memq #'lsp-eldoc-function eldoc-documentation-functions)) + (should (memq 'eldoc-documentation-default eldoc-documentation-functions)))) + +(ert-deftest test-prog-general-lsp-eldoc-provider-removal-idempotent () + "Boundary: re-running the removal after the provider is gone is a no-op." + (let ((eldoc-documentation-functions '(eldoc-documentation-default))) + (cj/lsp--remove-eldoc-provider-global) + (cj/lsp--remove-eldoc-provider-global) + (should (equal eldoc-documentation-functions + '(eldoc-documentation-default))))) + +(ert-deftest test-prog-general-lsp-no-obsolete-eldoc-hook-reference () + "Regression: prog-general should not reference obsolete `lsp-eldoc-hook'." + (with-temp-buffer + (insert-file-contents (expand-file-name "modules/prog-general.el" user-emacs-directory)) + (should-not (re-search-forward "\\_<lsp-eldoc-hook\\_>" nil t)))) + +(provide 'test-prog-general-lsp) +;;; test-prog-general-lsp.el ends here diff --git a/tests/test-prog-lsp--add-file-watch-ignored-extras.el b/tests/test-prog-lsp--add-file-watch-ignored-extras.el deleted file mode 100644 index 9b71cab8..00000000 --- a/tests/test-prog-lsp--add-file-watch-ignored-extras.el +++ /dev/null @@ -1,116 +0,0 @@ -;;; test-prog-lsp--add-file-watch-ignored-extras.el --- Tests for cj/lsp--add-file-watch-ignored-extras -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests for cj/lsp--add-file-watch-ignored-extras in prog-lsp.el. -;; The function adds project-agnostic build/cache directory patterns to -;; `lsp-file-watch-ignored-directories' without replacing lsp-mode's -;; defaults. Patterns are sourced from `cj/lsp-file-watch-ignored-extras'. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -;; Declare lsp-mode's defcustom as a special variable so `let' binds it -;; dynamically. Real definition is lsp-mode's; loaded only when use-package -;; activates lsp-mode. In the test environment, this stub provides the value -;; cell `add-to-list' needs. -(defvar lsp-file-watch-ignored-directories nil) -(defvar eldoc-documentation-functions nil) - -(defun lsp-eldoc-function (&rest _args) - "Stub lsp-mode Eldoc function for tests.") - -(require 'prog-lsp) - -;;; Normal Cases - -(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-normal-adds-all-patterns () - "Normal: every entry from `cj/lsp-file-watch-ignored-extras' lands in the list." - (let ((lsp-file-watch-ignored-directories nil)) - (cj/lsp--add-file-watch-ignored-extras) - (should (= (length lsp-file-watch-ignored-directories) - (length cj/lsp-file-watch-ignored-extras))) - (dolist (pattern cj/lsp-file-watch-ignored-extras) - (should (member pattern lsp-file-watch-ignored-directories))))) - -(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-normal-extends-not-replaces () - "Normal: pre-existing entries (lsp-mode defaults) are preserved." - (let ((lsp-file-watch-ignored-directories - '("[/\\\\]\\.git\\'" "[/\\\\]\\.svn\\'" "[/\\\\]\\.idea\\'"))) - (cj/lsp--add-file-watch-ignored-extras) - (should (member "[/\\\\]\\.git\\'" lsp-file-watch-ignored-directories)) - (should (member "[/\\\\]\\.svn\\'" lsp-file-watch-ignored-directories)) - (should (member "[/\\\\]\\.idea\\'" lsp-file-watch-ignored-directories)) - (dolist (pattern cj/lsp-file-watch-ignored-extras) - (should (member pattern lsp-file-watch-ignored-directories))))) - -(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-normal-key-patterns-present () - "Normal: specific expected directory names appear in the constant." - (dolist (name '("node_modules" "target" "__pycache__" ".venv" "venv" - "dist" "coverage" "test-results" "playwright-report" - ".terraform" ".ruff_cache" ".pytest_cache" ".mypy_cache")) - (should (cl-some (lambda (p) (string-match-p (regexp-quote name) p)) - cj/lsp-file-watch-ignored-extras)))) - -;;; Boundary Cases - -(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-boundary-idempotent () - "Boundary: calling twice doesn't duplicate entries." - (let ((lsp-file-watch-ignored-directories nil)) - (cj/lsp--add-file-watch-ignored-extras) - (cj/lsp--add-file-watch-ignored-extras) - (should (= (length lsp-file-watch-ignored-directories) - (length cj/lsp-file-watch-ignored-extras))))) - -(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-boundary-all-patterns-non-empty () - "Boundary: every pattern is a non-empty string." - (dolist (pattern cj/lsp-file-watch-ignored-extras) - (should (stringp pattern)) - (should (not (string-empty-p pattern))))) - -(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-boundary-all-patterns-valid-regex () - "Boundary: every pattern compiles as a valid Emacs regex." - (dolist (pattern cj/lsp-file-watch-ignored-extras) - (condition-case err - ;; string-match-p compiles the regex; invalid syntax raises invalid-regexp. - (string-match-p pattern "/some/sample/path") - (invalid-regexp - (ert-fail (format "Invalid regex %S: %s" - pattern (error-message-string err))))))) - -;;; Error Cases - -(ert-deftest test-prog-lsp--add-file-watch-ignored-extras-error-non-list-target () - "Error: non-list target value triggers `add-to-list' wrong-type-argument." - (let ((lsp-file-watch-ignored-directories "not-a-list")) - (should-error (cj/lsp--add-file-watch-ignored-extras) - :type 'wrong-type-argument))) - -(ert-deftest test-prog-lsp--remove-eldoc-provider-global-removes-from-default () - "Normal: remove lsp-mode's Eldoc provider from the global hook value. -The per-buffer removal that this replaced raced lsp-mode's own buffer- -local hook population; removing globally before any LSP buffer attaches -makes the absence stick for every subsequent lsp-managed buffer." - (let ((eldoc-documentation-functions - '(lsp-eldoc-function eldoc-documentation-default))) - (cj/lsp--remove-eldoc-provider-global) - (should-not (memq #'lsp-eldoc-function eldoc-documentation-functions)) - (should (memq 'eldoc-documentation-default eldoc-documentation-functions)))) - -(ert-deftest test-prog-lsp--remove-eldoc-provider-global-is-idempotent () - "Boundary: re-running the removal after the provider is gone is a no-op." - (let ((eldoc-documentation-functions '(eldoc-documentation-default))) - (cj/lsp--remove-eldoc-provider-global) - (cj/lsp--remove-eldoc-provider-global) - (should (equal eldoc-documentation-functions - '(eldoc-documentation-default))))) - -(ert-deftest test-prog-lsp--module-no-obsolete-lsp-eldoc-hook-reference () - "Regression: prog-lsp should not reference obsolete `lsp-eldoc-hook'." - (with-temp-buffer - (insert-file-contents (expand-file-name "modules/prog-lsp.el" user-emacs-directory)) - (should-not (re-search-forward "\\_<lsp-eldoc-hook\\_>" nil t)))) - -(provide 'test-prog-lsp--add-file-watch-ignored-extras) -;;; test-prog-lsp--add-file-watch-ignored-extras.el ends here diff --git a/tests/test-prog-lsp.el b/tests/test-prog-lsp.el deleted file mode 100644 index 7e38111d..00000000 --- a/tests/test-prog-lsp.el +++ /dev/null @@ -1,66 +0,0 @@ -;;; test-prog-lsp.el --- Startup smoke test for LSP config resolution -*- lexical-binding: t; -*- - -;;; Commentary: -;; A narrow smoke test of prog-lsp.el, the central LSP module. It pins the -;; invariants that should hold the moment the config loads, before any server -;; starts: lsp-enable-remote stays nil (so TRAMP files don't auto-start a slow -;; LSP), the file-watch-ignore defaults live in one idempotent place, the eldoc -;; provider is stripped from the global hook, and a mode never accrues a -;; duplicate lsp-deferred entry. The generic :config defaults are deferred to -;; lsp-mode's own load (see the make-test no-package-initialize note in -;; CLAUDE.md), so this tests the top-level :init and helper surface, which runs. - -;;; Code: - -(require 'ert) -(require 'cl-lib) -(require 'use-package) -(require 'prog-lsp) - -;; lsp-mode's defcustom isn't loaded under make test, and prog-lsp's bare -;; `(defvar lsp-file-watch-ignored-directories)' only marks it special within -;; that file's unit. Declare it special here too so the `let' bindings below -;; bind dynamically (the helper reads it through the symbol via add-to-list). -(defvar lsp-file-watch-ignored-directories nil) - -(ert-deftest test-prog-lsp-enable-remote-nil () - "Normal: lsp-enable-remote is nil so LSP never auto-starts on TRAMP files." - (should (boundp 'lsp-enable-remote)) - (should (null lsp-enable-remote))) - -(ert-deftest test-prog-lsp-file-watch-adds-extras () - "Normal: the build/cache ignore patterns get appended to lsp's watch-ignore list." - (let ((lsp-file-watch-ignored-directories '("[/\\\\]\\.git\\'"))) - (cj/lsp--add-file-watch-ignored-extras) - (dolist (pattern cj/lsp-file-watch-ignored-extras) - (should (member pattern lsp-file-watch-ignored-directories))) - (should (member "[/\\\\]\\.git\\'" lsp-file-watch-ignored-directories)))) - -(ert-deftest test-prog-lsp-file-watch-idempotent () - "Boundary: adding the extras twice leaves each pattern present exactly once." - (let ((lsp-file-watch-ignored-directories '())) - (cj/lsp--add-file-watch-ignored-extras) - (cj/lsp--add-file-watch-ignored-extras) - (dolist (pattern cj/lsp-file-watch-ignored-extras) - (should (= 1 (cl-count pattern lsp-file-watch-ignored-directories - :test #'equal)))))) - -(ert-deftest test-prog-lsp-eldoc-provider-removed-globally () - "Normal: the global eldoc provider is stripped so lsp can't reattach it." - (let ((eldoc-documentation-functions - (list #'lsp-eldoc-function #'ignore))) - (cj/lsp--remove-eldoc-provider-global) - (should-not (memq 'lsp-eldoc-function eldoc-documentation-functions)) - (should (memq 'ignore eldoc-documentation-functions)))) - -(ert-deftest test-prog-lsp-no-duplicate-mode-hook () - "Boundary: a mode prog-lsp wires never holds more than one lsp-deferred entry. -prog-lsp and the per-language modules both add lsp-deferred for some modes; -add-hook dedups identical symbols, and this pins that invariant so a future -non-symbol (lambda) addition that breaks it gets caught." - (dolist (hook '(c-mode-hook python-mode-hook go-ts-mode-hook)) - (when (boundp hook) - (should (>= 1 (cl-count 'lsp-deferred (symbol-value hook))))))) - -(provide 'test-prog-lsp) -;;; test-prog-lsp.el ends here diff --git a/tests/test-prog-python--lsp-guard.el b/tests/test-prog-python--lsp-guard.el new file mode 100644 index 00000000..071e1ca9 --- /dev/null +++ b/tests/test-prog-python--lsp-guard.el @@ -0,0 +1,64 @@ +;;; test-prog-python--lsp-guard.el --- Python LSP guard tests -*- lexical-binding: t; -*- + +;;; Commentary: +;; The config audit found lsp-pyright's unguarded :hook lambda calling +;; (require 'lsp-pyright) + (lsp-deferred) on every python-ts buffer, so +;; pyright-less machines got the LSP attach prompt that cj/python-setup's +;; guard exists to prevent. The guarded branch now owns the require and +;; the attach, and both classic and treesit modes run the same setup. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'prog-python) + +(defmacro test-python-guard--setup (pyright-found &rest body) + "Run `cj/python-setup' with pyright presence set to PYRIGHT-FOUND. +Records requires into `required' and lsp attaches into `attached'; +BODY sees both. Package minor modes are stubbed at their boundary." + (declare (indent 1)) + `(let ((required '()) (attached nil)) + (cl-letf (((symbol-function 'company-mode) #'ignore) + ((symbol-function 'flyspell-prog-mode) #'ignore) + ((symbol-function 'superword-mode) #'ignore) + ((symbol-function 'executable-find) + (lambda (&rest _) ,pyright-found)) + ((symbol-function 'require) + (lambda (feature &rest _) (push feature required) feature)) + ((symbol-function 'lsp-deferred) + (lambda () (setq attached t)))) + (with-temp-buffer + (cj/python-setup)) + ,@body))) + +(ert-deftest test-prog-python-setup-pyright-absent-no-lsp () + "Error: without pyright, setup neither loads lsp-pyright nor attaches." + (test-python-guard--setup nil + (should-not (memq 'lsp-pyright required)) + (should-not attached))) + +(ert-deftest test-prog-python-setup-pyright-present-attaches () + "Normal: with pyright on PATH, setup loads lsp-pyright then attaches." + (test-python-guard--setup "/usr/bin/pyright" + (should (memq 'lsp-pyright required)) + (should attached))) + +(ert-deftest test-prog-python-hooks-cover-both-mode-variants () + "Normal: classic and treesit python modes both run the same setup." + (dolist (hook '(python-mode-hook python-ts-mode-hook)) + (should (memq #'cj/python-setup (symbol-value hook))) + (should (memq #'cj/python-mode-keybindings (symbol-value hook))))) + +(ert-deftest test-prog-python-no-anonymous-hook-lambdas () + "Boundary: no anonymous lambda remains on the python hooks. +The audit's unguarded lsp-pyright lambda was anonymous; symbols only +means every hook entry is a named, greppable function." + (dolist (hook '(python-mode-hook python-ts-mode-hook)) + (dolist (fn (symbol-value hook)) + (should (symbolp fn))))) + +(provide 'test-prog-python--lsp-guard) +;;; test-prog-python--lsp-guard.el ends here diff --git a/tests/test-show-kill-ring--insert-item.el b/tests/test-show-kill-ring--insert-item.el deleted file mode 100644 index a29ca75e..00000000 --- a/tests/test-show-kill-ring--insert-item.el +++ /dev/null @@ -1,73 +0,0 @@ -;;; test-show-kill-ring--insert-item.el --- Tests for show-kill-insert-item -*- lexical-binding: t -*- - -;;; Commentary: -;; Tests for `show-kill-insert-item' in show-kill-ring.el — inserts a -;; kill-ring entry into the current buffer, truncating to -;; `show-kill-max-item-size' with an ellipsis when too long. The ellipsis -;; sits inline for short items and on its own line for items wider than the -;; frame. Frame width is read at runtime so the test is environment-stable. - -;;; Code: - -(require 'ert) -(require 'show-kill-ring) - -;;; Normal Cases - -(ert-deftest test-show-kill-ring-insert-item-short-verbatim () - "Normal: an item shorter than the max is inserted unchanged." - (let ((show-kill-max-item-size 1000)) - (with-temp-buffer - (show-kill-insert-item "hello") - (should (string= (buffer-string) "hello"))))) - -(ert-deftest test-show-kill-ring-insert-item-inline-ellipsis () - "Normal: an over-max item narrower than the frame gets an inline ellipsis." - (let* ((show-kill-max-item-size 5) - (len (/ (frame-width) 2)) ; > max, < (frame-width - 5) - (item (make-string len ?b))) - (with-temp-buffer - (show-kill-insert-item item) - (should (string= (buffer-string) "bbbbb..."))))) - -;;; Boundary Cases - -(ert-deftest test-show-kill-ring-insert-item-length-equals-max-truncates () - "Boundary: length exactly equal to max truncates — the guard is (< len max)." - (let ((show-kill-max-item-size 5)) - (with-temp-buffer - (show-kill-insert-item "hello") ; length 5, equals max - (should (string= (buffer-string) "hello..."))))) - -(ert-deftest test-show-kill-ring-insert-item-wide-newline-ellipsis () - "Boundary: an item wider than the frame puts the ellipsis on its own line." - (let* ((show-kill-max-item-size 5) - (item (make-string (+ (frame-width) 10) ?a))) - (with-temp-buffer - (show-kill-insert-item item) - (should (string= (buffer-string) "aaaaa\n..."))))) - -(ert-deftest test-show-kill-ring-insert-item-max-nil-verbatim () - "Boundary: a non-numeric max disables truncation." - (let ((show-kill-max-item-size nil)) - (with-temp-buffer - (show-kill-insert-item "anything long enough to exceed nothing") - (should (string= (buffer-string) - "anything long enough to exceed nothing"))))) - -(ert-deftest test-show-kill-ring-insert-item-max-negative-verbatim () - "Boundary: a negative max disables truncation." - (let ((show-kill-max-item-size -1)) - (with-temp-buffer - (show-kill-insert-item "abc") - (should (string= (buffer-string) "abc"))))) - -(ert-deftest test-show-kill-ring-insert-item-empty-string () - "Boundary: an empty item inserts nothing and does not error." - (let ((show-kill-max-item-size 1000)) - (with-temp-buffer - (show-kill-insert-item "") - (should (string= (buffer-string) ""))))) - -(provide 'test-show-kill-ring--insert-item) -;;; test-show-kill-ring--insert-item.el ends here diff --git a/tests/test-signal-config-notify.el b/tests/test-signal-config-notify.el deleted file mode 100644 index 1a772289..00000000 --- a/tests/test-signal-config-notify.el +++ /dev/null @@ -1,150 +0,0 @@ -;;; test-signal-config-notify.el --- Tests for the signal-config notification slice -*- lexical-binding: t -*- - -;;; Commentary: -;; ERT tests for the notification slice of `signal-config': the pure -;; body formatter (whitespace collapse + truncation to -;; `cj/signal--notify-body-max') and `cj/signel--notify' routing (the -;; suppression gate, the notify-script path with the sound flag, and -;; the `notifications-notify' fallback). Spec: the "Notification -;; slice" addendum in docs/specs/signal-client-spec-doing.org. No signal-cli or -;; linked account needed. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -;; signel is the fork at ~/code/signel; signal-config wires it via -;; use-package but these tests need the symbols available directly. -(eval-and-compile - (add-to-list 'load-path (expand-file-name "~/code/signel"))) -(require 'signel) - -(require 'signal-config) - -;;; cj/signal--format-notify-body - -(ert-deftest test-signal-config-format-notify-body-passthrough () - "Normal: short single-line text passes through unchanged." - (should (equal (cj/signal--format-notify-body "lunch at noon?") - "lunch at noon?"))) - -(ert-deftest test-signal-config-format-notify-body-collapses-whitespace () - "Normal: newlines and whitespace runs collapse to single spaces." - (should (equal (cj/signal--format-notify-body "two\nlines\n\nhere") - "two lines here")) - (should (equal (cj/signal--format-notify-body "tabs\t\tand spaces") - "tabs and spaces"))) - -(ert-deftest test-signal-config-format-notify-body-trims () - "Boundary: leading and trailing whitespace is trimmed." - (should (equal (cj/signal--format-notify-body " hi ") "hi"))) - -(ert-deftest test-signal-config-format-notify-body-empty () - "Boundary: the empty string stays empty." - (should (equal (cj/signal--format-notify-body "") ""))) - -(ert-deftest test-signal-config-format-notify-body-exact-limit () - "Boundary: a body exactly at the limit is untouched." - (let ((s (make-string cj/signal--notify-body-max ?x))) - (should (equal (cj/signal--format-notify-body s) s)))) - -(ert-deftest test-signal-config-format-notify-body-truncates-over-limit () - "Boundary: over-limit text truncates to the limit, ending in an ellipsis." - (let* ((s (make-string (1+ cj/signal--notify-body-max) ?x)) - (out (cj/signal--format-notify-body s))) - (should (= (length out) cj/signal--notify-body-max)) - (should (string-suffix-p "…" out)))) - -(ert-deftest test-signal-config-format-notify-body-unicode () - "Boundary: multibyte text truncates by characters, not bytes." - (let* ((s (make-string (+ cj/signal--notify-body-max 10) ?é)) - (out (cj/signal--format-notify-body s))) - (should (= (length out) cj/signal--notify-body-max)) - (should (string-suffix-p "…" out)))) - -;;; cj/signel--notify routing - -(ert-deftest test-signal-config-notify-suppressed-when-viewing () - "Normal: nothing fires when the suppression predicate says no." - (let (script-calls fallback-calls) - (cl-letf (((symbol-function 'cj/signal--should-notify-p) - (lambda (_chat-id) nil)) - ((symbol-function 'start-process) - (lambda (&rest args) (push args script-calls) nil)) - ((symbol-function 'notifications-notify) - (lambda (&rest args) (push args fallback-calls) nil))) - (cj/signel--notify "+15551234567" "Alice" "hi")) - (should-not script-calls) - (should-not fallback-calls))) - -(ert-deftest test-signal-config-notify-script-silent-by-default () - "Normal: with the script present and sound off, runs notify info --silent." - (let (script-calls) - (cl-letf (((symbol-function 'cj/signal--should-notify-p) - (lambda (_chat-id) t)) - ((symbol-function 'executable-find) - (lambda (p &optional _remote) - (when (equal p "notify") "/usr/bin/notify"))) - ((symbol-function 'start-process) - (lambda (&rest args) (push args script-calls) nil)) - ((symbol-function 'notifications-notify) - (lambda (&rest _) - (error "Fallback must not fire when the script is present")))) - (let ((cj/signel-notify-sound nil)) - (cj/signel--notify "+15551234567" "Alice" "hi"))) - (should (= (length script-calls) 1)) - ;; start-process args: (NAME BUFFER PROGRAM &rest PROGRAM-ARGS); - ;; PROGRAM is the path executable-find resolved, not the bare name. - (should (equal (nthcdr 2 (car script-calls)) - '("/usr/bin/notify" "info" "Signal: Alice" "hi" "--silent"))))) - -(ert-deftest test-signal-config-notify-sound-enabled-drops-silent () - "Normal: with `cj/signel-notify-sound' non-nil, --silent is omitted." - (let (script-calls) - (cl-letf (((symbol-function 'cj/signal--should-notify-p) - (lambda (_chat-id) t)) - ((symbol-function 'executable-find) - (lambda (p &optional _remote) - (when (equal p "notify") "/usr/bin/notify"))) - ((symbol-function 'start-process) - (lambda (&rest args) (push args script-calls) nil))) - (let ((cj/signel-notify-sound t)) - (cj/signel--notify "+15551234567" "Alice" "hi"))) - (should (equal (nthcdr 2 (car script-calls)) - '("/usr/bin/notify" "info" "Signal: Alice" "hi"))))) - -(ert-deftest test-signal-config-notify-fallback-when-script-missing () - "Error: without the script on PATH, falls back to notifications-notify." - (let (script-calls fallback-calls) - (cl-letf (((symbol-function 'cj/signal--should-notify-p) - (lambda (_chat-id) t)) - ((symbol-function 'executable-find) - (lambda (_p &optional _remote) nil)) - ((symbol-function 'start-process) - (lambda (&rest args) (push args script-calls) nil)) - ((symbol-function 'notifications-notify) - (lambda (&rest args) (push args fallback-calls) nil))) - (cj/signel--notify "+15551234567" "Alice" "hi")) - (should-not script-calls) - (should (= (length fallback-calls) 1)) - (let ((args (car fallback-calls))) - (should (equal (plist-get args :title) "Signal: Alice")) - (should (equal (plist-get args :body) "hi"))))) - -(ert-deftest test-signal-config-notify-formats-body-before-send () - "Normal: the body runs through the formatter before reaching the script." - (let (script-calls) - (cl-letf (((symbol-function 'cj/signal--should-notify-p) - (lambda (_chat-id) t)) - ((symbol-function 'executable-find) - (lambda (p &optional _remote) - (when (equal p "notify") "/usr/bin/notify"))) - ((symbol-function 'start-process) - (lambda (&rest args) (push args script-calls) nil))) - (let ((cj/signel-notify-sound nil)) - (cj/signel--notify "+15551234567" "Alice" "first line\nsecond line"))) - (should (equal (nth 5 (car script-calls)) "first line second line")))) - -(provide 'test-signal-config-notify) -;;; test-signal-config-notify.el ends here diff --git a/tests/test-signal-config.el b/tests/test-signal-config.el deleted file mode 100644 index 7556efdb..00000000 --- a/tests/test-signal-config.el +++ /dev/null @@ -1,407 +0,0 @@ -;;; test-signal-config.el --- Tests for signal-config -*- lexical-binding: t -*- - -;;; Commentary: -;; ERT tests for the pure helper layer of `signal-config': contact-list -;; parsing for the contact picker, and the notify-when-not-viewing -;; predicate. These need neither signal-cli nor a linked account. - -;;; Code: - -(require 'ert) -(require 'cl-lib) -(require 'json) - -;; signel is the fork at ~/code/signel; signal-config wires it via -;; use-package but the connection-guard/fetch tests need the symbols -;; available directly. -(eval-and-compile - (add-to-list 'load-path (expand-file-name "~/code/signel"))) -(require 'signel) - -(require 'signal-config) - -;;; cj/signal--jstr - -(ert-deftest test-signal-config-jstr-string () - "Normal: a non-blank string passes through unchanged." - (should (equal (cj/signal--jstr "hi") "hi"))) - -(ert-deftest test-signal-config-jstr-rejects-nonstrings () - "Boundary/Error: nil, empty, whitespace, and non-string sentinels become nil." - (should-not (cj/signal--jstr nil)) - (should-not (cj/signal--jstr "")) - (should-not (cj/signal--jstr " ")) - (should-not (cj/signal--jstr :null)) - (should-not (cj/signal--jstr 42))) - -;;; cj/signal--contact-display-name -;; Field priority: nickName, then nickGiven+nickFamily, then top-level name, -;; then top-level given+family, then profile given+family, then username. - -(ert-deftest test-signal-config-display-name-prefers-name () - "Normal: the top-level combined name wins over the given/family parts." - (should (equal (cj/signal--contact-display-name - '((name . "Alice Anderson") (givenName . "Ali") (familyName . "A"))) - "Alice Anderson"))) - -(ert-deftest test-signal-config-display-name-nickname-wins () - "Normal: a nickName overrides the contact name." - (should (equal (cj/signal--contact-display-name - '((nickName . "Edster") (name . "Eve Edwards") - (givenName . "Eve") (familyName . "Edwards"))) - "Edster"))) - -(ert-deftest test-signal-config-display-name-nickname-parts () - "Boundary: nickGivenName+nickFamilyName combine when nickName is unset." - (should (equal (cj/signal--contact-display-name - '((nickGivenName . "DJ") (nickFamilyName . "Cool") (name . "Daniel"))) - "DJ Cool"))) - -(ert-deftest test-signal-config-display-name-toplevel-given-family () - "Boundary: with no name, top-level givenName+familyName combine." - (should (equal (cj/signal--contact-display-name - '((name) (givenName . "Bob") (familyName . "Brown"))) - "Bob Brown"))) - -(ert-deftest test-signal-config-display-name-profile-fallback () - "Boundary: with no name or top-level parts, profile given/family is the fallback." - (should (equal (cj/signal--contact-display-name - '((name) (givenName) (familyName) - (profile . ((givenName . "Carol") (familyName))))) - "Carol"))) - -(ert-deftest test-signal-config-display-name-username-fallback () - "Boundary: username is the last name source." - (should (equal (cj/signal--contact-display-name - '((name) (username . "dave.42"))) - "dave.42"))) - -(ert-deftest test-signal-config-display-name-none () - "Error: no usable name yields nil." - (should-not (cj/signal--contact-display-name - '((name) (givenName) (familyName) - (profile . ((givenName) (familyName)))))) - (should-not (cj/signal--contact-display-name '((number . "+15551112222"))))) - -;;; cj/signal--parse-contacts - -(defconst test-signal-config--contacts-json - "[ - {\"number\":\"+15551112222\",\"uuid\":\"uuid-a\",\"name\":\"Alice Anderson\",\"givenName\":\"Alice\",\"familyName\":\"Anderson\",\"nickName\":null,\"nickGivenName\":null,\"nickFamilyName\":null,\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}}, - {\"number\":\"+15553334444\",\"uuid\":null,\"name\":null,\"givenName\":\"Bob\",\"familyName\":\"Brown\",\"nickName\":null,\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}}, - {\"number\":\"+15555556666\",\"uuid\":\"uuid-c\",\"name\":null,\"givenName\":null,\"familyName\":null,\"nickName\":null,\"username\":null,\"profile\":{\"givenName\":\"Carol\",\"familyName\":null}}, - {\"number\":null,\"uuid\":\"uuid-d\",\"name\":null,\"givenName\":null,\"familyName\":null,\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}}, - {\"number\":\"+15557778888\",\"uuid\":\"uuid-e\",\"name\":\"Eve Edwards\",\"givenName\":\"Eve\",\"familyName\":\"Edwards\",\"nickName\":\"Edster\",\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}} -]" - "Synthetic fixture mirroring the signal-cli 0.14.4.1 `listContacts' shape: -top-level name/givenName/familyName and nickName fields, with a profile -sub-object whose name fields are usually null. Field layout was confirmed -against a live linked account on 2026-05-26; the values here are fake.") - -(ert-deftest test-signal-config-parse-contacts-normal () - "Normal: top-level name, top-level parts, profile fallback, uuid fallback, nickname." - (let* ((result (json-read-from-string test-signal-config--contacts-json)) - (pairs (cj/signal--parse-contacts result))) - (should (equal pairs - '(("Alice Anderson (+15551112222)" . "+15551112222") - ("Bob Brown (+15553334444)" . "+15553334444") - ("Carol (+15555556666)" . "+15555556666") - ("Edster (+15557778888)" . "+15557778888") - ("uuid-d" . "uuid-d")))))) - -(ert-deftest test-signal-config-parse-contacts-empty () - "Boundary: an empty result yields nil for both vector and nil input." - (should-not (cj/signal--parse-contacts [])) - (should-not (cj/signal--parse-contacts nil))) - -(ert-deftest test-signal-config-parse-contacts-accepts-list-and-vector () - "Boundary: vector and list result sequences parse identically." - (let ((entry '((number . "+15551112222") (name . "Al")))) - (should (equal (cj/signal--parse-contacts (vector entry)) - (cj/signal--parse-contacts (list entry)))))) - -(ert-deftest test-signal-config-parse-contacts-drops-recipientless () - "Error: a contact with neither number nor uuid is dropped." - (should-not (cj/signal--parse-contacts - (list '((name . "Ghost") (number) (uuid)))))) - -;;; cj/signal--suppress-notify-p - -(ert-deftest test-signal-config-suppress-when-viewing-focused () - "Normal: viewing the chat buffer with focus suppresses the notification." - (should (cj/signal--suppress-notify-p - "+15551112222" "*Signel: +15551112222*" t))) - -(ert-deftest test-signal-config-no-suppress-other-buffer () - "Boundary: a different selected buffer does not suppress." - (should-not (cj/signal--suppress-notify-p - "+15551112222" "*scratch*" t))) - -(ert-deftest test-signal-config-no-suppress-unfocused () - "Boundary: viewing the chat but with the frame unfocused still notifies." - (should-not (cj/signal--suppress-notify-p - "+15551112222" "*Signel: +15551112222*" nil))) - -(ert-deftest test-signal-config-no-suppress-nil-viewing () - "Error: a nil viewing-buffer name does not suppress." - (should-not (cj/signal--suppress-notify-p "+15551112222" nil t))) - -;;; cj/signel--ensure-started - -(ert-deftest test-signal-config-ensure-started-live-process-noop () - "Normal: with a live signel process, ensure-started returns without -calling `signel-start' or the pre-warm fetch." - (let ((start-called nil) - (fetch-called nil)) - (cl-letf (((symbol-function 'process-live-p) (lambda (_) t)) - ((symbol-function 'get-process) (lambda (_) 'fake-proc)) - ((symbol-function 'signel-start) - (lambda () (setq start-called t))) - ((symbol-function 'cj/signel--fetch-contacts) - (lambda (&rest _) (setq fetch-called t)))) - (cj/signel--ensure-started) - (should-not start-called) - (should-not fetch-called)))) - -(ert-deftest test-signal-config-ensure-started-starts-when-account-set () - "Normal: with `signel-account' set and no live process, ensure-started -calls `signel-start' to bring the daemon up." - (let ((start-called nil) - (signel-account "+15555550100")) - (cl-letf (((symbol-function 'process-live-p) (lambda (_) nil)) - ((symbol-function 'get-process) (lambda (_) nil)) - ((symbol-function 'signel-start) - (lambda () (setq start-called t))) - ((symbol-function 'cj/signel--fetch-contacts) - (lambda (&rest _) nil))) - (cj/signel--ensure-started) - (should start-called)))) - -(ert-deftest test-signal-config-ensure-started-prewarms-on-start () - "Normal: when ensure-started actually starts the daemon, it triggers a -pre-warm fetch so the picker cache is warm on first invocation." - (let ((fetch-called nil) - (signel-account "+15555550100")) - (cl-letf (((symbol-function 'process-live-p) (lambda (_) nil)) - ((symbol-function 'get-process) (lambda (_) nil)) - ((symbol-function 'signel-start) (lambda () nil)) - ((symbol-function 'cj/signel--fetch-contacts) - (lambda (&rest _) (setq fetch-called t)))) - (cj/signel--ensure-started) - (should fetch-called)))) - -(ert-deftest test-signal-config-ensure-started-errors-when-no-account () - "Error: with `signel-account' nil, ensure-started signals a user-error -naming the remedy (set the account in the private config) instead of -starting an account-less daemon." - (let ((signel-account nil)) - (cl-letf (((symbol-function 'process-live-p) (lambda (_) nil)) - ((symbol-function 'get-process) (lambda (_) nil))) - (should-error (cj/signel--ensure-started) :type 'user-error)))) - -(ert-deftest test-signal-config-ensure-started-requires-signel-first () - "Error: ensure-started must `require' signel BEFORE reading any of -its private variables, so a `void-variable' error cannot fire when -called before signel has been autoloaded. Captures the bug where -`signel--process-name' was forward-declared in signal-config but not -yet bound at runtime because the `use-package' autoload had not fired. - -Asserts ordering, not just presence: a future refactor that moves the -`require' below the `cond' would still execute the require eventually -but the variable read in the cond would fire void-variable first. -The test fails if `require' isn't the first call inside the function." - (let ((call-order nil)) - (cl-letf (((symbol-function 'require) - (lambda (feature &optional _filename _noerror) - (push (list 'require feature) call-order) - t)) - ((symbol-function 'process-live-p) - (lambda (_) (push 'process-live-p call-order) t)) - ((symbol-function 'get-process) - (lambda (_) (push 'get-process call-order) 'fake-proc))) - (cj/signel--ensure-started) - (should (equal (car (reverse call-order)) '(require signel)))))) - -;;; cj/signel--fetch-contacts + cj/signel--contact-cache - -(ert-deftest test-signal-config-fetch-contacts-issues-list-contacts-rpc () - "Normal: fetch-contacts sends a `listContacts' RPC and registers a -success callback so the response routes back." - (let (sent-method sent-callback) - (cl-letf (((symbol-function 'signel--send-rpc) - (lambda (method _params _target callback) - (setq sent-method method - sent-callback callback) - 1))) - (cj/signel--fetch-contacts)) - (should (equal sent-method "listContacts")) - (should (functionp sent-callback)))) - -(ert-deftest test-signal-config-fetch-contacts-callback-populates-cache () - "Normal: on a successful result, the callback parses the contact list -and stores the (LABEL . RECIPIENT) alist in `cj/signel--contact-cache'." - (let (sent-callback) - (cl-letf (((symbol-function 'signel--send-rpc) - (lambda (_method _params _target callback) - (setq sent-callback callback) 1))) - (setq cj/signel--contact-cache nil) - (cj/signel--fetch-contacts) - (funcall sent-callback - [((number . "+15555550100") (givenName . "Alice"))])) - (should (equal cj/signel--contact-cache - '(("Alice (+15555550100)" . "+15555550100")))))) - -(ert-deftest test-signal-config-fetch-contacts-empty-result-clears-cache () - "Boundary: an empty listContacts result populates the cache as nil, -distinct from a failure path (which never invokes the success callback)." - (let (sent-callback) - (cl-letf (((symbol-function 'signel--send-rpc) - (lambda (_method _params _target callback) - (setq sent-callback callback) 1))) - (setq cj/signel--contact-cache '(("stale" . "+10000000000"))) - (cj/signel--fetch-contacts) - (funcall sent-callback [])) - (should-not cj/signel--contact-cache))) - -;;; cj/signel-refresh-contacts - -(ert-deftest test-signal-config-refresh-contacts-clears-and-refetches () - "Normal: `cj/signel-refresh-contacts' clears the cache and triggers a -fresh fetch so a stale entry can't survive a user-driven refresh." - (let ((fetch-called nil)) - (setq cj/signel--contact-cache '(("stale" . "+10000000000"))) - (cl-letf (((symbol-function 'cj/signel--fetch-contacts) - (lambda (&rest _) (setq fetch-called t)))) - (cj/signel-refresh-contacts)) - (should-not cj/signel--contact-cache) - (should fetch-called))) - -;;; cj/signel-message picker - -(ert-deftest test-signal-config-message-warm-cache-picks-contact () - "Normal: with a warm cache, picking a contact label opens that -recipient's chat buffer." - (let ((chosen-recipient nil) - (signel-account "+15555550100")) - (setq cj/signel--contact-cache - '(("Alice (+15555550200)" . "+15555550200"))) - (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil)) - ((symbol-function 'completing-read) - (lambda (&rest _) "Alice (+15555550200)")) - ((symbol-function 'signel-chat) - (lambda (r) (setq chosen-recipient r)))) - (cj/signel-message)) - (should (equal chosen-recipient "+15555550200")))) - -(ert-deftest test-signal-config-message-warm-cache-picks-note-to-self () - "Normal: the pinned `Note to Self' entry resolves to `signel-account' -so a self-message lands in the Signal Note-to-Self thread." - (let ((chosen-recipient nil) - (signel-account "+15555550100")) - (setq cj/signel--contact-cache - '(("Alice (+15555550200)" . "+15555550200"))) - (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil)) - ((symbol-function 'completing-read) - (lambda (&rest _) "Note to Self")) - ((symbol-function 'signel-chat) - (lambda (r) (setq chosen-recipient r)))) - (cj/signel-message)) - (should (equal chosen-recipient "+15555550100")))) - -(ert-deftest test-signal-config-message-cold-cache-fetch-resolves-in-time () - "Normal: cold cache, fetch's after-callback fires inside the bounded -wait, picker proceeds with the now-warm cache." - (let ((chosen-recipient nil) - (signel-account "+15555550100") - (cj/signel-fetch-timeout 1.0)) - (setq cj/signel--contact-cache nil) - (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil)) - ((symbol-function 'cj/signel--fetch-contacts) - (lambda (&optional after-cb) - (setq cj/signel--contact-cache - '(("Bob (+15555550300)" . "+15555550300"))) - (when after-cb (funcall after-cb)))) - ((symbol-function 'completing-read) - (lambda (&rest _) "Bob (+15555550300)")) - ((symbol-function 'signel-chat) - (lambda (r) (setq chosen-recipient r)))) - (cj/signel-message)) - (should (equal chosen-recipient "+15555550300")))) - -(ert-deftest test-signal-config-message-cold-cache-timeout-errors () - "Error: cold cache, fetch never resolves, picker user-errors before -the bounded wait would let Emacs hang on a dead daemon." - (let ((signel-account "+15555550100") - (cj/signel-fetch-timeout 0.1)) - (setq cj/signel--contact-cache nil) - (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil)) - ((symbol-function 'cj/signel--fetch-contacts) - (lambda (&rest _) nil))) - (should-error (cj/signel-message) :type 'user-error)))) - -;;; cj/signel-message-self - -(ert-deftest test-signal-config-message-self-calls-signel-chat-with-account () - "Normal: the direct self-message command opens a chat buffer addressed -to `signel-account', skipping the picker entirely." - (let ((chosen-recipient nil) - (signel-account "+15555550100")) - (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil)) - ((symbol-function 'signel-chat) - (lambda (r) (setq chosen-recipient r)))) - (cj/signel-message-self)) - (should (equal chosen-recipient "+15555550100")))) - -;;; cj/signel-prefix-map (C-; M) - -(ert-deftest test-signal-config-prefix-map-has-expected-bindings () - "Normal: the signel C-; M prefix map binds m / s / d / q / SPC to the -commands the workflow spec names." - (should (eq (keymap-lookup cj/signel-prefix-map "m") - #'cj/signel-message)) - (should (eq (keymap-lookup cj/signel-prefix-map "s") - #'cj/signel-message-self)) - (should (eq (keymap-lookup cj/signel-prefix-map "d") - #'signel-dashboard)) - (should (eq (keymap-lookup cj/signel-prefix-map "q") - #'signel-stop)) - (should (eq (keymap-lookup cj/signel-prefix-map "SPC") - #'cj/signel-connect))) - -(ert-deftest test-signal-config-prefix-map-registered-under-c-semi-m () - "Normal: loading signal-config registers `cj/signel-prefix-map' under -`M' in `cj/custom-keymap', so C-; M reaches the signel prefix. Guards -the wiring contract that the load-order bug broke: signal-config must -register through `cj/register-prefix-map', not a boundp-guarded direct -mutation that silently no-ops when keybindings loaded in a different -order." - (require 'keybindings) - (should (eq (keymap-lookup cj/custom-keymap "M") cj/signel-prefix-map))) - -;;; display-buffer-alist entry for *Signel: ...* chat buffers - -(ert-deftest test-signal-config-chat-buffer-display-rule-uses-bottom-30 () - "Normal: signal-config registers a `display-buffer-alist' entry that -matches `*Signel: <id>*' buffers, routes them through -`display-buffer-at-bottom', and sets `window-height' to 0.3 so the -chat docks to the bottom 30% of the frame." - (let ((entry (seq-find (lambda (e) (equal (car e) "\\`\\*Signel: ")) - display-buffer-alist))) - (should entry) - (should (memq 'display-buffer-at-bottom (cadr entry))) - (should (equal 0.3 (cdr (assq 'window-height (cddr entry))))))) - -(ert-deftest test-signal-config-chat-buffer-display-rule-matches-buffer-name () - "Boundary: the registered regex matches a realistic chat buffer name -\(phone-number id and group-id) and does not match unrelated buffers." - (let* ((entry (seq-find (lambda (e) (equal (car e) "\\`\\*Signel: ")) - display-buffer-alist)) - (regex (car entry))) - (should regex) - (should (string-match-p regex "*Signel: +15555550100*")) - (should (string-match-p regex "*Signel: groupid-abc*")) - (should-not (string-match-p regex "*signel-log*")) - (should-not (string-match-p regex "scratch")))) - -(provide 'test-signal-config) -;;; test-signal-config.el ends here diff --git a/tests/test-signel-cancel-input.el b/tests/test-signel-cancel-input.el deleted file mode 100644 index b2a7ef89..00000000 --- a/tests/test-signel-cancel-input.el +++ /dev/null @@ -1,74 +0,0 @@ -;;; test-signel-cancel-input.el --- Cancel-input contract for the signel fork -*- lexical-binding: t; -*- - -;;; Commentary: -;; `signel--cancel-input' is the C-c C-k handler in `signel-chat-mode'. -;; Its contract: clear any in-progress input between `signel--input-marker' -;; and `point-max' (so the prompt is fresh on next visit), then dismiss -;; the window via `quit-window' (the buffer stays alive so chat history -;; survives revisits). These tests lock the contract; the binding test -;; locks the keymap entry. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(eval-and-compile - (add-to-list 'load-path (expand-file-name "~/code/signel"))) -(require 'signel) - -(defmacro test-signel-cancel--with-chat-buffer (&rest body) - "Set up a temp signel-chat-mode buffer with prompt drawn and run BODY." - (declare (indent 0)) - `(with-temp-buffer - (signel-chat-mode) - (setq signel--chat-id "+15555550100") - (signel--draw-prompt) - ,@body)) - -(ert-deftest test-signel-cancel-input-clears-pending-text () - "Normal: pending input from input-marker to point-max is cleared." - (test-signel-cancel--with-chat-buffer - (insert "abandoned-draft") - (cl-letf (((symbol-function 'quit-window) (lambda (&rest _) nil))) - (signel--cancel-input)) - (should-not (signel--pending-input)))) - -(ert-deftest test-signel-cancel-input-empty-input-area-is-a-noop () - "Boundary: cancelling with no in-progress input is harmless." - (test-signel-cancel--with-chat-buffer - (cl-letf (((symbol-function 'quit-window) (lambda (&rest _) nil))) - (signel--cancel-input)) - (should-not (signel--pending-input)))) - -(ert-deftest test-signel-cancel-input-calls-quit-window () - "Normal: cancel dismisses the window via `quit-window'." - (test-signel-cancel--with-chat-buffer - (insert "abandoned-draft") - (let ((called nil)) - (cl-letf (((symbol-function 'quit-window) - (lambda (&rest _) (setq called t)))) - (signel--cancel-input)) - (should called)))) - -(ert-deftest test-signel-cancel-input-preserves-buffer () - "Normal: cancel does not kill the buffer; chat history (prompt + prior -content above the input marker) survives so reopening the contact lands -in the same buffer." - (test-signel-cancel--with-chat-buffer - (insert "abandoned-draft") - (let ((buf (current-buffer))) - (cl-letf (((symbol-function 'quit-window) (lambda (&rest _) nil))) - (signel--cancel-input)) - (should (buffer-live-p buf))))) - -(ert-deftest test-signel-chat-mode-binds-c-c-c-k-to-cancel () - "Normal: `signel-chat-mode' binds C-c C-k to `signel--cancel-input' so -the documented cancel gesture reaches the handler." - (with-temp-buffer - (signel-chat-mode) - (should (eq (lookup-key (current-local-map) (kbd "C-c C-k")) - #'signel--cancel-input)))) - -(provide 'test-signel-cancel-input) -;;; test-signel-cancel-input.el ends here diff --git a/tests/test-signel-input-preservation.el b/tests/test-signel-input-preservation.el deleted file mode 100644 index e8ce4ddb..00000000 --- a/tests/test-signel-input-preservation.el +++ /dev/null @@ -1,68 +0,0 @@ -;;; test-signel-input-preservation.el --- Regression for signel #2 input clobber -*- lexical-binding: t; -*- - -;;; Commentary: -;; signel-chat-mode buffers have an editable prompt area starting at -;; `signel--input-marker'. Before this fix, both `signel--insert-msg' (the -;; receive path) and `signel--insert-system-msg' (the RPC-error path) -;; called `(delete-region (point) (point-max))' to clear the old prompt -;; before redrawing it, which destroyed any text the user was mid-typing. -;; -;; These tests lock the preservation contract: a small `signel--pending-input' -;; helper captures the in-progress input from the marker to `point-max', and -;; both inserters restore it after the freshly drawn prompt. The chat-mode -;; buffer is constructed in a temp buffer; `signel--insert-msg' is steered -;; to it via a stub on `signel--get-buffer'. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(eval-and-compile - (add-to-list 'load-path (expand-file-name "~/code/signel"))) -(require 'signel) - -(defmacro test-signel--with-chat-buffer (&rest body) - "Set up a temp signel-chat-mode buffer with prompt drawn and run BODY." - (declare (indent 0)) - `(with-temp-buffer - (signel-chat-mode) - (setq signel--chat-id "+15555550100") - (signel--draw-prompt) - ,@body)) - -(ert-deftest test-signel-input-pending-returns-typed-text () - "Normal: with text after the prompt marker, `signel--pending-input' -returns the captured text." - (test-signel--with-chat-buffer - (insert "halfwritten") - (should (equal (signel--pending-input) "halfwritten")))) - -(ert-deftest test-signel-input-pending-returns-nil-when-empty () - "Boundary: an empty input area returns nil so callers don't restore an -empty string after the prompt." - (test-signel--with-chat-buffer - (should-not (signel--pending-input)))) - -(ert-deftest test-signel-input-system-msg-preserves-pending-input () - "Regression for #2: `signel--insert-system-msg' redraws the prompt -without clobbering text the user was mid-typing." - (test-signel--with-chat-buffer - (insert "halfwritten") - (signel--insert-system-msg "An error happened" 'signel-error-face) - (should (string-match-p "An error happened" (buffer-string))) - (should (equal (signel--pending-input) "halfwritten")))) - -(ert-deftest test-signel-input-msg-preserves-pending-input () - "Regression for #2: `signel--insert-msg' (the receive path) redraws the -prompt without clobbering the user's in-progress input." - (test-signel--with-chat-buffer - (insert "halfwritten") - (cl-letf (((symbol-function 'signel--get-buffer) - (lambda (_) (current-buffer)))) - (signel--insert-msg "+15555550100" "Alice" "Hi there" nil nil nil)) - (should (string-match-p "Hi there" (buffer-string))) - (should (equal (signel--pending-input) "halfwritten")))) - -(provide 'test-signel-input-preservation) -;;; test-signel-input-preservation.el ends here diff --git a/tests/test-signel-notify-function.el b/tests/test-signel-notify-function.el deleted file mode 100644 index e3d97af5..00000000 --- a/tests/test-signel-notify-function.el +++ /dev/null @@ -1,89 +0,0 @@ -;;; test-signel-notify-function.el --- Tests for signel's notify-function dispatch -*- lexical-binding: t -*- - -;;; Commentary: -;; signel's receive handler (signel.el in the fork at ~/code/signel) -;; raised notifications through a hardwired `notifications-notify' -;; call. The notification slice (docs/specs/signal-client-spec-doing.org, -;; "Notification slice" addendum) replaces that with -;; `signel-notify-function', a customization point called with -;; CHAT-ID, SENDER, and BODY so a config layer can add suppression or -;; route through an external notifier. These tests cover the -;; dispatch: text, sticker, and attachment bodies reach the function -;; with the right arguments, and the default preserves the plain -;; `notifications-notify' behavior. -;; -;; `signel--handle-receive' is exercised directly with synthetic -;; envelope alists; buffer/dashboard side effects are stubbed. No -;; live process needed. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(eval-and-compile - (add-to-list 'load-path (expand-file-name "~/code/signel"))) -(require 'signel) - -(defun test-signel-notify--receive (envelope) - "Run `signel--handle-receive' on ENVELOPE, capturing notify calls. -Returns the list of (CHAT-ID SENDER BODY) argument lists the handler -passed to `signel-notify-function', oldest first. Buffer and -dashboard side effects are stubbed out." - (let (calls) - (cl-letf (((symbol-function 'signel--insert-msg) (lambda (&rest _) nil)) - ((symbol-function 'signel--dashboard-refresh) (lambda () nil)) - ((symbol-function 'signel--get-buffer) - (lambda (_) (current-buffer)))) - (let ((signel-notify-function - (lambda (chat-id sender body) - (push (list chat-id sender body) calls))) - (signel-auto-open-buffer nil)) - (signel--handle-receive `((envelope . ,envelope))))) - (nreverse calls))) - -(ert-deftest test-signel-notify-function-text-message () - "Normal: a text dataMessage calls the function with chat-id, sender, text." - (should (equal (test-signel-notify--receive - '((sourceNumber . "+15551234567") - (sourceName . "Alice") - (dataMessage . ((message . "hi there"))))) - '(("+15551234567" "Alice" "hi there"))))) - -(ert-deftest test-signel-notify-function-sticker-placeholder () - "Boundary: a sticker with no text gets the [Sticker] placeholder body." - (should (equal (test-signel-notify--receive - '((sourceNumber . "+15551234567") - (sourceName . "Alice") - (dataMessage . ((sticker . ((packId . "p1"))))))) - '(("+15551234567" "Alice" "[Sticker]"))))) - -(ert-deftest test-signel-notify-function-attachment-placeholder () - "Boundary: an attachment with no text gets the [Attachment] placeholder." - (should (equal (test-signel-notify--receive - '((sourceNumber . "+15551234567") - (sourceName . "Alice") - (dataMessage . ((attachments . [((id . "a1"))]))))) - '(("+15551234567" "Alice" "[Attachment]"))))) - -(ert-deftest test-signel-notify-function-no-data-no-call () - "Boundary: an envelope with no dataMessage never calls the function." - (should-not (test-signel-notify--receive - '((sourceNumber . "+15551234567") - (sourceName . "Alice") - (typingMessage . ((action . "STARTED"))))))) - -(ert-deftest test-signel-notify-function-default-preserves-behavior () - "Normal: the default value raises a plain notifications-notify toast." - (should (eq signel-notify-function #'signel--notify-default)) - (let (calls) - (cl-letf (((symbol-function 'notifications-notify) - (lambda (&rest args) (push args calls) nil))) - (signel--notify-default "+15551234567" "Alice" "hi")) - (should (= (length calls) 1)) - (let ((args (car calls))) - (should (equal (plist-get args :title) "Signel: Alice")) - (should (equal (plist-get args :body) "hi"))))) - -(provide 'test-signel-notify-function) -;;; test-signel-notify-function.el ends here diff --git a/tests/test-signel-rpc-dispatch.el b/tests/test-signel-rpc-dispatch.el deleted file mode 100644 index 5ae023d6..00000000 --- a/tests/test-signel-rpc-dispatch.el +++ /dev/null @@ -1,94 +0,0 @@ -;;; test-signel-rpc-dispatch.el --- Tests for signel JSON-RPC success-result dispatch -*- lexical-binding: t; -*- - -;;; Commentary: -;; signel's JSON-RPC dispatch (signel.el in the fork at ~/code/signel) routes -;; incoming `receive' notifications and errors, but successful -;; `((id . N) (result . VALUE))' responses had no path until this work added a -;; request-callback table. These tests cover the new behavior: a registered -;; callback fires with the result and is then removed; an error response -;; also removes the handler so a retry starts clean; an unregistered id is a -;; silent no-op; passing SUCCESS-CALLBACK to `signel--send-rpc' registers it -;; under the returned id. -;; -;; The dispatch tests exercise `signel--dispatch' directly with synthetic JSON -;; alists; no live process is needed. The send-rpc test stubs `get-process' -;; and `process-send-string' so it doesn't require a running signal-cli. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(eval-and-compile - (add-to-list 'load-path (expand-file-name "~/code/signel"))) -(require 'signel) - -(defun test-signel-rpc--reset () - "Reset signel dispatch state to a clean baseline before each test." - (clrhash signel--request-handler-map) - (clrhash signel--request-buffer-map) - (setq signel--rpc-id-counter 0)) - -(ert-deftest test-signel-rpc-dispatch-result-invokes-callback () - "Normal: a result response with a registered id fires the callback with the -result value and removes the handler." - (test-signel-rpc--reset) - (let ((captured nil)) - (puthash 7 (lambda (val) (setq captured val)) signel--request-handler-map) - (signel--dispatch '((jsonrpc . "2.0") (id . 7) - (result . ((contacts . [1 2 3]))))) - (should (equal captured '((contacts . [1 2 3])))) - (should-not (gethash 7 signel--request-handler-map)))) - -(ert-deftest test-signel-rpc-dispatch-unknown-id-is-noop () - "Boundary: a result response with an unregistered id is a silent no-op: -neither receive nor error handler fires, and the handler map stays empty." - (test-signel-rpc--reset) - (let ((called nil)) - (cl-letf (((symbol-function 'signel--handle-error) - (lambda (&rest _) (setq called 'error))) - ((symbol-function 'signel--handle-receive) - (lambda (&rest _) (setq called 'receive)))) - (signel--dispatch '((jsonrpc . "2.0") (id . 99) (result . "anything")))) - (should-not called) - (should (zerop (hash-table-count signel--request-handler-map))))) - -(ert-deftest test-signel-rpc-dispatch-error-cleans-up-handler () - "Error: an error response with a registered id removes the handler without -firing the callback, leaving the map clean for a retry." - (test-signel-rpc--reset) - (let ((fired nil)) - (puthash 11 (lambda (&rest _) (setq fired t)) - signel--request-handler-map) - (cl-letf (((symbol-function 'signel--handle-error) (lambda (&rest _) nil))) - (signel--dispatch '((jsonrpc . "2.0") (id . 11) - (error . ((code . -1) (message . "boom")))))) - (should-not fired) - (should-not (gethash 11 signel--request-handler-map)))) - -(ert-deftest test-signel-rpc-send-rpc-registers-success-callback () - "Normal: passing a SUCCESS-CALLBACK to `signel--send-rpc' stores it under -the returned id so the matching response can route to it." - (test-signel-rpc--reset) - (let ((cb (lambda (_) 'ok)) - (sent nil)) - (cl-letf (((symbol-function 'get-process) (lambda (&rest _) 'fake-proc)) - ((symbol-function 'process-send-string) - (lambda (_ s) (setq sent s))) - ((symbol-function 'signel--log) (lambda (&rest _) nil))) - (let ((id (signel--send-rpc "listContacts" nil nil cb))) - (should (eq cb (gethash id signel--request-handler-map))) - (should (stringp sent)))))) - -(ert-deftest test-signel-rpc-stop-clears-handler-map () - "Normal (reconnect-invalidation): `signel-stop' clears the handler map so a -restart starts with no stale callbacks waiting for responses that will never -arrive." - (test-signel-rpc--reset) - (puthash 13 (lambda (&rest _) nil) signel--request-handler-map) - (cl-letf (((symbol-function 'get-process) (lambda (&rest _) nil))) - (signel-stop)) - (should (zerop (hash-table-count signel--request-handler-map)))) - -(provide 'test-signel-rpc-dispatch) -;;; test-signel-rpc-dispatch.el ends here diff --git a/tests/test-slack-config--notify.el b/tests/test-slack-config--notify.el new file mode 100644 index 00000000..d830ae29 --- /dev/null +++ b/tests/test-slack-config--notify.el @@ -0,0 +1,113 @@ +;;; test-slack-config--notify.el --- Slack notification hardening tests -*- lexical-binding: t; -*- + +;;; Commentary: +;; The config audit found `cj/slack-notify' missing signel's hardening: no +;; body truncation (giant toasts), no whitespace collapse, no sound gating, +;; and no `notifications-notify' fallback when the notify script is absent +;; (the raw `start-process' error was swallowed by the condition-case, so +;; the notification silently vanished). This mirrors signel's shape in +;; place; the shared cj/messenger-notify extraction belongs to the +;; messenger-unification task. +;; +;; The slack package's own predicates (`slack-im-p', `slack-message-minep', +;; `slack-message-mentioned-p') are package boundaries and are mocked; the +;; formatter and routing logic run real. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'slack-config) + +;;; ------------------------------ body formatter ----------------------------- + +(ert-deftest test-slack-config-format-notify-body-collapses-whitespace () + "Normal: whitespace runs (including newlines) become single spaces." + (should (equal (cj/slack--format-notify-body "a b\nc\t\td") + "a b c d"))) + +(ert-deftest test-slack-config-format-notify-body-truncates-long () + "Boundary: text over the max truncates to max length ending in an ellipsis." + (let ((long (make-string 500 ?x))) + (let ((formatted (cj/slack--format-notify-body long))) + (should (= (length formatted) cj/slack--notify-body-max)) + (should (string-suffix-p "…" formatted))))) + +(ert-deftest test-slack-config-format-notify-body-empty () + "Boundary: empty and whitespace-only input format to the empty string." + (should (equal (cj/slack--format-notify-body "") "")) + (should (equal (cj/slack--format-notify-body " \n\t ") ""))) + +;;; ----------------------------- delivery routing ---------------------------- + +(ert-deftest test-slack-config-send-notification-script-silent-by-default () + "Normal: with the notify script on PATH and sound off, --silent is passed." + (let ((argv nil) (cj/slack-notify-sound nil)) + (cl-letf (((symbol-function 'executable-find) + (lambda (prog &rest _) (when (equal prog "notify") "/bin/notify"))) + ((symbol-function 'start-process) + (lambda (_name _buf &rest args) (setq argv args)))) + (cj/slack--send-notification "Slack: general" "hello")) + (should (equal argv '("/bin/notify" "info" "Slack: general" "hello" "--silent"))))) + +(ert-deftest test-slack-config-send-notification-sound-enabled () + "Boundary: with sound enabled, --silent is not passed." + (let ((argv nil) (cj/slack-notify-sound t)) + (cl-letf (((symbol-function 'executable-find) + (lambda (prog &rest _) (when (equal prog "notify") "/bin/notify"))) + ((symbol-function 'start-process) + (lambda (_name _buf &rest args) (setq argv args)))) + (cj/slack--send-notification "Slack: general" "hello")) + (should (equal argv '("/bin/notify" "info" "Slack: general" "hello"))))) + +(ert-deftest test-slack-config-send-notification-fallback-without-script () + "Error: with no notify script, delivery falls back to notifications-notify." + (let ((fallback nil)) + (cl-letf (((symbol-function 'executable-find) (lambda (&rest _) nil)) + ((symbol-function 'notifications-notify) + (lambda (&rest args) (setq fallback args)))) + (cj/slack--send-notification "Slack: general" "hello")) + (should (equal (plist-get fallback :title) "Slack: general")) + (should (equal (plist-get fallback :body) "hello")))) + +;;; --------------------------- predicate wiring ------------------------------ + +(defmacro test-slack-notify--with-message (minep im-p mentioned-p &rest body) + "Run BODY with the slack package predicates mocked to the given values. +Also mocks room/body accessors and captures delivery into `sent'." + (declare (indent 3)) + `(let ((sent nil)) + (cl-letf (((symbol-function 'slack-message-minep) (lambda (&rest _) ,minep)) + ((symbol-function 'slack-im-p) (lambda (&rest _) ,im-p)) + ((symbol-function 'slack-message-mentioned-p) (lambda (&rest _) ,mentioned-p)) + ((symbol-function 'slack-room-display-name) (lambda (&rest _) "general")) + ((symbol-function 'slack-message-body) (lambda (&rest _) "the message")) + ((symbol-function 'cj/slack--send-notification) + (lambda (title body) (setq sent (list title body))))) + (cj/slack-notify 'msg 'room 'team) + ,@body))) + +(ert-deftest test-slack-config-notify-dm-notifies () + "Normal: a DM from someone else raises a notification." + (test-slack-notify--with-message nil t nil + (should (equal sent '("Slack: general" "the message"))))) + +(ert-deftest test-slack-config-notify-mention-notifies () + "Normal: an @mention in a channel raises a notification." + (test-slack-notify--with-message nil nil t + (should (equal sent '("Slack: general" "the message"))))) + +(ert-deftest test-slack-config-notify-own-message-silent () + "Boundary: your own message never notifies, even in a DM." + (test-slack-notify--with-message t t t + (should-not sent))) + +(ert-deftest test-slack-config-notify-plain-channel-silent () + "Boundary: a channel message with no mention stays silent." + (test-slack-notify--with-message nil nil nil + (should-not sent))) + +(provide 'test-slack-config--notify) +;;; test-slack-config--notify.el ends here diff --git a/tests/test-test-runner.el b/tests/test-test-runner.el index 0ff66f7f..6854b72e 100644 --- a/tests/test-test-runner.el +++ b/tests/test-test-runner.el @@ -152,6 +152,25 @@ FILES is an alist of relative test filenames to file contents." (should (eq (car result) 'not-in-testdir))) (test-testrunner-teardown)) +(ert-deftest test-testrunner-focus-add-file-shared-prefix-sibling-rejected () + "Boundary: a sibling directory sharing the test dir's name prefix is outside it. +`/tmp/x/tests-old/f.el' starts with `/tmp/x/tests' as a string, but it is not +in `/tmp/x/tests'. A raw `string-prefix-p' on the two truenames accepts it; +comparing against the directory with a trailing slash rejects it." + (test-testrunner-setup) + (let* ((testdir (file-truename test-testrunner--temp-dir)) + (sibling (concat (directory-file-name testdir) "-old")) + (filepath (expand-file-name "test-foo.el" sibling))) + (unwind-protect + (progn + (make-directory sibling t) + (with-temp-file filepath (insert ";; not in the test dir\n")) + (let ((result (cj/test--do-focus-add-file + filepath test-testrunner--temp-dir '()))) + (should (eq (car result) 'not-in-testdir)))) + (when (file-directory-p sibling) (delete-directory sibling t)))) + (test-testrunner-teardown)) + (ert-deftest test-testrunner-focus-add-file-already-focused () "Should detect already focused file." (test-testrunner-setup) diff --git a/tests/test-text-config.el b/tests/test-text-config.el index 96935e1b..82dfe05e 100644 --- a/tests/test-text-config.el +++ b/tests/test-text-config.el @@ -37,5 +37,18 @@ standard boundary check." (should (eq (cj/prettify-compose-block-markers-p start end "lambda") (prettify-symbols-default-compose-p start end "lambda")))))) +(ert-deftest test-text-config-edit-indirect-bound-on-reachable-key () + "Error/regression: edit-indirect-region is bound on M-I -- the event +Meta+Shift+i actually produces -- not the unreachable M-S-i, which no +keypress generates so it silently fell through to M-i tab-to-tab-stop." + (should (eq (key-binding (kbd "M-I")) #'edit-indirect-region)) + (should-not (eq (key-binding (kbd "M-S-i")) #'edit-indirect-region))) + +(ert-deftest test-text-config-accent-uses-completion-agnostic-backend () + "Regression: C-` invokes accent-menu, which reads through the minibuffer +and so survives a Company->Corfu migration, rather than accent-company, +whose company backend would break silently once Company is gone." + (should (eq (key-binding (kbd "C-`")) #'accent-menu))) + (provide 'test-text-config) ;;; test-text-config.el ends here diff --git a/tests/test-ui-theme-persistence.el b/tests/test-ui-theme-persistence.el index 02bb105a..250b606b 100644 --- a/tests/test-ui-theme-persistence.el +++ b/tests/test-ui-theme-persistence.el @@ -32,6 +32,19 @@ "modus-vivendi"))) (delete-file file)))) +(ert-deftest test-ui-theme-write-file-contents-creates-missing-parent-dir () + "Boundary: writing into a not-yet-existing directory creates it first. +On a fresh machine `persist/' does not exist, and `file-writable-p' returns nil +for a file inside a missing directory, so the write must create the parent." + (let* ((sandbox (make-temp-file "ui-theme-sandbox-" t)) + (file (expand-file-name "persist/emacs-theme" sandbox))) + (unwind-protect + (progn + (should-not (file-directory-p (file-name-directory file))) + (should (cj/theme-write-file-contents "modus-vivendi" file)) + (should (equal (cj/theme-read-file-contents file) "modus-vivendi"))) + (delete-directory sandbox t)))) + (ert-deftest test-ui-theme-write-file-contents-uses-write-region () "Theme persistence should write directly instead of visiting the file." (let ((file (make-temp-file "ui-theme-write-region-")) diff --git a/tests/test-undead-buffers-kill-other-window.el b/tests/test-undead-buffers-kill-other-window.el index e9371a0f..000ada9b 100644 --- a/tests/test-undead-buffers-kill-other-window.el +++ b/tests/test-undead-buffers-kill-other-window.el @@ -66,8 +66,9 @@ ;;; Boundary Cases -(ert-deftest test-kill-other-window-single-window-should-only-kill-buffer () - "With single window, should only kill the current buffer." +(ert-deftest test-kill-other-window-single-window-signals-no-other-window () + "Error: with a single window, signal `user-error' and kill nothing. +There is no other window, so acting would kill the buffer being viewed." (test-kill-other-window-setup) (unwind-protect (let ((buf (generate-new-buffer "*test-single-other*"))) @@ -75,9 +76,9 @@ (progn (switch-to-buffer buf) (should (one-window-p)) - (cj/kill-other-window) + (should-error (cj/kill-other-window) :type 'user-error) (should (one-window-p)) - (should-not (buffer-live-p buf))) + (should (buffer-live-p buf))) (when (buffer-live-p buf) (kill-buffer buf)))) (test-kill-other-window-teardown))) diff --git a/tests/test-video-audio-recording-process-sentinel.el b/tests/test-video-audio-recording-process-sentinel.el index 92fb3f0d..d733e46f 100644 --- a/tests/test-video-audio-recording-process-sentinel.el +++ b/tests/test-video-audio-recording-process-sentinel.el @@ -190,5 +190,157 @@ (should (null cj/audio-recording-ffmpeg-process)))) (test-sentinel-teardown))) +;;; Failed-Start Stub Deletion +;; +;; On Wayland a wf-recorder that fails to grab the compositor capture +;; still writes a ~500KB, ~0.5s stub .mkv before dying. The sentinel +;; detects the failed start (exit sooner than +;; `cj/recording-start-fail-threshold' without a user stop); these tests +;; pin that it also deletes the stub file stamped on the process as the +;; `cj-output-file' property — and that normal stops and user stops +;; never delete anything. + +(defun test-sentinel--make-exited-process () + "Return a real process that has already exited. +Drives the sentinel with a genuinely dead process so `process-status' +and the process plist behave for real instead of through mocks." + (let ((proc (make-process :name "test-sentinel-exited" + :command '("true") + :sentinel #'ignore))) + (while (process-live-p proc) + (accept-process-output proc 0.05)) + proc)) + +(defun test-sentinel--make-stub-file () + "Create and return a temp file standing in for the stub .mkv." + (make-temp-file "test-sentinel-stub-" nil ".mkv" "stub-content")) + +(ert-deftest test-video-audio-recording-process-sentinel-normal-failed-start-deletes-stub () + "Normal: a failed video start deletes the stub output file." + (test-sentinel-setup) + (unwind-protect + (let ((proc (test-sentinel--make-exited-process)) + (stub (test-sentinel--make-stub-file))) + (unwind-protect + (progn + (setq cj/video-recording-ffmpeg-process proc) + ;; Exited immediately after its start time — a failed start. + (process-put proc 'cj-start-time (float-time)) + (process-put proc 'cj-output-file stub) + (cj/recording-process-sentinel proc "exited abnormally\n") + (should-not (file-exists-p stub))) + (when (file-exists-p stub) (delete-file stub)))) + (test-sentinel-teardown))) + +(ert-deftest test-video-audio-recording-process-sentinel-normal-failed-start-still-messages () + "Normal: the failed-start branch still reports the failure to the user." + (test-sentinel-setup) + (unwind-protect + (let ((proc (test-sentinel--make-exited-process)) + (stub (test-sentinel--make-stub-file)) + (failure-messaged nil)) + (unwind-protect + (progn + (setq cj/video-recording-ffmpeg-process proc) + (process-put proc 'cj-start-time (float-time)) + (process-put proc 'cj-output-file stub) + (cl-letf (((symbol-function 'message) + (lambda (fmt &rest args) + (let ((msg (apply #'format fmt args))) + (when (string-match-p "failed to start" msg) + (setq failure-messaged t)))))) + (cj/recording-process-sentinel proc "exited abnormally\n")) + (should failure-messaged)) + (when (file-exists-p stub) (delete-file stub)))) + (test-sentinel-teardown))) + +(ert-deftest test-video-audio-recording-process-sentinel-normal-long-run-keeps-file () + "Normal: a recording that ran past the threshold keeps its output file." + (test-sentinel-setup) + (unwind-protect + (let ((proc (test-sentinel--make-exited-process)) + (stub (test-sentinel--make-stub-file))) + (unwind-protect + (progn + (setq cj/video-recording-ffmpeg-process proc) + ;; Ran well past the fail threshold — a real recording. + (process-put proc 'cj-start-time + (- (float-time) + (* 10 cj/recording-start-fail-threshold))) + (process-put proc 'cj-output-file stub) + (cj/recording-process-sentinel proc "finished\n") + (should (file-exists-p stub))) + (when (file-exists-p stub) (delete-file stub)))) + (test-sentinel-teardown))) + +(ert-deftest test-video-audio-recording-process-sentinel-boundary-user-stop-keeps-file () + "Boundary: a quick user stop (cj-stopping) never deletes the file." + (test-sentinel-setup) + (unwind-protect + (let ((proc (test-sentinel--make-exited-process)) + (stub (test-sentinel--make-stub-file))) + (unwind-protect + (progn + (setq cj/video-recording-ffmpeg-process proc) + ;; Quick exit, but the user asked for it. + (process-put proc 'cj-start-time (float-time)) + (process-put proc 'cj-stopping t) + (process-put proc 'cj-output-file stub) + (cj/recording-process-sentinel proc "finished\n") + (should (file-exists-p stub))) + (when (file-exists-p stub) (delete-file stub)))) + (test-sentinel-teardown))) + +(ert-deftest test-video-audio-recording-process-sentinel-boundary-missing-stub-no-error () + "Boundary: failed start whose stub never hit disk signals no error." + (test-sentinel-setup) + (unwind-protect + (let ((proc (test-sentinel--make-exited-process))) + (setq cj/video-recording-ffmpeg-process proc) + (process-put proc 'cj-start-time (float-time)) + (process-put proc 'cj-output-file "/nonexistent/dir/never-written.mkv") + ;; Must not signal even though the file is absent. + (cj/recording-process-sentinel proc "exited abnormally\n") + (should (null cj/video-recording-ffmpeg-process))) + (test-sentinel-teardown))) + +(ert-deftest test-video-audio-recording-process-sentinel-error-nil-output-property-no-error () + "Error: failed start with no cj-output-file property signals no error. +Covers processes started before the property existed (a live daemon +mid-upgrade) — the sentinel degrades to the old message-only path." + (test-sentinel-setup) + (unwind-protect + (let ((proc (test-sentinel--make-exited-process))) + (setq cj/video-recording-ffmpeg-process proc) + (process-put proc 'cj-start-time (float-time)) + ;; No cj-output-file property at all. + (cj/recording-process-sentinel proc "exited abnormally\n") + (should (null cj/video-recording-ffmpeg-process))) + (test-sentinel-teardown))) + +(ert-deftest test-video-audio-recording-process-sentinel-normal-start-stamps-output-file () + "Normal: `cj/ffmpeg-record-video' stamps cj-output-file on the process." + (test-sentinel-setup) + (unwind-protect + (let ((cj/recording-mic-device "test-mic-device") + (cj/recording-system-device "test-monitor-device") + (cj/recording-mic-boost 2.0) + (cj/recording-system-volume 1.0)) + (cl-letf (((symbol-function 'cj/recording--wayland-p) (lambda () nil)) + ((symbol-function 'cj/recording--validate-system-audio) + (lambda () nil)) + ((symbol-function 'start-process-shell-command) + (lambda (_name _buffer _command) + (make-process :name "fake-video" :command '("sleep" "1000"))))) + (cj/ffmpeg-record-video "/tmp/video-recordings/") + (let ((output-file (process-get cj/video-recording-ffmpeg-process + 'cj-output-file))) + (should (stringp output-file)) + (should (string-suffix-p ".mkv" output-file)) + (should (string-prefix-p "/tmp/video-recordings/" output-file)))) + (when cj/video-recording-ffmpeg-process + (ignore-errors (delete-process cj/video-recording-ffmpeg-process)))) + (test-sentinel-teardown))) + (provide 'test-video-audio-recording-process-sentinel) ;;; test-video-audio-recording-process-sentinel.el ends here |
