aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-22 19:58:00 -0500
committerCraig Jennings <c@cjennings.net>2026-04-22 19:58:00 -0500
commita97266c0e89ef8560824789063512d2613849fc9 (patch)
treeba320ed3d2dfedebade1fa79ece01baaa4750bd3 /tests
parentecca6c5809aa2945d593baae10308c0dcfe6ec17 (diff)
downloaddotemacs-a97266c0e89ef8560824789063512d2613849fc9.tar.gz
dotemacs-a97266c0e89ef8560824789063512d2613849fc9.zip
feat(coverage): wire make coverage target + simplecov pipeline
Completes the coverage v1 pipeline by adding the Makefile target, the undercover driver script, the exclusion list, and the .gitignore entry. Uses simplecov JSON rather than LCOV as the collection format. The LCOV vs simplecov choice: Undercover's :merge-report t option only supports simplecov. Since the pipeline runs tests per-file (matching test-unit's isolation pattern) and accumulates coverage across runs, merge-report is required. LCOV is better-supported by external coverage viewers, but for a primarily interactive workflow the on-disk format is an internal detail. Other moves in this commit: - Renamed cj/--coverage-parse-lcov to cj/--coverage-parse-simplecov and rewrote its tests for the JSON schema. Same signature, same semantics (file to set of covered lines), different parser. - Renamed the backend protocol's :lcov-path key to :report-path, format-neutral and matching the renamed cj/--coverage-elisp-report-path function. - The coverage target deletes modules/*.elc before running so undercover can instrument the .el sources. Without this, byte-compiled versions shadow the instrumentation and only a handful of pre-loaded modules end up with coverage data. - Excluded tests/test-all-comp-errors.el from make coverage runs. That test byte-compiles every module, which fails under undercover's instrumentation. Excluded only from coverage. Normal make test still runs it. - Updated docs/design/coverage.org to reflect the simplecov pivot with a historical note on why we moved off LCOV. Verified end-to-end: make coverage produces .coverage/simplecov.json with 2717 of 4559 executable lines hit across 44 tracked modules.
Diffstat (limited to 'tests')
-rw-r--r--tests/run-coverage-file.el40
-rw-r--r--tests/test-coverage-core--backend-registry.el28
-rw-r--r--tests/test-coverage-core--parse-lcov.el149
-rw-r--r--tests/test-coverage-core--parse-simplecov.el153
-rw-r--r--tests/test-coverage-elisp--detect.el2
5 files changed, 208 insertions, 164 deletions
diff --git a/tests/run-coverage-file.el b/tests/run-coverage-file.el
new file mode 100644
index 00000000..eeae5de9
--- /dev/null
+++ b/tests/run-coverage-file.el
@@ -0,0 +1,40 @@
+;;; run-coverage-file.el --- Undercover setup for per-file coverage runs -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Loaded via `-l tests/run-coverage-file.el' by the Makefile's coverage
+;; target before each test file runs. Ensures undercover is active and
+;; configured to merge into the shared LCOV output so coverage data
+;; accumulates across all test-file invocations.
+;;
+;; Per-file isolation matches the project's `make test-unit' pattern:
+;; each test file runs in its own Emacs process, so tests that work
+;; under `make test' will also work under `make coverage'. See
+;; docs/design/coverage.org for the rationale.
+
+;;; Code:
+
+(require 'package)
+(setq package-user-dir (expand-file-name "elpa" user-emacs-directory))
+(package-initialize)
+
+(unless (require 'undercover nil t)
+ (message "")
+ (message "ERROR: undercover not installed.")
+ (message "Start Emacs interactively to install it via use-package,")
+ (message "or run: emacs --batch --eval \"(progn (package-refresh-contents) (package-install 'undercover))\"")
+ (message "")
+ (kill-emacs 1))
+
+;; Force coverage collection even when not in CI. Must happen AFTER
+;; `(require 'undercover)' because undercover.el's top-level
+;; `(setq undercover-force-coverage (getenv "UNDERCOVER_FORCE"))'
+;; would otherwise overwrite our value.
+(setq undercover-force-coverage t)
+
+(undercover "modules/*.el"
+ (:report-format 'simplecov)
+ (:report-file ".coverage/simplecov.json")
+ (:merge-report t)
+ (:send-report nil))
+
+;;; run-coverage-file.el ends here
diff --git a/tests/test-coverage-core--backend-registry.el b/tests/test-coverage-core--backend-registry.el
index ac1f6f7d..2369806f 100644
--- a/tests/test-coverage-core--backend-registry.el
+++ b/tests/test-coverage-core--backend-registry.el
@@ -3,7 +3,7 @@
;;; Commentary:
;; Unit tests for the backend registry.
;;
-;; A backend is a plist with at least :name, :detect, :run, and :lcov-path
+;; A backend is a plist with at least :name, :detect, :run, and :report-path
;; keys. `cj/coverage-register-backend' adds or replaces an entry.
;; `cj/--coverage-backend-for-project' resolves which backend applies to
;; a project root, honoring an optional override (buffer-local
@@ -28,7 +28,7 @@
"Normal: registering a backend makes it retrievable by name."
(test-coverage-registry-with-empty
(cj/coverage-register-backend
- '(:name elisp :detect (lambda (_) t) :run ignore :lcov-path ignore))
+ '(:name elisp :detect (lambda (_) t) :run ignore :report-path ignore))
(should (= 1 (length cj/coverage-backends)))
(should (eq 'elisp (plist-get (car cj/coverage-backends) :name)))))
@@ -36,11 +36,11 @@
"Normal: re-registering by name replaces the existing entry at the same position."
(test-coverage-registry-with-empty
(cj/coverage-register-backend
- '(:name elisp :detect (lambda (_) nil) :run ignore :lcov-path ignore))
+ '(:name elisp :detect (lambda (_) nil) :run ignore :report-path ignore))
(cj/coverage-register-backend
- '(:name python :detect (lambda (_) nil) :run ignore :lcov-path ignore))
+ '(:name python :detect (lambda (_) nil) :run ignore :report-path ignore))
(cj/coverage-register-backend
- '(:name elisp :detect (lambda (_) t) :run ignore :lcov-path ignore))
+ '(:name elisp :detect (lambda (_) t) :run ignore :report-path ignore))
(should (= 2 (length cj/coverage-backends)))
(should (eq 'elisp (plist-get (nth 0 cj/coverage-backends) :name)))
(should (eq 'python (plist-get (nth 1 cj/coverage-backends) :name)))
@@ -50,11 +50,11 @@
"Normal: resolution returns the first backend whose :detect matches."
(test-coverage-registry-with-empty
(cj/coverage-register-backend
- '(:name a :detect (lambda (_) nil) :run ignore :lcov-path ignore))
+ '(:name a :detect (lambda (_) nil) :run ignore :report-path ignore))
(cj/coverage-register-backend
- '(:name b :detect (lambda (_) t) :run ignore :lcov-path ignore))
+ '(:name b :detect (lambda (_) t) :run ignore :report-path ignore))
(cj/coverage-register-backend
- '(:name c :detect (lambda (_) t) :run ignore :lcov-path ignore))
+ '(:name c :detect (lambda (_) t) :run ignore :report-path ignore))
(let ((backend (cj/--coverage-backend-for-project "/tmp")))
(should (eq 'b (plist-get backend :name))))))
@@ -69,18 +69,18 @@
"Boundary: no backend's :detect matches returns nil."
(test-coverage-registry-with-empty
(cj/coverage-register-backend
- '(:name a :detect (lambda (_) nil) :run ignore :lcov-path ignore))
+ '(:name a :detect (lambda (_) nil) :run ignore :report-path ignore))
(cj/coverage-register-backend
- '(:name b :detect (lambda (_) nil) :run ignore :lcov-path ignore))
+ '(:name b :detect (lambda (_) nil) :run ignore :report-path ignore))
(should (null (cj/--coverage-backend-for-project "/tmp")))))
(ert-deftest test-coverage-backend-for-project-override-bypasses-detect ()
"Boundary: OVERRIDE returns the named backend without calling :detect."
(test-coverage-registry-with-empty
(cj/coverage-register-backend
- '(:name a :detect (lambda (_) nil) :run ignore :lcov-path ignore))
+ '(:name a :detect (lambda (_) nil) :run ignore :report-path ignore))
(cj/coverage-register-backend
- '(:name b :detect (lambda (_) nil) :run ignore :lcov-path ignore))
+ '(:name b :detect (lambda (_) nil) :run ignore :report-path ignore))
(let ((backend (cj/--coverage-backend-for-project "/tmp" 'b)))
(should (eq 'b (plist-get backend :name))))))
@@ -91,7 +91,7 @@
(cj/coverage-register-backend
`(:name a
:detect ,(lambda (root) (setq captured root) t)
- :run ignore :lcov-path ignore))
+ :run ignore :report-path ignore))
(cj/--coverage-backend-for-project "/my/root")
(should (equal "/my/root" captured)))))
@@ -101,7 +101,7 @@
"Error: OVERRIDE that names an unregistered backend signals user-error."
(test-coverage-registry-with-empty
(cj/coverage-register-backend
- '(:name a :detect (lambda (_) t) :run ignore :lcov-path ignore))
+ '(:name a :detect (lambda (_) t) :run ignore :report-path ignore))
(should-error (cj/--coverage-backend-for-project "/tmp" 'bogus)
:type 'user-error)))
diff --git a/tests/test-coverage-core--parse-lcov.el b/tests/test-coverage-core--parse-lcov.el
deleted file mode 100644
index a0a800ef..00000000
--- a/tests/test-coverage-core--parse-lcov.el
+++ /dev/null
@@ -1,149 +0,0 @@
-;;; test-coverage-core--parse-lcov.el --- Tests for cj/--coverage-parse-lcov -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; Unit tests for `cj/--coverage-parse-lcov', the pure helper that
-;; reads an LCOV file and returns a hash table of file → set of
-;; covered line numbers.
-;;
-;; LCOV format (the subset we care about):
-;; SF:<source file>
-;; DA:<line>,<hit count>
-;; end_of_record
-;;
-;; A line counts as "covered" when its hit count is greater than zero.
-
-;;; Code:
-
-(require 'ert)
-
-(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-(require 'coverage-core)
-
-(defun test-coverage-parse-lcov--write-temp-lcov (content)
- "Write CONTENT to a temp file and return its path.
-Caller is responsible for deleting the file."
- (let ((file (make-temp-file "test-lcov-" nil ".info")))
- (with-temp-file file
- (insert content))
- file))
-
-;;; Normal cases
-
-(ert-deftest test-coverage-parse-lcov-single-file-all-covered ()
- "Normal: one file with every line hit > 0 returns all lines in the set."
- (let* ((content "SF:/path/to/foo.el\nDA:10,1\nDA:11,2\nDA:12,3\nend_of_record\n")
- (file (test-coverage-parse-lcov--write-temp-lcov content)))
- (unwind-protect
- (let* ((result (cj/--coverage-parse-lcov file))
- (lines (gethash "/path/to/foo.el" result)))
- (should (hash-table-p result))
- (should (= 3 (hash-table-count lines)))
- (should (gethash 10 lines))
- (should (gethash 11 lines))
- (should (gethash 12 lines)))
- (delete-file file))))
-
-(ert-deftest test-coverage-parse-lcov-multiple-files ()
- "Normal: multiple file records in one LCOV file are both parsed."
- (let* ((content (concat "SF:/a.el\nDA:1,1\nDA:2,1\nend_of_record\n"
- "SF:/b.el\nDA:5,1\nend_of_record\n"))
- (file (test-coverage-parse-lcov--write-temp-lcov content)))
- (unwind-protect
- (let ((result (cj/--coverage-parse-lcov file)))
- (should (= 2 (hash-table-count result)))
- (should (gethash "/a.el" result))
- (should (gethash "/b.el" result)))
- (delete-file file))))
-
-(ert-deftest test-coverage-parse-lcov-mixed-hits ()
- "Normal: lines with hit count 0 are excluded; positive counts included."
- (let* ((content "SF:/foo.el\nDA:1,0\nDA:2,1\nDA:3,0\nDA:4,5\nend_of_record\n")
- (file (test-coverage-parse-lcov--write-temp-lcov content)))
- (unwind-protect
- (let* ((result (cj/--coverage-parse-lcov file))
- (lines (gethash "/foo.el" result)))
- (should (= 2 (hash-table-count lines)))
- (should-not (gethash 1 lines))
- (should (gethash 2 lines))
- (should-not (gethash 3 lines))
- (should (gethash 4 lines)))
- (delete-file file))))
-
-;;; Boundary cases
-
-(ert-deftest test-coverage-parse-lcov-empty-file ()
- "Boundary: empty LCOV file returns an empty hash table, not nil."
- (let ((file (test-coverage-parse-lcov--write-temp-lcov "")))
- (unwind-protect
- (let ((result (cj/--coverage-parse-lcov file)))
- (should (hash-table-p result))
- (should (= 0 (hash-table-count result))))
- (delete-file file))))
-
-(ert-deftest test-coverage-parse-lcov-file-with-spaces-in-path ()
- "Boundary: filename with spaces is parsed as one key."
- (let* ((content "SF:/my path/with spaces.el\nDA:1,1\nend_of_record\n")
- (file (test-coverage-parse-lcov--write-temp-lcov content)))
- (unwind-protect
- (let ((result (cj/--coverage-parse-lcov file)))
- (should (gethash "/my path/with spaces.el" result)))
- (delete-file file))))
-
-(ert-deftest test-coverage-parse-lcov-extra-lcov-fields-ignored ()
- "Boundary: LF/LH/BRDA/FN fields don't break parsing; only DA matters."
- (let* ((content (concat "SF:/foo.el\n"
- "FN:10,some-function\n"
- "FNDA:1,some-function\n"
- "DA:10,1\n"
- "DA:11,1\n"
- "LF:2\n"
- "LH:2\n"
- "BRDA:10,0,0,1\n"
- "end_of_record\n"))
- (file (test-coverage-parse-lcov--write-temp-lcov content)))
- (unwind-protect
- (let* ((result (cj/--coverage-parse-lcov file))
- (lines (gethash "/foo.el" result)))
- (should (= 2 (hash-table-count lines)))
- (should (gethash 10 lines))
- (should (gethash 11 lines)))
- (delete-file file))))
-
-(ert-deftest test-coverage-parse-lcov-all-zero-hits ()
- "Boundary: file with no covered lines returns an empty set for that file."
- (let* ((content "SF:/foo.el\nDA:1,0\nDA:2,0\nend_of_record\n")
- (file (test-coverage-parse-lcov--write-temp-lcov content)))
- (unwind-protect
- (let* ((result (cj/--coverage-parse-lcov file))
- (lines (gethash "/foo.el" result)))
- (should (hash-table-p lines))
- (should (= 0 (hash-table-count lines))))
- (delete-file file))))
-
-;;; Error cases
-
-(ert-deftest test-coverage-parse-lcov-missing-file-errors ()
- "Error: nonexistent file signals a user-error naming the path."
- (should-error (cj/--coverage-parse-lcov "/nonexistent/path/xyz.info")
- :type 'user-error))
-
-(ert-deftest test-coverage-parse-lcov-malformed-da-line-skipped ()
- "Error: malformed DA lines are skipped; parsing continues on the rest."
- (let* ((content (concat "SF:/foo.el\n"
- "DA:not-a-number,1\n"
- "DA:10,1\n"
- "DA:\n"
- "DA:11,notanumber\n"
- "DA:12,2\n"
- "end_of_record\n"))
- (file (test-coverage-parse-lcov--write-temp-lcov content)))
- (unwind-protect
- (let* ((result (cj/--coverage-parse-lcov file))
- (lines (gethash "/foo.el" result)))
- (should (= 2 (hash-table-count lines)))
- (should (gethash 10 lines))
- (should (gethash 12 lines)))
- (delete-file file))))
-
-(provide 'test-coverage-core--parse-lcov)
-;;; test-coverage-core--parse-lcov.el ends here
diff --git a/tests/test-coverage-core--parse-simplecov.el b/tests/test-coverage-core--parse-simplecov.el
new file mode 100644
index 00000000..09a3051e
--- /dev/null
+++ b/tests/test-coverage-core--parse-simplecov.el
@@ -0,0 +1,153 @@
+;;; test-coverage-core--parse-simplecov.el --- Tests for cj/--coverage-parse-simplecov -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for `cj/--coverage-parse-simplecov', the pure helper
+;; that reads a simplecov JSON report and returns a hash table of
+;; file → set of covered line numbers.
+;;
+;; Simplecov JSON structure:
+;; { <test-name>: { "coverage": { <path>: [null | 0 | int, ...] } } }
+;;
+;; Array index i (0-based) corresponds to line (i+1).
+;; nil — line is not executable (blank, comment)
+;; 0 — line is executable but not hit
+;; positive int — hit count
+;;
+;; When the JSON has multiple top-level test-name keys, coverage is
+;; unioned across them.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'coverage-core)
+
+(defun test-coverage-parse-simplecov--write-temp-json (content)
+ "Write CONTENT (a JSON string) to a temp file; return its path."
+ (let ((file (make-temp-file "test-simplecov-" nil ".json")))
+ (with-temp-file file
+ (insert content))
+ file))
+
+;;; Normal cases
+
+(ert-deftest test-coverage-parse-simplecov-single-file-all-hit ()
+ "Normal: one file with every executable line hit."
+ (let* ((content "{\"run\":{\"timestamp\":1,\"coverage\":{\"/foo.el\":[null,1,2,3]}}}")
+ (file (test-coverage-parse-simplecov--write-temp-json content)))
+ (unwind-protect
+ (let* ((result (cj/--coverage-parse-simplecov file))
+ (lines (gethash "/foo.el" result)))
+ (should (hash-table-p result))
+ (should (= 3 (hash-table-count lines)))
+ (should (gethash 2 lines))
+ (should (gethash 3 lines))
+ (should (gethash 4 lines))
+ (should-not (gethash 1 lines)))
+ (delete-file file))))
+
+(ert-deftest test-coverage-parse-simplecov-multiple-files ()
+ "Normal: multiple files under one test-name are both parsed."
+ (let* ((content "{\"run\":{\"timestamp\":1,\"coverage\":{\"/a.el\":[1,1],\"/b.el\":[null,5]}}}")
+ (file (test-coverage-parse-simplecov--write-temp-json content)))
+ (unwind-protect
+ (let ((result (cj/--coverage-parse-simplecov file)))
+ (should (= 2 (hash-table-count result)))
+ (should (gethash "/a.el" result))
+ (should (gethash "/b.el" result))
+ (should (= 2 (hash-table-count (gethash "/a.el" result))))
+ (should (= 1 (hash-table-count (gethash "/b.el" result)))))
+ (delete-file file))))
+
+(ert-deftest test-coverage-parse-simplecov-mixed-hits ()
+ "Normal: null (not executable) and 0 (not hit) are excluded."
+ (let* ((content "{\"run\":{\"timestamp\":1,\"coverage\":{\"/foo.el\":[null,1,0,5,null,0,2]}}}")
+ (file (test-coverage-parse-simplecov--write-temp-json content)))
+ (unwind-protect
+ (let* ((result (cj/--coverage-parse-simplecov file))
+ (lines (gethash "/foo.el" result)))
+ (should (= 3 (hash-table-count lines)))
+ (should-not (gethash 1 lines))
+ (should (gethash 2 lines))
+ (should-not (gethash 3 lines))
+ (should (gethash 4 lines))
+ (should-not (gethash 5 lines))
+ (should-not (gethash 6 lines))
+ (should (gethash 7 lines)))
+ (delete-file file))))
+
+;;; Boundary cases
+
+(ert-deftest test-coverage-parse-simplecov-empty-json ()
+ "Boundary: a JSON object with no test-name keys returns empty hash."
+ (let* ((content "{}")
+ (file (test-coverage-parse-simplecov--write-temp-json content)))
+ (unwind-protect
+ (let ((result (cj/--coverage-parse-simplecov file)))
+ (should (hash-table-p result))
+ (should (= 0 (hash-table-count result))))
+ (delete-file file))))
+
+(ert-deftest test-coverage-parse-simplecov-file-with-spaces-in-path ()
+ "Boundary: filename with spaces is parsed as one key."
+ (let* ((content "{\"run\":{\"timestamp\":1,\"coverage\":{\"/my path/spaces.el\":[1]}}}")
+ (file (test-coverage-parse-simplecov--write-temp-json content)))
+ (unwind-protect
+ (let ((result (cj/--coverage-parse-simplecov file)))
+ (should (gethash "/my path/spaces.el" result)))
+ (delete-file file))))
+
+(ert-deftest test-coverage-parse-simplecov-all-zero-hits ()
+ "Boundary: file with every executable line at 0 returns empty set."
+ (let* ((content "{\"run\":{\"timestamp\":1,\"coverage\":{\"/foo.el\":[0,0,null,0]}}}")
+ (file (test-coverage-parse-simplecov--write-temp-json content)))
+ (unwind-protect
+ (let* ((result (cj/--coverage-parse-simplecov file))
+ (lines (gethash "/foo.el" result)))
+ (should (hash-table-p lines))
+ (should (= 0 (hash-table-count lines))))
+ (delete-file file))))
+
+(ert-deftest test-coverage-parse-simplecov-all-null-entries ()
+ "Boundary: all-null coverage array (no executable lines) returns empty set."
+ (let* ((content "{\"run\":{\"timestamp\":1,\"coverage\":{\"/foo.el\":[null,null,null]}}}")
+ (file (test-coverage-parse-simplecov--write-temp-json content)))
+ (unwind-protect
+ (let* ((result (cj/--coverage-parse-simplecov file))
+ (lines (gethash "/foo.el" result)))
+ (should (hash-table-p lines))
+ (should (= 0 (hash-table-count lines))))
+ (delete-file file))))
+
+(ert-deftest test-coverage-parse-simplecov-multiple-test-names-unioned ()
+ "Boundary: multiple top-level test-name keys are unioned for the same file."
+ (let* ((content "{\"run1\":{\"timestamp\":1,\"coverage\":{\"/foo.el\":[1,1,0,0]}},\"run2\":{\"timestamp\":2,\"coverage\":{\"/foo.el\":[0,0,1,1]}}}")
+ (file (test-coverage-parse-simplecov--write-temp-json content)))
+ (unwind-protect
+ (let* ((result (cj/--coverage-parse-simplecov file))
+ (lines (gethash "/foo.el" result)))
+ (should (= 4 (hash-table-count lines)))
+ (should (gethash 1 lines))
+ (should (gethash 2 lines))
+ (should (gethash 3 lines))
+ (should (gethash 4 lines)))
+ (delete-file file))))
+
+;;; Error cases
+
+(ert-deftest test-coverage-parse-simplecov-missing-file-errors ()
+ "Error: nonexistent file signals user-error."
+ (should-error (cj/--coverage-parse-simplecov "/nonexistent/path/xyz.json")
+ :type 'user-error))
+
+(ert-deftest test-coverage-parse-simplecov-malformed-json-errors ()
+ "Error: malformed JSON input signals user-error naming the file."
+ (let ((file (test-coverage-parse-simplecov--write-temp-json "{not valid json")))
+ (unwind-protect
+ (should-error (cj/--coverage-parse-simplecov file)
+ :type 'user-error)
+ (delete-file file))))
+
+(provide 'test-coverage-core--parse-simplecov)
+;;; test-coverage-core--parse-simplecov.el ends here
diff --git a/tests/test-coverage-elisp--detect.el b/tests/test-coverage-elisp--detect.el
index 1301495a..0a83449b 100644
--- a/tests/test-coverage-elisp--detect.el
+++ b/tests/test-coverage-elisp--detect.el
@@ -93,7 +93,7 @@ directories; others are files. Returns the temp directory path."
(should (eq 'elisp (plist-get backend :name)))
(should (functionp (plist-get backend :detect)))
(should (functionp (plist-get backend :run)))
- (should (functionp (plist-get backend :lcov-path)))))
+ (should (functionp (plist-get backend :report-path)))))
(provide 'test-coverage-elisp--detect)
;;; test-coverage-elisp--detect.el ends here