diff options
| author | Craig Jennings <c@cjennings.net> | 2026-04-23 01:07:52 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-04-23 01:07:52 -0500 |
| commit | 3fa47b1a3b7dd8d3a4f00f117e6f97caf55a3c5d (patch) | |
| tree | 41a88535e7dfed8f48107936468bf44adc5825a4 | |
| parent | a97266c0e89ef8560824789063512d2613849fc9 (diff) | |
| download | dotemacs-3fa47b1a3b7dd8d3a4f00f117e6f97caf55a3c5d.tar.gz dotemacs-3fa47b1a3b7dd8d3a4f00f117e6f97caf55a3c5d.zip | |
feat(coverage): add format-report helper for the report buffer
Pure helper that renders intersect records into the text shown in the coverage report buffer. Takes the list of per-file plists from cj/--coverage-intersect and a scope label, returns the formatted string.
Output has three sections depending on what's present:
- "Uncovered lines" — one line per uncovered line, formatted as "<path>:<line>: uncovered" so compilation-mode's default regex picks them up for next-error navigation.
- "Not tracked" — files changed in the diff but absent from the coverage data (READMEs, test files, config).
- "Fully covered" — tracked files where every changed line is covered.
Files with empty :changed-lines (deletion-only hunks) are omitted. Summary counts cover only tracked files, so an all-README change shows "0 of 0" rather than a misleading percentage over nothing.
Tests cover Normal (partial, fully covered, mixed sections), Boundary (empty records, 100% coverage with no uncovered section, only-not-tracked case, deletion-only exclusion), and the output format that next-error relies on.
| -rw-r--r-- | modules/coverage-core.el | 70 | ||||
| -rw-r--r-- | tests/test-coverage-core--format-report.el | 124 |
2 files changed, 194 insertions, 0 deletions
diff --git a/modules/coverage-core.el b/modules/coverage-core.el index 2209b2f7..bda90612 100644 --- a/modules/coverage-core.el +++ b/modules/coverage-core.el @@ -227,5 +227,75 @@ can be classified as covered or uncovered." records))) (nreverse records))) +(defun cj/--coverage-format-report (records scope-label) + "Render RECORDS as a text report for SCOPE-LABEL. +RECORDS is the list of plists produced by `cj/--coverage-intersect'. +SCOPE-LABEL is the human-readable scope name (e.g. \"Staged\"). +Returns a string ready to insert into a compilation-mode buffer. + +Uncovered-line entries use the format \"<path>:<line>: uncovered\" +so `compilation-error-regexp-alist' picks them up for +`next-error' / `previous-error' navigation. + +Files with an empty :changed-lines (deletion-only hunks) are +omitted from the display. The summary counts only tracked files." + (if (null records) + (format "Coverage Report — %s\n\nNo changes in this scope; nothing to report.\n" + scope-label) + (let (partial fully-covered not-tracked + (total-covered 0) + (total-tracked 0)) + (dolist (rec records) + (let ((changed (plist-get rec :changed-lines)) + (tracked (plist-get rec :tracked)) + (uncovered (plist-get rec :uncovered-lines)) + (covered (plist-get rec :covered-lines))) + (cond + ((null changed) nil) ; deletion-only; skip + ((not tracked) + (push rec not-tracked)) + (uncovered + (push rec partial) + (setq total-covered (+ total-covered (length covered)) + total-tracked (+ total-tracked (length changed)))) + (t + (push rec fully-covered) + (setq total-covered (+ total-covered (length covered)) + total-tracked (+ total-tracked (length changed))))))) + (setq partial (nreverse partial) + fully-covered (nreverse fully-covered) + not-tracked (nreverse not-tracked)) + (with-temp-buffer + (let* ((header (format "Coverage Report — %s" scope-label)) + (pct (if (> total-tracked 0) + (/ (* 100.0 total-covered) total-tracked) + 0.0))) + (insert header "\n") + (insert (make-string (length header) ?=) "\n\n") + (insert (format "Summary: %d of %d changed lines covered (%.1f%%)\n\n" + total-covered total-tracked pct))) + (when partial + (insert "Uncovered lines:\n") + (dolist (rec partial) + (dolist (line (plist-get rec :uncovered-lines)) + (insert (format " %s:%d: uncovered\n" + (plist-get rec :path) line)))) + (insert "\n")) + (when not-tracked + (insert "Not tracked (coverage data unavailable):\n") + (dolist (rec not-tracked) + (insert (format " %s (%d lines changed)\n" + (plist-get rec :path) + (length (plist-get rec :changed-lines))))) + (insert "\n")) + (when fully-covered + (insert "Fully covered:\n") + (dolist (rec fully-covered) + (let ((cnt (length (plist-get rec :covered-lines)))) + (insert (format " %s (%d/%d)\n" + (plist-get rec :path) cnt cnt)))) + (insert "\n")) + (buffer-string))))) + (provide 'coverage-core) ;;; coverage-core.el ends here diff --git a/tests/test-coverage-core--format-report.el b/tests/test-coverage-core--format-report.el new file mode 100644 index 00000000..24d34be0 --- /dev/null +++ b/tests/test-coverage-core--format-report.el @@ -0,0 +1,124 @@ +;;; test-coverage-core--format-report.el --- Tests for cj/--coverage-format-report -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for `cj/--coverage-format-report', the pure helper that +;; renders intersect records into the text shown in the report buffer. +;; +;; Input: list of plists from `cj/--coverage-intersect': +;; (:path PATH +;; :changed-lines LIST +;; :covered-lines LIST +;; :uncovered-lines LIST +;; :tracked BOOL) +;; +;; Output: a string suitable for insertion into a compilation-mode +;; buffer. Uncovered-line entries use the format "<path>:<line>: +;; uncovered" so `compilation-error-regexp-alist' picks them up for +;; `next-error' navigation. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'coverage-core) + +(defun test-coverage-format-report--record (path changed covered uncovered tracked) + "Build an intersect record plist for use in tests." + (list :path path + :changed-lines changed + :covered-lines covered + :uncovered-lines uncovered + :tracked tracked)) + +;;; Normal cases + +(ert-deftest test-coverage-format-report-partial-coverage () + "Normal: one file with 2 of 3 changed lines covered." + (let* ((records (list (test-coverage-format-report--record + "modules/foo.el" '(10 11 12) '(10 11) '(12) t))) + (output (cj/--coverage-format-report records "Staged"))) + (should (string-match-p "Staged" output)) + ;; Summary line shows the counts + (should (string-match-p "2 of 3" output)) + (should (string-match-p "66" output)) ; 66.7% + ;; Uncovered line uses compilation-friendly format + (should (string-match-p "modules/foo\\.el:12: uncovered" output)) + ;; Covered lines aren't listed individually + (should-not (string-match-p "modules/foo\\.el:10: uncovered" output)) + (should-not (string-match-p "modules/foo\\.el:11: uncovered" output)))) + +(ert-deftest test-coverage-format-report-fully-covered () + "Normal: a fully-covered file lists in the \"Fully covered\" section." + (let* ((records (list (test-coverage-format-report--record + "modules/baz.el" '(1 2 3) '(1 2 3) nil t))) + (output (cj/--coverage-format-report records "Working tree"))) + (should (string-match-p "Fully covered" output)) + (should (string-match-p "modules/baz\\.el" output)) + (should (string-match-p "3/3" output)) + ;; No uncovered entries for this file + (should-not (string-match-p ":[0-9]+: uncovered" output)))) + +(ert-deftest test-coverage-format-report-mixed-records () + "Normal: a mix of covered, partial, and not-tracked files." + (let* ((records (list + (test-coverage-format-report--record + "modules/foo.el" '(10 11) '(10) '(11) t) + (test-coverage-format-report--record + "README.md" '(5 6 7) nil nil nil) + (test-coverage-format-report--record + "modules/baz.el" '(1 2) '(1 2) nil t))) + (output (cj/--coverage-format-report records "Branch vs main"))) + (should (string-match-p "Uncovered lines" output)) + (should (string-match-p "Not tracked" output)) + (should (string-match-p "Fully covered" output)) + (should (string-match-p "modules/foo\\.el:11: uncovered" output)) + (should (string-match-p "README\\.md" output)) + (should (string-match-p "modules/baz\\.el" output)))) + +;;; Boundary cases + +(ert-deftest test-coverage-format-report-empty () + "Boundary: no records produces a clear \"nothing to report\" message." + (let ((output (cj/--coverage-format-report nil "Staged"))) + (should (string-match-p "No changes" output)) + (should (string-match-p "Staged" output)))) + +(ert-deftest test-coverage-format-report-100-percent () + "Boundary: 100% coverage shows a success line." + (let* ((records (list (test-coverage-format-report--record + "modules/foo.el" '(1 2 3) '(1 2 3) nil t))) + (output (cj/--coverage-format-report records "Working tree"))) + (should (string-match-p "3 of 3" output)) + (should (string-match-p "100" output)) + ;; No "Uncovered lines" section when there are none + (should-not (string-match-p "Uncovered lines" output)))) + +(ert-deftest test-coverage-format-report-only-not-tracked () + "Boundary: every changed file is not-tracked (README-only edits)." + (let* ((records (list + (test-coverage-format-report--record + "README.md" '(1 2) nil nil nil) + (test-coverage-format-report--record + "CHANGELOG.md" '(5) nil nil nil))) + (output (cj/--coverage-format-report records "Staged"))) + (should (string-match-p "Not tracked" output)) + ;; Summary denominator excludes not-tracked lines + (should (string-match-p "0 of 0" output)) + (should-not (string-match-p "Uncovered lines" output)))) + +(ert-deftest test-coverage-format-report-deletion-only-skipped () + "Boundary: deletion-only records (empty changed-lines) are excluded from display." + (let* ((records (list + (test-coverage-format-report--record + "modules/foo.el" '(10 11) '(10 11) nil t) + (test-coverage-format-report--record + "gone.el" nil nil nil t))) + (output (cj/--coverage-format-report records "Working tree"))) + ;; The deletion-only file shouldn't appear anywhere. + (should-not (string-match-p "gone\\.el" output)) + ;; Summary still shows the normal-case counts. + (should (string-match-p "2 of 2" output)))) + +(provide 'test-coverage-core--format-report) +;;; test-coverage-core--format-report.el ends here |
