diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-31 11:43:03 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-31 11:43:03 -0500 |
| commit | b46619cd17ed4e36f2e59c1b600078521b2049ef (patch) | |
| tree | f128aeef3f0f679a400595c896a98618266706d9 /languages/elisp/tests | |
| parent | 3640664e0fa11d7eb99c2900df57734b411e2d2b (diff) | |
| download | rulesets-b46619cd17ed4e36f2e59c1b600078521b2049ef.tar.gz rulesets-b46619cd17ed4e36f2e59c1b600078521b2049ef.zip | |
feat(elisp): add coverage-summary to the Elisp bundle with missing-file detection
A line-weighted coverage total has a blind spot: a module no test loads never shows up in the SimpleCov report, so it can't drag the number down. The suite looks healthier than it is. This adds a summary that counts every source file on disk against the report and treats an absent file as 0%, weighting the project number by file instead of by line so untested modules stay visible.
The script ships at languages/elisp/claude/scripts/coverage-summary.el, self-contained on stock Emacs (just the built-in json). It parses the undercover SimpleCov shape directly rather than depending on the editor's coverage engine, so it runs anywhere the bundle lands. I proved it against a real 103-file report: 93 tracked, 27 untested modules surfaced, project number 66.4%.
Delivery follows the bundle convention. The script lives under the gitignored .claude/ footprint and gets auto-fixed on drift by sync-language-bundle.sh, which I made generic for any claude/scripts/* rather than coverage-specific. The Makefile targets ship as a project-owned fragment (languages/elisp/coverage-makefile.txt) that install-lang.sh seeds at the project root and sync drops into .ai/inbox/ when that convention exists. The bundle never edits the project's own Makefile.
Tests: 12 ERT for the kernel (Normal/Boundary/Error per function), wired into make test via a new languages/*/tests/ discovery path, plus bats for the sync auto-fix and the inbox-drop guards.
This is the Elisp pilot. The pattern is proven, so fanning out to Python, Go, and TypeScript is now a follow-up. Each one needs only its own parser and fragment. The plumbing is already generic.
Diffstat (limited to 'languages/elisp/tests')
| -rw-r--r-- | languages/elisp/tests/test-coverage-summary.el | 173 |
1 files changed, 173 insertions, 0 deletions
diff --git a/languages/elisp/tests/test-coverage-summary.el b/languages/elisp/tests/test-coverage-summary.el new file mode 100644 index 0000000..5be03b3 --- /dev/null +++ b/languages/elisp/tests/test-coverage-summary.el @@ -0,0 +1,173 @@ +;;; test-coverage-summary.el --- Tests for the bundle coverage-summary kernel -*- lexical-binding: t; -*- + +;;; Commentary: +;; ERT tests for languages/elisp/claude/scripts/coverage-summary.el. +;; +;; The kernel is the missing-file detection and the unit-weighted project +;; number; the per-file table is incidental. Tests build a throwaway project +;; tree (source files on disk) plus a SimpleCov JSON report, then assert the +;; kernel's numbers against hand-computed values. +;; +;; Normal / Boundary / Error coverage for each pure function. + +;;; Code: + +(require 'ert) +(require 'json) + +;; Load the script under test relative to this file, so the test runs from any cwd. +(load (expand-file-name + "../claude/scripts/coverage-summary.el" + (file-name-directory (or load-file-name buffer-file-name))) + nil t) + +;; --- fixtures -------------------------------------------------------------- + +(defun cs-test--write (path contents) + "Write CONTENTS to PATH, creating parent dirs." + (make-directory (file-name-directory path) t) + (with-temp-file path (insert contents))) + +(defmacro cs-test--with-project (spec &rest body) + "Build a temp project from SPEC and run BODY with `root', `src', `report' bound. + +SPEC is a plist: + :sources alist of (relpath . contents) source files written under src/ + :report the SimpleCov JSON string written to .coverage/simplecov.json + (the literal string \"%SRC%\" is replaced with the absolute src dir)." + (declare (indent 1)) + `(let* ((root (file-name-as-directory (make-temp-file "cs-proj-" t))) + (src (expand-file-name "src/" root)) + (report (expand-file-name ".coverage/simplecov.json" root))) + (unwind-protect + (progn + (dolist (pair (plist-get ,spec :sources)) + (cs-test--write (expand-file-name (car pair) src) (cdr pair))) + (cs-test--write + report + (replace-regexp-in-string + "%SRC%" (directory-file-name src) (plist-get ,spec :report) t t)) + ,@body) + (delete-directory root t)))) + +(defun cs-test--report (files) + "Build a SimpleCov JSON string for FILES. +FILES is an alist of (relpath . line-vector-literal), where each line vector +is a JSON array string like \"[1, 0, null]\"." + (concat + "{\"undercover.el\": {\"timestamp\": 0, \"coverage\": {" + (mapconcat + (lambda (pair) + (format "\"%%SRC%%/%s\": %s" (car pair) (cdr pair))) + files ", ") + "}}}")) + +;; --- parse: covered / total ------------------------------------------------ + +(ert-deftest cs-parse-counts-covered-and-executable () + "covered = entries > 0; total = numeric entries; null is non-executable." + (cs-test--with-project + (list :sources '(("a.el" . ";; a")) + :report (cs-test--report '(("a.el" . "[null, 1, 0, 3, null, 0]")))) + (let* ((table (cj/coverage-summary--parse-file report)) + (rec (gethash (expand-file-name "a.el" src) table))) + ;; entries: null,1,0,3,null,0 -> numeric {1,0,3,0}=4 total, >0 {1,3}=2 covered + (should (equal rec '(2 . 4)))))) + +(ert-deftest cs-parse-unions-multiple-suites () + "Coverage data unions across multiple top-level suite keys." + (cs-test--with-project + (list :sources '(("a.el" . ";; a")) + :report (concat + "{\"s1\": {\"coverage\": {\"%SRC%/a.el\": [1, 0, 0]}}," + " \"s2\": {\"coverage\": {\"%SRC%/a.el\": [0, 1, 0]}}}")) + (let* ((table (cj/coverage-summary--parse-file report)) + (rec (gethash (expand-file-name "a.el" src) table))) + ;; union of hits at line 1 (s1) and line 2 (s2): 2 covered of 3 total + (should (equal rec '(2 . 3)))))) + +(ert-deftest cs-parse-missing-report-signals () + "A nonexistent report file signals user-error." + (should-error (cj/coverage-summary--parse-file "/no/such/report.json") + :type 'user-error)) + +(ert-deftest cs-parse-malformed-json-signals () + "Malformed JSON signals user-error rather than a raw json error." + (cs-test--with-project + (list :sources '(("a.el" . ";; a")) :report "{not json") + (should-error (cj/coverage-summary--parse-file report) :type 'user-error))) + +;; --- file percentage ------------------------------------------------------- + +(ert-deftest cs-file-pct-normal () + (should (= 50.0 (cj/coverage-summary--file-pct 1 2)))) + +(ert-deftest cs-file-pct-no-executable-lines-is-100 () + "A tracked file with zero executable lines has nothing left uncovered." + (should (= 100.0 (cj/coverage-summary--file-pct 0 0)))) + +(ert-deftest cs-file-pct-fully-covered () + (should (= 100.0 (cj/coverage-summary--file-pct 4 4)))) + +;; --- missing-file detection (the kernel) ----------------------------------- + +(ert-deftest cs-missing-finds-ondisk-file-absent-from-report () + "A source file on disk but not in the report is reported missing." + (cs-test--with-project + (list :sources '(("tracked.el" . ";; t") ("untested.el" . ";; u")) + :report (cs-test--report '(("tracked.el" . "[1, 1]")))) + (let* ((table (cj/coverage-summary--under-dir + (cj/coverage-summary--parse-file report) src root)) + (tracked (let (ks) (maphash (lambda (k _v) (push k ks)) table) ks)) + (missing (cj/coverage-summary--missing tracked src root))) + (should (equal missing (list (file-relative-name + (expand-file-name "src/untested.el" root) + root))))))) + +(ert-deftest cs-missing-empty-when-all-tracked () + "No missing files when every source file appears in the report." + (cs-test--with-project + (list :sources '(("a.el" . ";; a")) + :report (cs-test--report '(("a.el" . "[1]")))) + (let* ((table (cj/coverage-summary--under-dir + (cj/coverage-summary--parse-file report) src root)) + (tracked (let (ks) (maphash (lambda (k _v) (push k ks)) table) ks)) + (missing (cj/coverage-summary--missing tracked src root))) + (should (null missing))))) + +;; --- project number (unit-weighted, missing as 0%) ------------------------- + +(ert-deftest cs-project-pct-unit-weighted-with-missing-as-zero () + "Project number averages per-file pct over tracked+missing; missing=0%." + (cs-test--with-project + (list :sources '(("full.el" . ";; f") ("half.el" . ";; h") + ("untested.el" . ";; u")) + :report (cs-test--report '(("full.el" . "[1, 1]") + ("half.el" . "[1, 0]")))) + ;; full=100%, half=50%, untested missing=0% -> (100+50+0)/3 = 50.0 + (should (= 50.0 (cj/coverage-summary--project-pct report src root))))) + +(ert-deftest cs-project-pct-no-missing () + "With every file tracked, the number is the plain unit average." + (cs-test--with-project + (list :sources '(("full.el" . ";; f") ("half.el" . ";; h")) + :report (cs-test--report '(("full.el" . "[1, 1]") + ("half.el" . "[1, 0]")))) + ;; (100 + 50) / 2 = 75.0 + (should (= 75.0 (cj/coverage-summary--project-pct report src root))))) + +;; --- end-to-end text ------------------------------------------------------- + +(ert-deftest cs-text-reports-missing-and-project-number () + "The rendered summary names the missing file and the project percentage." + (cs-test--with-project + (list :sources '(("a.el" . ";; a") ("orphan.el" . ";; o")) + :report (cs-test--report '(("a.el" . "[1, 0]")))) + (let ((text (cj/coverage-summary-text report src root))) + (should (string-match-p "orphan\\.el" text)) + (should (string-match-p "count as 0%" text)) + ;; a.el = 50%, orphan missing = 0% -> 25.0% + (should (string-match-p "25\\.0%" text))))) + +(provide 'test-coverage-summary) +;;; test-coverage-summary.el ends here |
