aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/coverage-core.el52
-rw-r--r--tests/test-coverage-core--intersect.el144
2 files changed, 196 insertions, 0 deletions
diff --git a/modules/coverage-core.el b/modules/coverage-core.el
index a2b03817..e7540434 100644
--- a/modules/coverage-core.el
+++ b/modules/coverage-core.el
@@ -118,5 +118,57 @@ Signals `user-error' for any other SCOPE."
(user-error "Unknown coverage scope: %s" scope)))))
(cj/--coverage-parse-diff-output (shell-command-to-string cmd))))
+(defun cj/--coverage-hash-keys-sorted (table)
+ "Return a sorted list of TABLE's integer keys."
+ (let (keys)
+ (maphash (lambda (k _v) (push k keys)) table)
+ (sort keys #'<)))
+
+(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
+`cj/--coverage-parse-diff-output'). Either may be nil, in which case
+the result is an empty list.
+
+Return value is a list of plists, one per entry in CHANGED, sorted by
+path:
+ (:path PATH
+ :changed-lines LIST-OF-INTS
+ :covered-lines LIST-OF-INTS ; nil when the file isn't tracked
+ :uncovered-lines LIST-OF-INTS ; nil when the file isn't tracked
+ :tracked BOOL)
+
+A file that appears in CHANGED but not in COVERED is marked as
+`:tracked nil'; coverage data is unavailable for it, so no lines
+can be classified as covered or uncovered."
+ (unless (and covered changed)
+ (setq covered (or covered (make-hash-table :test 'equal)))
+ (setq changed (or changed (make-hash-table :test 'equal))))
+ (let (paths records)
+ (maphash (lambda (path _) (push path paths)) changed)
+ (setq paths (sort paths #'string<))
+ (dolist (path paths)
+ (let* ((changed-set (gethash path changed))
+ (changed-lines (cj/--coverage-hash-keys-sorted changed-set))
+ (covered-set (gethash path covered))
+ (tracked (and covered-set t))
+ covered-lines
+ uncovered-lines)
+ (when tracked
+ (dolist (line changed-lines)
+ (if (gethash line covered-set)
+ (push line covered-lines)
+ (push line uncovered-lines)))
+ (setq covered-lines (nreverse covered-lines)
+ uncovered-lines (nreverse uncovered-lines)))
+ (push (list :path path
+ :changed-lines changed-lines
+ :covered-lines covered-lines
+ :uncovered-lines uncovered-lines
+ :tracked tracked)
+ records)))
+ (nreverse records)))
+
(provide 'coverage-core)
;;; coverage-core.el ends here
diff --git a/tests/test-coverage-core--intersect.el b/tests/test-coverage-core--intersect.el
new file mode 100644
index 00000000..00542cb2
--- /dev/null
+++ b/tests/test-coverage-core--intersect.el
@@ -0,0 +1,144 @@
+;;; test-coverage-core--intersect.el --- Tests for cj/--coverage-intersect -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for `cj/--coverage-intersect', the pure helper that
+;; combines covered-line data (from LCOV) with changed-line data
+;; (from git diff) into per-file records ready for the report buffer.
+;;
+;; Return shape: a list of plists, one per changed file, sorted by path.
+;; (:path "modules/foo.el"
+;; :changed-lines (10 11 12)
+;; :covered-lines (10 11) ; nil when the file isn't tracked
+;; :uncovered-lines (12) ; nil when the file isn't tracked
+;; :tracked t) ; nil when the file isn't in the LCOV data
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'coverage-core)
+
+(defun test-coverage-intersect--hash-of-lines (pairs)
+ "Build a file → line-set hash table from PAIRS.
+Each pair is (FILE . (LINES...)); LINES becomes a hash-table of line → t."
+ (let ((result (make-hash-table :test 'equal)))
+ (dolist (pair pairs)
+ (let ((lines (make-hash-table :test 'eql)))
+ (dolist (line (cdr pair))
+ (puthash line t lines))
+ (puthash (car pair) lines result)))
+ result))
+
+;;; Normal cases
+
+(ert-deftest test-coverage-intersect-single-file-all-covered ()
+ "Normal: one changed file, all lines covered."
+ (let* ((covered (test-coverage-intersect--hash-of-lines
+ '(("foo.el" 10 11 12))))
+ (changed (test-coverage-intersect--hash-of-lines
+ '(("foo.el" 10 11 12))))
+ (result (cj/--coverage-intersect covered changed))
+ (record (car result)))
+ (should (= 1 (length result)))
+ (should (equal "foo.el" (plist-get record :path)))
+ (should (equal '(10 11 12) (plist-get record :covered-lines)))
+ (should (equal nil (plist-get record :uncovered-lines)))
+ (should (equal '(10 11 12) (plist-get record :changed-lines)))
+ (should (eq t (plist-get record :tracked)))))
+
+(ert-deftest test-coverage-intersect-single-file-partial ()
+ "Normal: partial coverage — 10 and 12 covered, 11 not."
+ (let* ((covered (test-coverage-intersect--hash-of-lines
+ '(("foo.el" 10 12 50))))
+ (changed (test-coverage-intersect--hash-of-lines
+ '(("foo.el" 10 11 12))))
+ (result (cj/--coverage-intersect covered changed))
+ (record (car result)))
+ (should (equal '(10 12) (plist-get record :covered-lines)))
+ (should (equal '(11) (plist-get record :uncovered-lines)))
+ (should (eq t (plist-get record :tracked)))))
+
+(ert-deftest test-coverage-intersect-multiple-files-sorted ()
+ "Normal: multiple files sorted by path."
+ (let* ((covered (test-coverage-intersect--hash-of-lines
+ '(("a.el" 1) ("b.el" 5) ("c.el" 10))))
+ (changed (test-coverage-intersect--hash-of-lines
+ '(("c.el" 10) ("a.el" 1) ("b.el" 5))))
+ (result (cj/--coverage-intersect covered changed))
+ (paths (mapcar (lambda (r) (plist-get r :path)) result)))
+ (should (equal '("a.el" "b.el" "c.el") paths))))
+
+;;; Boundary cases
+
+(ert-deftest test-coverage-intersect-file-not-tracked ()
+ "Boundary: file has changed lines but isn't in the covered set at all."
+ (let* ((covered (test-coverage-intersect--hash-of-lines
+ '(("tracked.el" 1 2 3))))
+ (changed (test-coverage-intersect--hash-of-lines
+ '(("README.md" 5 6 7))))
+ (result (cj/--coverage-intersect covered changed))
+ (record (car result)))
+ (should (= 1 (length result)))
+ (should (equal "README.md" (plist-get record :path)))
+ (should (equal '(5 6 7) (plist-get record :changed-lines)))
+ (should (equal nil (plist-get record :covered-lines)))
+ (should (equal nil (plist-get record :uncovered-lines)))
+ (should (eq nil (plist-get record :tracked)))))
+
+(ert-deftest test-coverage-intersect-tracked-file-none-covered ()
+ "Boundary: file is in LCOV but none of the changed lines are covered."
+ (let* ((covered (test-coverage-intersect--hash-of-lines
+ '(("foo.el" 1 2 3))))
+ (changed (test-coverage-intersect--hash-of-lines
+ '(("foo.el" 10 11 12))))
+ (result (cj/--coverage-intersect covered changed))
+ (record (car result)))
+ (should (eq t (plist-get record :tracked)))
+ (should (equal nil (plist-get record :covered-lines)))
+ (should (equal '(10 11 12) (plist-get record :uncovered-lines)))))
+
+(ert-deftest test-coverage-intersect-empty-changed-lines ()
+ "Boundary: file with empty changed-lines (deletion-only) appears with all lists empty."
+ (let* ((covered (test-coverage-intersect--hash-of-lines
+ '(("foo.el" 1 2))))
+ (changed (test-coverage-intersect--hash-of-lines
+ '(("gone.el"))))
+ (result (cj/--coverage-intersect covered changed))
+ (record (car result)))
+ (should (= 1 (length result)))
+ (should (equal "gone.el" (plist-get record :path)))
+ (should (equal nil (plist-get record :changed-lines)))
+ (should (equal nil (plist-get record :covered-lines)))
+ (should (equal nil (plist-get record :uncovered-lines)))))
+
+(ert-deftest test-coverage-intersect-empty-changed-returns-empty ()
+ "Boundary: empty CHANGED hash table returns an empty list, not nil-as-error."
+ (let* ((covered (test-coverage-intersect--hash-of-lines
+ '(("foo.el" 1 2 3))))
+ (changed (make-hash-table :test 'equal))
+ (result (cj/--coverage-intersect covered changed)))
+ (should (listp result))
+ (should (= 0 (length result)))))
+
+(ert-deftest test-coverage-intersect-empty-covered-all-not-tracked ()
+ "Boundary: empty COVERED means every changed file is not-tracked."
+ (let* ((covered (make-hash-table :test 'equal))
+ (changed (test-coverage-intersect--hash-of-lines
+ '(("a.el" 1) ("b.el" 5))))
+ (result (cj/--coverage-intersect covered changed)))
+ (should (= 2 (length result)))
+ (dolist (record result)
+ (should (eq nil (plist-get record :tracked))))))
+
+;;; Error cases
+
+(ert-deftest test-coverage-intersect-nil-inputs-return-empty ()
+ "Error: nil CHANGED returns an empty list rather than erroring."
+ (should (equal nil (cj/--coverage-intersect nil nil)))
+ (should (equal nil (cj/--coverage-intersect
+ (test-coverage-intersect--hash-of-lines '(("x" 1)))
+ nil))))
+
+(provide 'test-coverage-core--intersect)
+;;; test-coverage-core--intersect.el ends here