diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Makefile | 61 | ||||
| -rw-r--r-- | docs/design/coverage.org | 38 | ||||
| -rw-r--r-- | modules/coverage-core.el | 86 | ||||
| -rw-r--r-- | modules/coverage-elisp.el | 24 | ||||
| -rw-r--r-- | tests/run-coverage-file.el | 40 | ||||
| -rw-r--r-- | tests/test-coverage-core--backend-registry.el | 28 | ||||
| -rw-r--r-- | tests/test-coverage-core--parse-lcov.el | 149 | ||||
| -rw-r--r-- | tests/test-coverage-core--parse-simplecov.el | 153 | ||||
| -rw-r--r-- | tests/test-coverage-elisp--detect.el | 2 |
10 files changed, 348 insertions, 234 deletions
@@ -2,6 +2,7 @@ *.cache .cache/ /eln-cache/ +/.coverage/ flycheck_* projectile-bookmarks.eld /recentf @@ -6,6 +6,8 @@ # make test-unit - Run unit tests only # make test-file FILE=test-foo.el - Run specific test file # make test-name TEST=test-foo-* - Run tests matching pattern +# make coverage - Generate simplecov coverage report +# make coverage-clean - Remove coverage report file # make validate-parens - Check for unbalanced parentheses # make validate-modules - Load all modules to verify they compile # make compile - Byte-compile all modules @@ -39,6 +41,7 @@ EMACS_TEST = $(EMACS_BATCH) -L $(TEST_DIR) -L $(MODULE_DIR) # No colors - using plain text symbols instead .PHONY: help targets test test-all test-unit test-integration test-file test-name \ + coverage coverage-clean \ validate-parens validate-modules compile lint profile \ clean clean-compiled clean-tests reset @@ -58,6 +61,10 @@ help: @echo " make test-file FILE=<filename> - Run specific test file" @echo " make test-name TEST=<pattern> - Run tests matching pattern" @echo "" + @echo " Coverage:" + @echo " make coverage - Generate simplecov JSON at $(COVERAGE_FILE)" + @echo " make coverage-clean - Delete the coverage report file" + @echo "" @echo " Validation:" @echo " make validate-parens - Check for unbalanced parentheses in modules" @echo " make validate-modules - Load all modules to verify they compile" @@ -214,6 +221,60 @@ endif @echo "✓ Tests matching '$(TEST)' complete" # ============================================================================ +# Coverage Targets +# ============================================================================ + +COVERAGE_DIR = .coverage +COVERAGE_FILE = $(COVERAGE_DIR)/simplecov.json + +# Test files that can't coexist with undercover's instrumentation +# (e.g. test-all-comp-errors byte-compiles modules, which fails on +# instrumented sources). Excluded from `make coverage' only. +COVERAGE_EXCLUDE = $(TEST_DIR)/test-all-comp-errors.el +COVERAGE_TESTS = $(filter-out $(COVERAGE_EXCLUDE),$(UNIT_TESTS)) + +coverage: coverage-clean $(COVERAGE_DIR) + @echo "[i] Deleting modules/*.elc so undercover can instrument sources..." + @rm -f $(MODULE_DIR)/*.elc + @echo "[i] Running coverage across $(words $(COVERAGE_TESTS)) test files..." + @echo " (this is slower than 'make test' — each file runs in its own Emacs)" + @echo " excluded from coverage: $(notdir $(COVERAGE_EXCLUDE))" + @echo "" + @failed=0; \ + failed_files=""; \ + for test in $(COVERAGE_TESTS); do \ + test_name=$$(basename $$test); \ + printf " Coverage: %-58s " "$$test_name..."; \ + output=$$($(EMACS_TEST) -l $(TEST_DIR)/run-coverage-file.el -l $$test --eval "(ert-run-tests-batch-and-exit '(not (tag :slow)))" 2>&1); \ + result=$$?; \ + if [ $$result -eq 0 ]; then \ + echo "✓"; \ + else \ + echo "✗"; \ + failed=$$((failed + 1)); \ + failed_files="$$failed_files $$test_name"; \ + fi; \ + done; \ + echo ""; \ + if [ $$failed -gt 0 ]; then \ + echo "[!] $$failed test file(s) failed during coverage run:"; \ + echo "$$failed_files" | tr ' ' '\n' | grep -v '^$$' | sed 's/^/ /'; \ + exit 1; \ + fi + @if [ -f $(COVERAGE_FILE) ]; then \ + echo "✓ Coverage report: $(COVERAGE_FILE) ($$(du -h $(COVERAGE_FILE) | cut -f1))"; \ + else \ + echo "[!] No coverage file produced; check that undercover is installed"; \ + exit 1; \ + fi + +coverage-clean: + @rm -f $(COVERAGE_FILE) + +$(COVERAGE_DIR): + @mkdir -p $(COVERAGE_DIR) + +# ============================================================================ # Validation Targets # ============================================================================ diff --git a/docs/design/coverage.org b/docs/design/coverage.org index a913a2bc..1a9452bf 100644 --- a/docs/design/coverage.org +++ b/docs/design/coverage.org @@ -18,16 +18,18 @@ The tooling should be pluggable so the same workflow covers Elisp today and Pyth - Continuous in-buffer overlays (fringe marks, line highlights). Parked over performance concerns. - Mutation testing or any signal other than line coverage. -- CI integration beyond emitting an LCOV artifact. No coveralls, no GitHub Actions wiring. +- CI integration beyond emitting a simplecov JSON artifact. No coveralls, no GitHub Actions wiring. - Shadowing or replacing existing test-running commands (=make test=, =make test-file=, etc.). * Approaches Considered ** Recommended: diff-aware report with pluggable backends -Core engine reads an LCOV file, shells to ~git diff~ at a selectable scope, intersects, and displays the result in a compilation-mode-derived buffer. Language-specific "backends" each produce LCOV in their own way and register themselves with the core. +Core engine reads a simplecov JSON file, shells to ~git diff~ at a selectable scope, intersects, and displays the result in a compilation-mode-derived buffer. Language-specific "backends" each produce simplecov in their own way and register themselves with the core. -*Pros:* Directly serves the primary use case. LCOV is a universal format, so new languages plug in without touching the core. Compilation-mode inheritance gives free =next-error= / =previous-error= navigation. +*Pros:* Directly serves the primary use case. Simplecov is broadly supported across language coverage tools, and Undercover's ~:merge-report t~ option works for simplecov (but not for LCOV), which is essential for the per-file coverage-run strategy. Compilation-mode inheritance gives free =next-error= / =previous-error= navigation. + +*Note on format:* An earlier draft of this design used LCOV. That was changed to simplecov after discovering that Undercover's LCOV writer does not implement report-merging — per-file coverage runs would require custom merge logic or an external ~lcov~ tool. Simplecov's native merge-report support made it the cleaner fit without changing anything about the pluggable backend story. *Cons:* More code than a "just run coverage and read the output" approach. Backend registry adds one layer of indirection (small — ~30 lines). @@ -60,17 +62,17 @@ Three files: Each backend is a plist registered into =cj/coverage-backends=: #+begin_src emacs-lisp -(:name 'elisp - :detect (lambda () ...) ; non-nil if current project matches - :run (lambda (cb) ...) ; kick off coverage build; invoke CB with LCOV path - :lcov-path (lambda () ...)) ; where the LCOV lives (for re-reading without running) +(:name 'elisp + :detect (lambda () ...) ; non-nil if current project matches + :run (lambda (cb) ...) ; kick off coverage build; invoke CB with report path + :report-path (lambda () ...)) ; where the simplecov JSON lives (for re-reading without running) #+end_src Detection precedence: =.dir-locals.el= override (=cj/coverage-backend= set to a backend name), then project-root fingerprints (=go.mod=, =pyproject.toml=, =package.json=, =.el= files + Makefile, etc.). First =:detect= that matches wins. No silent fallback — if nothing matches, the command errors with guidance. *** Pure helpers -- =cj/--coverage-parse-lcov FILE= → hash-table ={file → covered-line-set}=. +- =cj/--coverage-parse-simplecov FILE= → hash-table ={file → covered-line-set}=. - =cj/--coverage-changed-lines SCOPE BASE= → hash-table ={file → changed-line-set}= by shelling a =git diff --unified=0= for the selected scope and parsing hunk headers. - =cj/--coverage-intersect COVERED CHANGED= → per-file records with three buckets: covered, uncovered, not-tracked. @@ -85,13 +87,13 @@ All three are pure, fully ERT-tested. - "Staged — about to commit" - "Branch vs parent" (uses =cj/coverage-base-branch= → =@{upstream}= → =main= in order) - "Branch vs main" (explicit) -4. Freshness check: if =lcov.info= is missing, or older than the newest changed file, prompt "Run coverage now?" Yes runs the backend's =:run= asynchronously via =compile=; no reads the stale file anyway. -5. Parse LCOV, compute changed lines, intersect. +4. Freshness check: if =simplecov.json= is missing, or older than the newest changed file, prompt "Run coverage now?" Yes runs the backend's =:run= asynchronously via =compile=; no reads the stale file anyway. +5. Parse simplecov, compute changed lines, intersect. 6. Display a report buffer in a mode derived from =compilation-mode=. ** Persistence -- =.coverage/lcov.info= at the project root, gitignored. Overwritten on each run. +- =.coverage/simplecov.json= at the project root, gitignored. Overwritten on each run. - No long-term storage. Historical tracking is explicitly out of scope for v1. ** Error Handling @@ -104,12 +106,12 @@ All three are pure, fully ERT-tested. *During the coverage run:* - Backend =:run= fails (test failure, Make error) → keep the =compile= buffer visible, do *not* proceed to display a report. Partial data is worse than no data. -- Run completes but no LCOV produced → error naming the expected path. +- Run completes but no simplecov.json produced → error naming the expected path. *Post-flight classification:* three buckets, not two. -- *Covered* — changed line in LCOV's covered-line set. +- *Covered* — changed line in the simplecov covered-line set. - *Uncovered* — changed line in a tracked file but not covered. -- *Not tracked* — changed file isn't in LCOV at all (test files, READMEs, config). Reported separately — don't conflate "coverage didn't look here" with "tests didn't exercise this code." +- *Not tracked* — changed file isn't in the simplecov data at all (test files, READMEs, config). Reported separately — don't conflate "coverage didn't look here" with "tests didn't exercise this code." *Happy-path degenerates:* - Zero changed lines in scope → "No changes in this scope; nothing to report." @@ -119,7 +121,7 @@ All three are pure, fully ERT-tested. *Global:* - =F7= → =cj/coverage-report= (prompts scope, shows report). -- =C-u F7= → force re-run regardless of LCOV freshness. +- =C-u F7= → force re-run regardless of report freshness. *In the report buffer* (compilation-mode derived, most inherited for free): - =RET= → jump to source under point. @@ -136,7 +138,7 @@ The =F4=–=F7= developer block (compile+run, debug, test, coverage) gets its fu ** Testing *Pure helpers, fully tested* (Normal / Boundary / Error for each): -- =cj/--coverage-parse-lcov= — handcrafted LCOV fragments in temp files; empty, headers-only, spaces/unicode in filenames, malformed lines, missing =end_of_record=. +- =cj/--coverage-parse-simplecov= — handcrafted simplecov JSON in temp files; empty object, all-null coverage arrays, spaces in filenames, multiple test-name keys unioned, malformed JSON. - =cj/--coverage-changed-lines= — =cl-letf= over =shell-command-to-string= to return canned =git diff= output; single hunk, new-file hunk, deletion-only hunk, binary marker, no-diff case. - =cj/--coverage-intersect= — pure table-in / table-out; covered ⊇ changed, unknown files, nil/empty inputs. @@ -144,13 +146,13 @@ The =F4=–=F7= developer block (compile+run, debug, test, coverage) gets its fu - =cj/coverage-backend-for-project ROOT= — synthetic temp project roots with marker files; assert correct backend. Registration-order test: two backends match, first-registered wins. *Not tested:* -- =cj/coverage-report= interactive command — one smoke test with a prepared LCOV and a stubbed git-diff. No tests for the prompt UI or the compilation-buffer display. +- =cj/coverage-report= interactive command — one smoke test with a prepared simplecov report and a stubbed git-diff. No tests for the prompt UI or the compilation-buffer display. - The elisp backend's =:run= function — shells to =make coverage=; integration-test-shaped, low value, slow. Skipped by design. * Open Questions - [ ] Which tests should a coverage run actually execute? All of them (simple, slow for 265 files), or only the test files whose target modules changed (fast, but dependent-test discovery in Elisp is non-trivial)? Deferred until implementation. -- [ ] Default behavior when LCOV is stale but not missing: prompt, or auto-rerun? Current design prompts. Revisit after first use. +- [ ] Default behavior when the simplecov report is stale but not missing: prompt, or auto-rerun? Current design prompts. Revisit after first use. - [ ] Whether =cj/coverage-base-branch= should be a single value or a list of candidates (useful if you routinely stack PRs more than one level deep). Single value for v1. * Next Steps diff --git a/modules/coverage-core.el b/modules/coverage-core.el index f1e8ae88..2209b2f7 100644 --- a/modules/coverage-core.el +++ b/modules/coverage-core.el @@ -16,7 +16,7 @@ (defvar cj/coverage-backends nil "Registry of coverage backends in priority order. -Each entry is a plist with at least :name, :detect, :run, and :lcov-path. +Each entry is a plist with at least :name, :detect, :run, and :report-path. Use `cj/coverage-register-backend' to add or replace an entry.") (defvar-local cj/coverage-backend nil @@ -26,7 +26,7 @@ function in registration order. Typically set buffer-locally via `.dir-locals.el' to pin a specific backend.") (defun cj/coverage-register-backend (backend) - "Register BACKEND, a plist with :name, :detect, :run, :lcov-path. + "Register BACKEND, a plist with :name, :detect, :run, :report-path. Appends to `cj/coverage-backends' at the end, or replaces the existing entry with the same :name in its current position." (let ((name (plist-get backend :name))) @@ -65,46 +65,50 @@ Returns the backend plist, or nil when no backend matches." (funcall (plist-get backend :detect) root)) cj/coverage-backends)))) -(defun cj/--coverage-parse-lcov (file) - "Parse FILE as LCOV and return a hash table of covered lines. +(defun cj/--coverage-parse-simplecov (file) + "Parse FILE as a simplecov JSON report and return covered lines per file. Keys are source-file paths (strings). Values are hash tables whose -keys are line numbers (integers) that had a hit count greater than -zero. Only the SF, DA, and end_of_record fields are read; other -LCOV fields are ignored. Malformed DA lines are skipped silently. -Signals `user-error' if FILE does not exist." +keys are line numbers (integers) with a hit count greater than zero. +Lines marked nil (not executable) or 0 (executable but not hit) are +excluded. + +Simplecov JSON structure is: + { <test-name>: { \"coverage\": { <path>: [null | 0 | int, ...] } } } + +When the JSON contains multiple top-level test-name keys, coverage +data is unioned across them; useful for files produced with undercover's +`:merge-report t' option that accumulate runs under a shared key, and +also for defensive handling of unexpected multi-key shapes. + +Signals `user-error' if FILE does not exist or contains malformed JSON." (unless (file-exists-p file) - (user-error "LCOV file not found: %s" file)) - (let ((result (make-hash-table :test 'equal)) - (current-file nil) - (current-lines nil)) - (with-temp-buffer - (insert-file-contents file) - (goto-char (point-min)) - (while (not (eobp)) - (let ((line (buffer-substring-no-properties - (line-beginning-position) (line-end-position)))) - (cond - ((string-prefix-p "SF:" line) - (setq current-file (substring line 3)) - (setq current-lines (make-hash-table :test 'eql))) - ((string-prefix-p "DA:" line) - (when current-lines - (let* ((rest (substring line 3)) - (parts (split-string rest ",")) - (line-str (car parts)) - (hits-str (cadr parts)) - (line-num (and line-str (string-match-p "\\`[0-9]+\\'" line-str) - (string-to-number line-str))) - (hits (and hits-str (string-match-p "\\`[0-9]+\\'" hits-str) - (string-to-number hits-str)))) - (when (and line-num hits (> hits 0)) - (puthash line-num t current-lines))))) - ((string= line "end_of_record") - (when current-file - (puthash current-file current-lines result)) - (setq current-file nil - current-lines nil)))) - (forward-line 1))) + (user-error "Simplecov report not found: %s" file)) + (require 'json) + (let* ((json-object-type 'hash-table) + (json-array-type 'list) + (json-key-type 'string) + (data (condition-case err + (json-read-file file) + (error (user-error "Malformed simplecov JSON in %s: %s" + file (error-message-string err))))) + (result (make-hash-table :test 'equal))) + (maphash + (lambda (_test-name section) + (when (hash-table-p section) + (let ((coverage (gethash "coverage" section))) + (when (hash-table-p coverage) + (maphash + (lambda (path hits-list) + (let ((lines (or (gethash path result) + (make-hash-table :test 'eql))) + (line-num 1)) + (dolist (hits hits-list) + (when (and (numberp hits) (> hits 0)) + (puthash line-num t lines)) + (setq line-num (1+ line-num))) + (puthash path lines result))) + coverage))))) + data) result)) (defconst cj/--coverage-hunk-header-regexp @@ -180,7 +184,7 @@ Signals `user-error' for any other SCOPE." (defun cj/--coverage-intersect (covered changed) "Combine COVERED (LCOV) with CHANGED (git diff) into per-file records. COVERED and CHANGED are each hash tables from file path to a hash table -of line numbers (as built by `cj/--coverage-parse-lcov' and +of line numbers (as built by `cj/--coverage-parse-simplecov' and `cj/--coverage-parse-diff-output'). Either may be nil, in which case the result is an empty list. diff --git a/modules/coverage-elisp.el b/modules/coverage-elisp.el index 63d89906..048c81dd 100644 --- a/modules/coverage-elisp.el +++ b/modules/coverage-elisp.el @@ -13,8 +13,10 @@ ;; the callback is invoked with the LCOV path; on failure, the buffer ;; stays visible for the user to inspect. ;; -;; :lcov-path resolves to `<project-root>/.coverage/lcov.info', which -;; matches the path the Makefile's coverage target writes to. +;; :report-path resolves to `<project-root>/.coverage/simplecov.json', +;; which matches the path the Makefile's coverage target writes to. +;; The simplecov JSON format is used because undercover's `:merge-report' +;; support only covers simplecov, not LCOV. ;;; Code: @@ -23,9 +25,9 @@ (use-package undercover :defer t) -(defconst cj/--coverage-elisp-lcov-relative-path - ".coverage/lcov.info" - "Project-relative path to the LCOV file produced by `make coverage'.") +(defconst cj/--coverage-elisp-report-relative-path + ".coverage/simplecov.json" + "Project-relative path to the simplecov JSON produced by `make coverage'.") (defun cj/--coverage-elisp-project-root (&optional root) "Return ROOT or fall back to projectile's root or `default-directory'." @@ -44,14 +46,14 @@ The heuristic needs both (a) a Makefile, Eask, or Cask at ROOT and (or (file-expand-wildcards (expand-file-name "modules/*.el" root)) (file-expand-wildcards (expand-file-name "*.el" root))))) -(defun cj/--coverage-elisp-lcov-path (&optional root) - "Return the absolute path to the LCOV file for ROOT." - (expand-file-name cj/--coverage-elisp-lcov-relative-path +(defun cj/--coverage-elisp-report-path (&optional root) + "Return the absolute path to the simplecov JSON file for ROOT." + (expand-file-name cj/--coverage-elisp-report-relative-path (cj/--coverage-elisp-project-root root))) (defun cj/--coverage-elisp-run (callback) "Run `make coverage' asynchronously. -CALLBACK is invoked with the LCOV path when the build finishes +CALLBACK is invoked with the report path when the build finishes successfully. On failure, no callback is invoked and the compilation buffer stays visible so the user can read the error." (let* ((default-directory (cj/--coverage-elisp-project-root)) @@ -61,14 +63,14 @@ buffer stays visible so the user can read the error." (add-hook 'compilation-finish-functions (lambda (_buf status) (when (string-match-p "^finished" status) - (funcall callback (cj/--coverage-elisp-lcov-path)))) + (funcall callback (cj/--coverage-elisp-report-path)))) nil t)))) (cj/coverage-register-backend (list :name 'elisp :detect #'cj/--coverage-elisp-detect :run #'cj/--coverage-elisp-run - :lcov-path #'cj/--coverage-elisp-lcov-path)) + :report-path #'cj/--coverage-elisp-report-path)) (provide 'coverage-elisp) ;;; coverage-elisp.el ends here 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 |
