summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-23 01:07:52 -0500
committerCraig Jennings <c@cjennings.net>2026-04-23 01:07:52 -0500
commit3fa47b1a3b7dd8d3a4f00f117e6f97caf55a3c5d (patch)
tree41a88535e7dfed8f48107936468bf44adc5825a4
parenta97266c0e89ef8560824789063512d2613849fc9 (diff)
downloaddotemacs-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.el70
-rw-r--r--tests/test-coverage-core--format-report.el124
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