From 95dbb5abdbb746cf5da9f7926740d17205ac8d55 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 6 Jun 2026 10:31:30 -0500 Subject: build: add Eask, test harness, and dev tooling I brought the skeleton up to a working package baseline (Phase 0 in the design spec). Eask defines the package and its dev deps. A root Makefile delegates test targets to tests/Makefile and adds compile, coverage, lint, doctor, and clean, matching the layout the other packages use. deps installs both halves DUET needs: the Emacs Lisp deps via eask, and the transport CLIs (rsync, rclone, lftp, unison) via the system package manager, so a contributor's environment is ready before the code that shells out to them. make complexity runs a small homegrown McCabe branch counter (scripts/duet-complexity.el). No off-the-shelf tool measures Emacs Lisp: lizard doesn't support it and codemetrics is an interactive overlay, so DUET owns one. The counting is pure and covered by Normal/Boundary/Error tests. The budget is soft and the target is advisory. The ERT harness (bootstrap, check-deps, per-file undercover coverage) and a smoke test prove the loop works end to end. --- .gitignore | 6 + Eask | 26 ++++ Makefile | 250 ++++++++++++++++++++++++++++++++++ docs/developer-guide.org | 52 +++++++ scripts/coverage-summary.el | 189 ++++++++++++++++++++++++++ scripts/duet-complexity.el | 165 +++++++++++++++++++++++ tests/.gitkeep | 0 tests/Makefile | 307 ++++++++++++++++++++++++++++++++++++++++++ tests/check-deps.el | 43 ++++++ tests/run-coverage-file.el | 50 +++++++ tests/test-bootstrap.el | 40 ++++++ tests/test-duet-complexity.el | 126 +++++++++++++++++ tests/test-duet-smoke.el | 36 +++++ 13 files changed, 1290 insertions(+) create mode 100644 Eask create mode 100644 Makefile create mode 100644 docs/developer-guide.org create mode 100644 scripts/coverage-summary.el create mode 100644 scripts/duet-complexity.el delete mode 100644 tests/.gitkeep create mode 100644 tests/Makefile create mode 100644 tests/check-deps.el create mode 100644 tests/run-coverage-file.el create mode 100644 tests/test-bootstrap.el create mode 100644 tests/test-duet-complexity.el create mode 100644 tests/test-duet-smoke.el diff --git a/.gitignore b/.gitignore index 715b879..b78f809 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,13 @@ *.elc *.eln /.eldev/ +/.eask/ /eln-cache/ +# Generated by eask +/duet-autoloads.el +/duet-pkg.el + # Emacs cruft *~ \#*\# @@ -12,6 +17,7 @@ # Coverage / test output /coverage/ /.coverage/ +tests/*-output.log # Local planning + tooling (kept out of the public repo). /.ai/ diff --git a/Eask b/Eask new file mode 100644 index 0000000..d7b8f52 --- /dev/null +++ b/Eask @@ -0,0 +1,26 @@ +;; -*- mode: eask; lexical-binding: t -*- + +(package "duet" + "0.1.0" + "Dual-pane file commander over dirvish/dired") + +(website-url "https://github.com/cjennings/duet") +(keywords "files" "tools" "convenience") + +(package-file "duet.el") + +(source 'gnu) +(source 'nongnu) +(source 'melpa) + +(depends-on "emacs" "29.1") + +;; dirvish is the recommended renderer but optional at runtime (DUET degrades +;; to plain dired), so it is not a hard package dependency. It is pulled in +;; as a development dependency once pane code lands (Phase 4); until then the +;; suite needs none of it. + +(development + (depends-on "elisp-lint") + (depends-on "package-lint") + (depends-on "undercover")) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9052026 --- /dev/null +++ b/Makefile @@ -0,0 +1,250 @@ +# Makefile for duet.el +# Test targets delegate to tests/Makefile. +# setup / deps / compile / coverage / complexity / doctor operate at project root. +# Run 'make help' for available commands. + +EASK ?= eask +EMACS_BATCH = $(EASK) emacs --batch +# Coverage / test loops need default-directory = tests/ so test files' +# relative paths (../duet.el, sibling test files) resolve as they do under +# tests/Makefile. +EMACS_BATCH_TESTS = $(EASK) emacs --batch --eval '(cd "tests/")' + +TEST_DIR = tests +SOURCE_FILE = duet.el + +# Transport CLIs DUET shells out to. rsync is the stage-1 default; rclone, +# lftp, and unison arrive in later stages but `deps' installs the full set so +# a contributor's environment is ready ahead of the code that uses them. +TRANSPORT_CLIS = rsync rclone lftp unison + +# Cyclomatic-complexity gate (scripts/duet-complexity.el). The budget is soft +# (the design spec allows a written justification past it); override per run +# with `make complexity COMPLEXITY_THRESHOLD=12'. +COMPLEXITY_THRESHOLD ?= 10 +COMPLEXITY_FILES = $(SOURCE_FILE) + +# Coverage configuration +COVERAGE_DIR = .coverage +COVERAGE_FILE = $(COVERAGE_DIR)/simplecov.json + +# Test-file list used by the coverage loop, mirroring tests/Makefile. +# Coverage runs every test file so the report represents the full suite. +ALL_TESTS = $(filter-out $(TEST_DIR)/test-bootstrap.el, \ + $(wildcard $(TEST_DIR)/test-*.el)) + +# Include local overrides if present (per-machine knobs, not committed) +-include makefile-local + +.PHONY: help test test-all test-unit test-integration test-file test-one test-name \ + count list validate lint check-deps clean clean-compiled clean-tests \ + setup deps deps-elisp deps-system compile coverage coverage-summary coverage-clean \ + complexity doctor test-live + +help: + @$(MAKE) -C $(TEST_DIR) help + +# Test target delegations +test: + @$(MAKE) -C $(TEST_DIR) test + +test-all: + @$(MAKE) -C $(TEST_DIR) test-all + +test-unit: + @$(MAKE) -C $(TEST_DIR) test-unit + +test-integration: + @$(MAKE) -C $(TEST_DIR) test-integration + +test-file: + @$(MAKE) -C $(TEST_DIR) test-file FILE="$(FILE)" + +test-one: + @$(MAKE) -C $(TEST_DIR) test-one TEST="$(TEST)" + +test-name: + @$(MAKE) -C $(TEST_DIR) test-name TEST="$(TEST)" + +count: + @$(MAKE) -C $(TEST_DIR) count + +list: + @$(MAKE) -C $(TEST_DIR) list + +validate: + @$(MAKE) -C $(TEST_DIR) validate + +lint: + @$(MAKE) -C $(TEST_DIR) lint + +check-deps: + @$(MAKE) -C $(TEST_DIR) check-deps + +# +# Dependencies +# + +# Install everything DUET needs: Emacs Lisp deps (via eask) plus the transport +# CLIs (via the system package manager). +deps: deps-elisp deps-system + +# Install runtime + development Emacs Lisp dependencies via eask. +deps-elisp setup: + @if ! command -v $(EASK) >/dev/null 2>&1; then \ + echo "[✗] eask not found on PATH"; \ + echo " Install: npm install -g @emacs-eask/cli"; \ + echo " Or: https://emacs-eask.github.io/Getting-Started/Install-Eask/"; \ + exit 1; \ + fi + @echo "[i] Installing Emacs Lisp dependencies via eask..." + @$(EASK) install-deps --dev + @echo "[✓] Emacs Lisp dependencies installed in .eask/" + +# Install the transport CLIs via the detected system package manager. pacman +# is checked first (the maintainer's environment); apt/dnf/zypper/brew follow. +# An unknown manager is not a failure — it prints the package list so the user +# can install by hand. +deps-system: + @echo "[i] Installing transport CLIs: $(TRANSPORT_CLIS)" + @if command -v pacman >/dev/null 2>&1; then \ + sudo pacman -S --needed $(TRANSPORT_CLIS); \ + elif command -v apt-get >/dev/null 2>&1; then \ + sudo apt-get install -y $(TRANSPORT_CLIS); \ + elif command -v dnf >/dev/null 2>&1; then \ + sudo dnf install -y $(TRANSPORT_CLIS); \ + elif command -v zypper >/dev/null 2>&1; then \ + sudo zypper install -y $(TRANSPORT_CLIS); \ + elif command -v brew >/dev/null 2>&1; then \ + brew install $(TRANSPORT_CLIS); \ + else \ + echo "[!] No supported package manager found."; \ + echo " Install these yourself: $(TRANSPORT_CLIS)"; \ + fi + +# Byte-compile duet.el — surfaces free-variable / unused-let / suspicious-call +# warnings that checkdoc and elisp-lint don't catch. byte-compile-error-on-warn +# is t so any warning fails the build. +compile: + @echo "[i] Byte-compiling $(SOURCE_FILE)..." + @$(EMACS_BATCH) \ + --eval "(progn \ + (setq byte-compile-error-on-warn t) \ + (batch-byte-compile))" $(SOURCE_FILE) + @echo "[✓] Compilation complete" + +# +# Cyclomatic complexity (scripts/duet-complexity.el) +# + +# Report per-function complexity and exit non-zero if any function exceeds the +# soft budget. Advisory: run on demand, not wired into `make test'. +complexity: + @echo "[i] Scanning complexity (budget = $(COMPLEXITY_THRESHOLD))..." + @emacs -Q --batch -L scripts -l duet-complexity \ + --eval "(duet-complexity-batch '($(foreach f,$(COMPLEXITY_FILES),\"$(f)\")) $(COMPLEXITY_THRESHOLD))" + +# +# Doctor — verify the runtime environment +# + +# Report which transport CLIs are present and confirm duet loads. Missing CLIs +# are warnings (rclone/lftp/unison land in later stages); a load failure is the +# only hard error. +doctor: + @echo "[i] DUET doctor" + @echo "Transport executables:" + @for c in $(TRANSPORT_CLIS); do \ + if command -v $$c >/dev/null 2>&1; then \ + printf " [✓] %-8s %s\n" "$$c" "$$(command -v $$c)"; \ + else \ + printf " [!] %-8s missing (install via 'make deps-system')\n" "$$c"; \ + fi; \ + done + @echo "Package load:" + @if $(EMACS_BATCH) -l $(SOURCE_FILE) --eval "(require 'duet)" >/dev/null 2>&1; then \ + echo " [✓] (require 'duet) succeeds"; \ + else \ + echo " [✗] duet failed to load"; \ + exit 1; \ + fi + +# Run env-gated live remote tests. Skipped unless DUET_LIVE_TESTS is set, since +# they need real remote hosts and credentials. +test-live: + @if [ -z "$$DUET_LIVE_TESTS" ]; then \ + echo "[i] DUET_LIVE_TESTS is unset — skipping live remote tests."; \ + echo " Set DUET_LIVE_TESTS=1 (and the host/path vars the tests document) to run them."; \ + else \ + $(MAKE) -C $(TEST_DIR) test-name TEST='live'; \ + fi + +# +# Coverage (undercover + simplecov JSON) +# +# Each test file runs in its own Emacs process (matching test-unit); +# tests/run-coverage-file.el instruments duet.el before the source is loaded, +# and undercover merges per-file results into a single simplecov JSON. + +coverage: coverage-clean $(COVERAGE_DIR) + @echo "[i] Cleaning .elc files so undercover can instrument source..." + @find . -name "*.elc" -delete + @echo "[i] Running coverage across $(words $(ALL_TESTS)) test file(s)..." + @echo " (slower than 'make test' — each file runs in its own Emacs)" + @failed=0; \ + for test in $(ALL_TESTS); do \ + echo " Coverage: $$test..."; \ + testfile=$$(basename $$test); \ + $(EMACS_BATCH_TESTS) \ + -l ert \ + -l run-coverage-file.el \ + -l ../$(SOURCE_FILE) \ + -l $$testfile \ + --eval "(ert-run-tests-batch-and-exit t)" || failed=$$((failed + 1)); \ + done; \ + if [ $$failed -gt 0 ]; then \ + echo "[!] $$failed test file(s) failed during coverage run"; \ + exit 1; \ + fi + @coverage_file="$${COVERAGE_FILE_ACTUAL:-$(COVERAGE_FILE)}"; \ + [ -n "$$CI" ] && coverage_file="$(COVERAGE_DIR)/coveralls.json"; \ + 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 + @# Print the human-readable summary after a local run. CI emits + @# coveralls.json (not simplecov.json) and the upload action reports + @# instead, so skip the terminal summary there. + @if [ -z "$$CI" ] && [ -f $(COVERAGE_FILE) ]; then \ + $(MAKE) --no-print-directory coverage-summary; \ + fi + +# Print a human-readable summary of the SimpleCov report. +coverage-summary: + @if [ ! -f $(COVERAGE_FILE) ]; then \ + echo "[!] No coverage report at $(COVERAGE_FILE). Run 'make coverage' first."; \ + exit 1; \ + fi + @$(EMACS_BATCH) -L scripts -l coverage-summary \ + --eval '(duet-coverage-print-summary "$(COVERAGE_FILE)" (list "$(SOURCE_FILE)") "$(CURDIR)")' + +coverage-clean: + @rm -f $(COVERAGE_FILE) + +$(COVERAGE_DIR): + @mkdir -p $(COVERAGE_DIR) + +# +# Cleaning +# + +clean: clean-compiled clean-tests + @rm -rf $(COVERAGE_DIR) + +clean-compiled: + @rm -f *.elc scripts/*.elc + +clean-tests: + @$(MAKE) -C $(TEST_DIR) clean diff --git a/docs/developer-guide.org b/docs/developer-guide.org new file mode 100644 index 0000000..df3199f --- /dev/null +++ b/docs/developer-guide.org @@ -0,0 +1,52 @@ +#+TITLE: DUET Developer Guide +#+AUTHOR: Craig Jennings + +* Status + +Stub. Fills out across the implementation phases; the backend API and +transfer-spec contract land with Phase 2/3, the full release checklist with +Phase 10. The authoritative design is [[file:design/duet-spec.org][docs/design/duet-spec.org]]. + +* Repository layout + +- =duet.el= — the package. Public =duet-= commands and pure =duet--= helpers. + The only file that ships (Eask =package-file=). +- =tests/= — ERT suites, one =test-duet-.el= per source area, plus the + shared harness (=test-bootstrap.el=, =check-deps.el=, =run-coverage-file.el=). +- =scripts/= — developer tooling, not part of the package: =coverage-summary.el= + (terminal coverage report) and =duet-complexity.el= (the McCabe gate). +- =docs/= — this guide and the design spec. + +* Building and testing + +DUET uses [[https://emacs-eask.github.io/][Eask]] for dependency management and a Makefile for the common +loops. From the project root: + +#+begin_src shell +make deps # Emacs Lisp deps (eask) + transport CLIs (rsync/rclone/lftp/unison) +make setup # Emacs Lisp deps only +make test # full ERT suite (excludes :slow) +make test-file FILE=complexity # one file +make test-name TEST=pattern # ERT name selector +make compile # byte-compile, warnings-as-errors +make lint # elisp-lint +make coverage # undercover + simplecov JSON, then a terminal summary +make complexity # cyclomatic-complexity report, gated on the soft budget +make doctor # transport executables present + duet loads +make test-live # env-gated live remote tests (DUET_LIVE_TESTS) +#+end_src + +* Complexity budget + +=make complexity= runs =scripts/duet-complexity.el=, a homegrown McCabe branch +counter (no off-the-shelf tool measures Emacs Lisp). The budget is soft: a +function above the threshold (default 10) is split or carries a written +justification. The target is advisory — run it on demand; it is not part of +=make test=. + +* Backend API + +To be written with Phase 2. The published seam is =duet-register-backend=; the +stable surface is the classified-endpoint plist, the =duet-backend= struct, the +=duet-transfer-spec= plist, and the transfer-event payload. See the spec's +"Backend extension API and developer contract". diff --git a/scripts/coverage-summary.el b/scripts/coverage-summary.el new file mode 100644 index 0000000..51ddda9 --- /dev/null +++ b/scripts/coverage-summary.el @@ -0,0 +1,189 @@ +;;; coverage-summary.el --- Terminal coverage summary for duet -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;; Author: Craig Jennings + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see . + +;;; Commentary: + +;; Batch helper behind `make coverage-summary'. Parses the SimpleCov JSON that +;; `make coverage' writes and prints a terminal summary instead of just the +;; report's file size: +;; +;; - per tracked source file: covered/total executable lines and a percent, +;; worst-covered first; +;; - a line-weighted project figure over files present in the report; +;; - a source-weighted figure where a tracked source missing from the report +;; counts as 0% rather than being silently dropped; and +;; - an explicit list of tracked sources absent from the report. +;; +;; The missing-source handling is the robustness win: a source that never got +;; instrumented shows as 0% instead of vanishing from the numbers. DUET ships +;; a single source file today, so this is generalized over a list of tracked +;; sources and stays self-contained. + +;;; Code: + +(require 'json) +(require 'seq) +(require 'subr-x) + +(defun duet-coverage--read-json (file) + "Parse FILE as SimpleCov JSON and return the decoded hash table. +Signal `user-error' when FILE is missing or the JSON is malformed." + (unless (file-exists-p file) + (user-error "Coverage report not found: %s (run 'make coverage' first)" file)) + (let ((json-object-type 'hash-table) + (json-array-type 'list) + (json-key-type 'string)) + (condition-case err + (json-read-file file) + (error (user-error "Malformed coverage JSON in %s: %s" + file (error-message-string err)))))) + +(defun duet-coverage--collect (file predicate) + "Return path -> (line -> t) hash for lines in FILE matching PREDICATE. +PREDICATE receives each line's SimpleCov hits entry (a number, or nil for a +non-executable line). Coverage is unioned across every top-level test-name key +so undercover's `:merge-report' output accumulates correctly. A path is +recorded even when no line matches, so callers can tell \"present but empty\" +from \"absent\"." + (let ((data (duet-coverage--read-json file)) + (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 (funcall predicate hits) + (puthash line-num t lines)) + (setq line-num (1+ line-num))) + (puthash path lines result))) + coverage))))) + data) + result)) + +(defun duet-coverage--parse-simplecov (file) + "Return path -> (line -> t) hash for HIT lines (hits > 0) in FILE." + (duet-coverage--collect file (lambda (h) (and (numberp h) (> h 0))))) + +(defun duet-coverage--executable-lines (file) + "Return path -> (line -> t) hash for EXECUTABLE lines in FILE. +Executable means the SimpleCov entry is a number (hit or unhit); null entries +\(blank lines, comments) are excluded." + (duet-coverage--collect file #'numberp)) + +(defun duet-coverage--line-count (table path) + "Return the number of recorded lines for PATH in TABLE, or 0 when absent." + (let ((lines (gethash path table))) + (if (hash-table-p lines) (hash-table-count lines) 0))) + +(defun duet-coverage--find-key (table abs) + "Return the key in TABLE matching ABS, or nil. +Matches by expanded path first, then by basename, so a report whose paths are +written in a slightly different but equivalent form still resolves." + (let ((want-base (file-name-nondirectory abs)) + (found nil)) + (catch 'done + (maphash (lambda (k _v) + (when (or (string= (expand-file-name k) abs) + (string= (file-name-nondirectory k) want-base)) + (setq found k) + (throw 'done k))) + table)) + found)) + +(defun duet-coverage--records (report-file source-files project-root) + "Return per-file coverage records for SOURCE-FILES from REPORT-FILE. +SOURCE-FILES are paths relative to PROJECT-ROOT. Each record is a plist: + + (:path REL :covered N :total N :present BOOL :percent FLOAT) + +A source absent from the report has :present nil, :covered 0, :total 0, and +:percent 0.0 — it counts as 0%, not 100%. A present source with no executable +lines is 100% (nothing left to cover)." + (let ((covered (duet-coverage--parse-simplecov report-file)) + (executable (duet-coverage--executable-lines report-file)) + (root (file-name-as-directory (expand-file-name project-root)))) + (mapcar + (lambda (rel) + (let* ((abs (expand-file-name rel root)) + (key (duet-coverage--find-key executable abs)) + (present (and key t)) + (total (if key (duet-coverage--line-count executable key) 0)) + (cov (if key (duet-coverage--line-count covered key) 0)) + (percent (cond ((not present) 0.0) + ((zerop total) 100.0) + (t (/ (* 100.0 cov) total))))) + (list :path rel :covered cov :total total + :present present :percent percent))) + source-files))) + +(defun duet-coverage-summary-text (report-file source-files project-root) + "Return a coverage summary string for SOURCE-FILES from REPORT-FILE. +SOURCE-FILES are paths relative to PROJECT-ROOT." + (let* ((records (duet-coverage--records report-file source-files project-root)) + (present (seq-filter (lambda (r) (plist-get r :present)) records)) + (missing (seq-remove (lambda (r) (plist-get r :present)) records)) + (total-cov (apply #'+ (mapcar (lambda (r) (plist-get r :covered)) present))) + (total-lines (apply #'+ (mapcar (lambda (r) (plist-get r :total)) present))) + (line-pct (if (> total-lines 0) (/ (* 100.0 total-cov) total-lines) 100.0)) + (file-score (apply #'+ (mapcar (lambda (r) (plist-get r :percent)) records))) + (file-pct (if records (/ file-score (length records)) 0.0)) + (sorted (sort (copy-sequence records) + (lambda (a b) (< (plist-get a :percent) (plist-get b :percent))))) + (width (apply #'max 8 (mapcar (lambda (r) (length (plist-get r :path))) records))) + (row-format (format " %%-%ds %%5d/%%-5d (%%5.1f%%%%)\n" width)) + (miss-format (format " %%-%ds not in report (counts as 0%%%%)\n" width))) + (with-temp-buffer + (insert "Coverage Summary\n================\n\n") + (insert "Per-file coverage (worst first):\n") + (dolist (r sorted) + (if (plist-get r :present) + (insert (format row-format + (plist-get r :path) + (plist-get r :covered) + (plist-get r :total) + (plist-get r :percent))) + (insert (format miss-format (plist-get r :path))))) + (insert "\n") + (insert (format "Line coverage: %d/%d lines (%.1f%%) across %d file%s in report\n" + total-cov total-lines line-pct (length present) + (if (= 1 (length present)) "" "s"))) + (insert (format "Source coverage: %.1f%% (%d tracked, %d missing; missing counts as 0%%)\n" + file-pct (length present) (length missing))) + (when missing + (insert (format "\nNot in coverage report: %d source%s\n" + (length missing) (if (= 1 (length missing)) "" "s"))) + (dolist (r missing) + (insert (format " %s\n" (plist-get r :path))))) + (buffer-string)))) + +(defun duet-coverage-print-summary (report-file source-files project-root) + "Print the coverage summary for SOURCE-FILES to standard output. +SOURCE-FILES are paths relative to PROJECT-ROOT. Entry point for +`make coverage-summary'." + (princ "\n") + (princ (duet-coverage-summary-text report-file source-files project-root))) + +(provide 'duet-coverage-summary) +;;; coverage-summary.el ends here diff --git a/scripts/duet-complexity.el b/scripts/duet-complexity.el new file mode 100644 index 0000000..63d59cf --- /dev/null +++ b/scripts/duet-complexity.el @@ -0,0 +1,165 @@ +;;; duet-complexity.el --- McCabe complexity scanner for duet -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;; Author: Craig Jennings + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see . + +;;; Commentary: + +;; A small, dependency-free cyclomatic-complexity scanner behind +;; `make complexity'. No off-the-shelf tool measures Emacs Lisp (lizard does +;; not support it; codemetrics is an interactive tree-sitter overlay, a poor +;; fit for a headless gate), so DUET owns this one. +;; +;; The metric is McCabe's: a function's complexity is 1 plus the number of +;; decision points in its body. A decision point is a branch-introducing form: +;; +;; - `if' / `when' / `unless' +1 each +;; - `while' / `dolist' / `dotimes' / `cl-loop' +1 each (and cl- variants) +;; - `cond' +1 per clause +;; - `pcase' / `pcase-exhaustive' +1 per clause +;; - `cl-case' / `case' / `cl-typecase' (+ e-) +1 per clause +;; - `condition-case' +1 per handler +;; - `and' / `or' +1 per operand beyond the first +;; +;; The counting is pure — it operates on already-read forms — so it is fully +;; unit-testable without I/O. Only `duet-complexity-scan-file' touches disk. +;; +;; Known limitations (acceptable for an advisory soft-budget gate): an inline +;; lambda's complexity is attributed to its enclosing function rather than +;; counted separately, and backquoted templates are walked as ordinary code. +;; The budget is soft (the spec allows a written justification past ~8-10), so +;; the bias toward flagging is deliberate. + +;;; Code: + +(require 'seq) + +(defconst duet-complexity-default-threshold 10 + "Default soft cyclomatic-complexity budget. +A function above this is split or carries a written justification (see the +design spec's \"Complexity and refactoring controls\").") + +(defconst duet-complexity-defun-heads + '(defun defmacro defsubst cl-defun cl-defmacro cl-defsubst) + "Top-level forms the scanner measures as functions.") + +(defun duet-complexity--clause-rest (clauses) + "Return every CLAUSE's `cdr' appended, skipping each clause head. +Used where a clause head is data, not executable code: `pcase' patterns, +`cl-case' key lists, and `condition-case' handler condition names. Skipping +the head avoids miscounting a `pcase' `or'/`and' pattern as a boolean form." + (apply #'append + (mapcar (lambda (c) (and (consp c) (copy-sequence (cdr c)))) clauses))) + +(defun duet-complexity--clause-all (clauses) + "Return every element of each CLAUSE in CLAUSES appended. +Used for `cond', where a clause's condition is itself executable code that may +contain nested decisions." + (apply #'append + (mapcar (lambda (c) (and (consp c) (copy-sequence c))) clauses))) + +(defun duet-complexity--sum (forms) + "Return the total decision points across FORMS." + (apply #'+ (mapcar #'duet-complexity--decision-points forms))) + +(defun duet-complexity--decision-points (form) + "Return the number of McCabe decision points in FORM, recursively." + (if (not (consp form)) + 0 + (let ((head (car form))) + (cond + ((eq head 'quote) 0) + ((memq head '(if when unless while dolist dotimes + cl-dolist cl-dotimes cl-loop)) + (+ 1 (duet-complexity--sum (cdr form)))) + ((memq head '(and or)) + (+ (max 0 (1- (length (cdr form)))) + (duet-complexity--sum (cdr form)))) + ((eq head 'cond) + (+ (length (cdr form)) + (duet-complexity--sum (duet-complexity--clause-all (cdr form))))) + ((memq head '(pcase pcase-exhaustive + cl-case case cl-ecase cl-typecase cl-etypecase)) + (+ (length (cddr form)) + (duet-complexity--decision-points (cadr form)) + (duet-complexity--sum (duet-complexity--clause-rest (cddr form))))) + ((eq head 'condition-case) + (+ (length (cdddr form)) + (duet-complexity--decision-points (caddr form)) + (duet-complexity--sum (duet-complexity--clause-rest (cdddr form))))) + (t + (duet-complexity--sum (cdr form))))))) + +(defun duet-complexity-of-form (form) + "Return the cyclomatic complexity of defun-like FORM. +FORM is a `(HEAD NAME ARGLIST . BODY)' list; the result is 1 plus the decision +points in its body." + (1+ (duet-complexity--sum (nthcdr 3 form)))) + +(defun duet-complexity--read-forms (file) + "Return the list of top-level forms read from FILE." + (with-temp-buffer + (insert-file-contents file) + (goto-char (point-min)) + (let (forms) + (condition-case nil + (while t (push (read (current-buffer)) forms)) + (end-of-file (nreverse forms)))))) + +(defun duet-complexity-scan-file (file) + "Return `(NAME . COMPLEXITY)' pairs for each defun-like form in FILE." + (let (results) + (dolist (form (duet-complexity--read-forms file) (nreverse results)) + (when (and (consp form) + (memq (car form) duet-complexity-defun-heads) + (symbolp (nth 1 form))) + (push (cons (nth 1 form) (duet-complexity-of-form form)) results))))) + +(defun duet-complexity-over-threshold (results threshold) + "Return RESULTS entries whose complexity exceeds THRESHOLD." + (seq-filter (lambda (r) (> (cdr r) threshold)) results)) + +(defun duet-complexity-batch (files threshold) + "Scan FILES and print per-function complexity to standard output. +Exit non-zero when any function's complexity exceeds THRESHOLD. Entry point +for `make complexity'." + (let ((offenders nil) + (scanned 0)) + (dolist (file files) + (let ((results (duet-complexity-scan-file file))) + (princ (format "\n%s\n" file)) + (dolist (r (sort (copy-sequence results) + (lambda (a b) (> (cdr a) (cdr b))))) + (setq scanned (1+ scanned)) + (princ (format " %3d %s%s\n" + (cdr r) (car r) + (if (> (cdr r) threshold) " <-- over budget" "")))) + (setq offenders + (append offenders + (mapcar (lambda (r) (cons file r)) + (duet-complexity-over-threshold results threshold)))))) + (princ (format "\nScanned %d function(s); budget = %d.\n" scanned threshold)) + (if offenders + (progn + (princ (format "%d function(s) over budget:\n" (length offenders))) + (dolist (o offenders) + (princ (format " %s: %s (%d)\n" (car o) (cadr o) (cddr o)))) + (kill-emacs 1)) + (princ "All functions within budget.\n")))) + +(provide 'duet-complexity) +;;; duet-complexity.el ends here diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/Makefile b/tests/Makefile new file mode 100644 index 0000000..9d2f89d --- /dev/null +++ b/tests/Makefile @@ -0,0 +1,307 @@ +# Makefile for duet.el test suite +# Usage: +# make test - Run all tests (excluding :slow tagged) +# make test-all - Run every test, including :slow tagged +# make test-file FILE=complexity - Run tests in one file +# make test-one TEST=name - Run one specific test +# make test-unit - Run unit tests only +# make test-integration - Run integration tests only +# make clean - Remove byte-compiled files + +# Configuration +EASK ?= eask + +# eask treats the CWD as its workspace and reads .eask/ from there. All eask +# invocations must run from project root so the project's .eask/ is picked up. +# The (cd "tests/") --eval restores Emacs default-directory so test files' +# relative paths (../duet.el, test-bootstrap.el, ../scripts/) resolve correctly. +PROJECT_ROOT := $(abspath ..) +EMACS_BATCH = cd $(PROJECT_ROOT) && $(EASK) emacs --batch --eval '(cd "tests/")' + +# Include local overrides if present (per-machine knobs, not committed) +-include makefile-local + +# Test files +ALL_TESTS = $(filter-out test-bootstrap.el,$(wildcard test-*.el)) +UNIT_TESTS = $(filter-out test-integration-%.el,$(ALL_TESTS)) +INTEGRATION_TESTS = $(wildcard test-integration-*.el) + +# ERT selector that excludes tests tagged :slow. Applied to default test runs +# so a slow integration suite doesn't dominate the fast feedback path. +# test-all runs everything; test-one and test-name honor the user pattern. +ERT_FAST_SELECTOR = (ert-run-tests-batch-and-exit '(not (tag :slow))) + +# Colors for output (if terminal supports it) +RED = \033[0;31m +GREEN = \033[0;32m +YELLOW = \033[1;33m +NC = \033[0m + +.PHONY: all test test-all test-file test-one test-name test-unit test-integration \ + validate lint clean help check-deps count list + +all: test + +# Verify eask + installed deps are available +check-deps: + @if ! command -v $(EASK) >/dev/null 2>&1; then \ + printf "$(RED)Error: eask not found on PATH$(NC)\n"; \ + echo "Install: npm install -g @emacs-eask/cli"; \ + echo " or: https://emacs-eask.github.io/Getting-Started/Install-Eask/"; \ + exit 1; \ + fi + @if [ ! -d $(PROJECT_ROOT)/.eask ]; then \ + printf "$(YELLOW)Warning: .eask not found — run 'make setup' from project root$(NC)\n"; \ + exit 1; \ + fi + @$(EMACS_BATCH) -l check-deps.el >$(PROJECT_ROOT)/tests/check-deps-output.log 2>&1 || { \ + printf "$(RED)Error: required Emacs Lisp test dependencies are missing$(NC)\n"; \ + cat $(PROJECT_ROOT)/tests/check-deps-output.log; \ + exit 1; \ + } + @printf "$(GREEN)✓ eask available, required Emacs Lisp deps loadable$(NC)\n" + +# Run all tests (excluding :slow) +test: check-deps + @printf "$(YELLOW)Running all tests ($(words $(ALL_TESTS)) files, excluding :slow)...$(NC)\n" + @$(MAKE) --no-print-directory test-unit + @$(MAKE) --no-print-directory test-integration + @printf "$(GREEN)[✓] All tests complete$(NC)\n" + +# Run every test, including :slow tagged +test-all: check-deps + @printf "$(YELLOW)Running all tests including :slow ($(words $(ALL_TESTS)) files)...$(NC)\n" + @failed=0; \ + for testfile in $(ALL_TESTS); do \ + echo " Testing $$testfile..."; \ + $(EMACS_BATCH) -l ert -l "$$testfile" \ + --eval '(ert-run-tests-batch-and-exit t)' || failed=$$((failed + 1)); \ + done; \ + if [ $$failed -eq 0 ]; then \ + printf "$(GREEN)[✓] All tests passed$(NC)\n"; \ + else \ + printf "$(RED)[✗] $$failed test file(s) failed$(NC)\n"; \ + exit 1; \ + fi + +# Run tests in one file +test-file: check-deps +ifndef FILE + @printf "$(RED)Error: FILE not specified$(NC)\n" + @echo "Usage: make test-file FILE=complexity" + @echo " make test-file FILE=test-duet-complexity.el" + @exit 1 +endif + @TESTFILE=$$( \ + if [ -f "$(FILE)" ]; then echo "$(FILE)"; \ + elif [ -f "$(FILE).el" ]; then echo "$(FILE).el"; \ + else find . -maxdepth 1 -name "*$(FILE)*.el" -type f | sed 's|^\./||'; fi); \ + if [ -z "$$TESTFILE" ]; then \ + printf "$(RED)Error: No test file matching '$(FILE)' found$(NC)\n"; \ + exit 1; \ + fi; \ + if [ "$$(printf '%s\n' "$$TESTFILE" | grep -c .)" -gt 1 ]; then \ + printf "$(RED)Error: '$(FILE)' matches multiple files; pass the exact name (e.g. $(FILE).el):$(NC)\n"; \ + printf '%s\n' "$$TESTFILE" | sed 's|^| |'; \ + exit 1; \ + fi; \ + printf "$(YELLOW)Running tests in $$TESTFILE...$(NC)\n"; \ + $(EMACS_BATCH) -l ert -l "$$TESTFILE" \ + --eval "$(ERT_FAST_SELECTOR)" 2>&1 | tee $(PROJECT_ROOT)/tests/test-file-output.log; \ + if [ $$? -eq 0 ]; then \ + printf "$(GREEN)✓ All tests in $$TESTFILE passed!$(NC)\n"; \ + else \ + printf "$(RED)✗ Some tests failed.$(NC)\n"; \ + exit 1; \ + fi + +# Run one specific test (fuzzy match by name) +test-one: check-deps +ifndef TEST + @printf "$(RED)Error: TEST not specified$(NC)\n" + @echo "Usage: make test-one TEST=cond" + @echo " make test-one TEST=test-duet-complexity-cond-one-per-clause" + @exit 1 +endif + @printf "$(YELLOW)Searching for test matching '$(TEST)'...$(NC)\n" + @TESTFILE=$$(grep -l "ert-deftest.*$(TEST)" test-*.el 2>/dev/null | head -1); \ + if [ -z "$$TESTFILE" ]; then \ + printf "$(RED)Error: No test matching '$(TEST)' found$(NC)\n"; \ + exit 1; \ + fi; \ + TESTNAME=$$(grep "ert-deftest.*$(TEST)" "$$TESTFILE" | sed 's/^(ert-deftest \([^ ]*\).*/\1/' | head -1); \ + printf "$(YELLOW)Running test '$$TESTNAME' in $$TESTFILE...$(NC)\n"; \ + $(EMACS_BATCH) -l ert -l "$$TESTFILE" \ + --eval "(ert-run-tests-batch-and-exit \"$$TESTNAME\")" 2>&1; \ + if [ $$? -eq 0 ]; then \ + printf "$(GREEN)✓ Test $$TESTNAME passed!$(NC)\n"; \ + else \ + printf "$(RED)✗ Test $$TESTNAME failed.$(NC)\n"; \ + exit 1; \ + fi + +# Run only unit tests (excluding :slow) +test-unit: check-deps + @printf "$(YELLOW)Running unit tests ($(words $(UNIT_TESTS)) files, excluding :slow)...$(NC)\n" + @failed=0; \ + for testfile in $(UNIT_TESTS); do \ + echo " Testing $$testfile..."; \ + $(EMACS_BATCH) -l ert -l "$$testfile" \ + --eval "$(ERT_FAST_SELECTOR)" || failed=$$((failed + 1)); \ + done; \ + if [ $$failed -eq 0 ]; then \ + printf "$(GREEN)[✓] All unit tests passed$(NC)\n"; \ + else \ + printf "$(RED)[✗] $$failed unit test file(s) failed$(NC)\n"; \ + exit 1; \ + fi + +# Run only integration tests (excluding :slow) +test-integration: check-deps + @printf "$(YELLOW)Running integration tests ($(words $(INTEGRATION_TESTS)) files, excluding :slow)...$(NC)\n" + @if [ -z "$(INTEGRATION_TESTS)" ]; then \ + printf "$(YELLOW) (no integration test files yet)$(NC)\n"; \ + fi + @failed=0; \ + for testfile in $(INTEGRATION_TESTS); do \ + echo " Testing $$testfile..."; \ + $(EMACS_BATCH) -l ert -l "$$testfile" \ + --eval "$(ERT_FAST_SELECTOR)" || failed=$$((failed + 1)); \ + done; \ + if [ $$failed -eq 0 ]; then \ + printf "$(GREEN)[✓] All integration tests passed$(NC)\n"; \ + else \ + printf "$(RED)[✗] $$failed integration test file(s) failed$(NC)\n"; \ + exit 1; \ + fi + +# Run tests matching a name pattern (ERT selector) +test-name: check-deps +ifndef TEST + @printf "$(RED)Error: TEST not specified$(NC)\n" + @echo "Usage: make test-name TEST=test-duet-complexity" + @echo " make test-name TEST='test-duet-complexity-.*'" + @exit 1 +endif + @printf "$(YELLOW)Running tests matching pattern: $(TEST)...$(NC)\n" + @$(EMACS_BATCH) -l ert \ + --eval "(dolist (f (directory-files \".\" t \"^test-.*\\\\.el$$\")) (load f))" \ + --eval '(ert-run-tests-batch-and-exit "$(TEST)")' + +# Count tests +count: + @echo "Test file inventory:" + @for f in $(ALL_TESTS); do \ + count=$$(grep -c "^(ert-deftest" "$$f" 2>/dev/null || echo 0); \ + printf "%3d tests - %s\n" "$$count" "$$f"; \ + done | sort -rn + @total=$$(find . -name "test-*.el" -exec grep -c "^(ert-deftest" {} \; | awk '{sum+=$$1} END {print sum}'); \ + printf "$(GREEN)Total: $$total tests across $(words $(ALL_TESTS)) files$(NC)\n" + +# List all available tests +list: + @echo "Available tests:" + @grep -h "^(ert-deftest" test-*.el | sed 's/^(ert-deftest \([^ ]*\).*/ \1/' | sort + +# Validate Emacs Lisp syntax (parens balance — no deps needed) +validate: + @printf "$(YELLOW)Validating Emacs Lisp syntax...$(NC)\n" + @failed=0; \ + total=0; \ + for file in ../duet.el test-*.el; do \ + if [ -f "$$file" ] && [ ! -d "$$file" ]; then \ + total=$$((total + 1)); \ + output=$$(emacs --batch -Q --eval "(progn \ + (setq byte-compile-error-on-warn nil) \ + (find-file \"$$file\") \ + (condition-case err \ + (progn \ + (check-parens) \ + (message \"✓ $$file - parentheses balanced\")) \ + (error \ + (message \"✗ $$file: %s\" (error-message-string err)) \ + (kill-emacs 1))))" 2>&1 | grep -E '(✓|✗)'); \ + if [ $$? -eq 0 ]; then \ + printf "$(GREEN)$$output$(NC)\n"; \ + else \ + printf "$(RED)$$output$(NC)\n"; \ + failed=$$((failed + 1)); \ + fi; \ + fi; \ + done; \ + if [ $$failed -eq 0 ]; then \ + printf "$(GREEN)✓ All $$total files validated successfully$(NC)\n"; \ + else \ + printf "$(RED)✗ $$failed of $$total files failed validation$(NC)\n"; \ + exit 1; \ + fi + +# Comprehensive linting with elisp-lint (via eask-installed dev dep). +# Validators disabled and why: +# - checkdoc: covered by `eask lint checkdoc' as its own MELPA-prep step. +# - package-lint: covered by `eask lint package' as its own step. +# - indent-character: project uses spaces; validator defaults to requiring tabs. +# - fill-column: validator default (70) is stricter than this project wants. +# - indent: false positives on threading / alignment. +lint: check-deps + @printf "$(YELLOW)Running elisp-lint...$(NC)\n" + @$(EMACS_BATCH) \ + -l $(PROJECT_ROOT)/duet.el \ + --eval "(require 'elisp-lint)" \ + -f elisp-lint-files-batch \ + --no-checkdoc \ + --no-package-lint \ + --no-indent-character \ + --no-fill-column \ + --no-indent \ + $(PROJECT_ROOT)/duet.el 2>&1; \ + if [ $$? -eq 0 ]; then \ + printf "$(GREEN)✓ Linting completed successfully$(NC)\n"; \ + else \ + printf "$(RED)✗ Linting found issues (see above)$(NC)\n"; \ + exit 1; \ + fi + +# Clean byte-compiled files +clean: + @printf "$(YELLOW)Cleaning byte-compiled files...$(NC)\n" + @rm -f *.elc ../*.elc ../scripts/*.elc + @rm -f check-deps-output.log test-output.log test-file-output.log test-unit-output.log test-integration-output.log + @printf "$(GREEN)✓ Cleaned$(NC)\n" + +# Show help +help: + @echo "duet Test Suite Makefile" + @echo "" + @echo "Usage:" + @echo " make test - Run all tests, excluding :slow" + @echo " make test-all - Run all tests including :slow" + @echo " make test-unit - Run unit tests only (excluding :slow)" + @echo " make test-integration - Run integration tests only (excluding :slow)" + @echo " make test-file FILE=complexity - Run tests in one file (fuzzy match)" + @echo " make test-one TEST=cond - Run one specific test (fuzzy match)" + @echo " make test-name TEST=pattern - Run tests matching ERT name pattern" + @echo " make validate - Validate Emacs Lisp syntax (parens balance)" + @echo " make lint - Comprehensive linting with elisp-lint" + @echo " make count - Count tests per file" + @echo " make list - List all test names" + @echo " make clean - Remove byte-compiled files and logs" + @echo " make check-deps - Verify eask + loadable Emacs Lisp deps" + @echo " make help - Show this help message" + @echo "" + @echo "Project-root targets (run from project root):" + @echo " make deps - Install Emacs + system (rsync/rclone/lftp/unison) deps" + @echo " make setup - Install Emacs Lisp deps via eask" + @echo " make compile - Byte-compile duet.el" + @echo " make coverage - Generate simplecov JSON via undercover (+ summary)" + @echo " make coverage-summary - Print covered/total + percent from the last report" + @echo " make complexity - Report cyclomatic complexity, gate on the soft budget" + @echo " make doctor - Check transport executables + that duet loads" + @echo " make test-live - Run env-gated live remote tests (DUET_LIVE_TESTS)" + @echo "" + @echo "Tagging tests as :slow:" + @echo " (ert-deftest test-foo () :tags '(:slow) ...) — excluded by default" + @echo " Run with 'make test-all' to include them." + @echo "" + @echo "Environment variables:" + @echo " EASK - eask executable (default: eask)" diff --git a/tests/check-deps.el b/tests/check-deps.el new file mode 100644 index 0000000..55aaae6 --- /dev/null +++ b/tests/check-deps.el @@ -0,0 +1,43 @@ +;;; check-deps.el --- Verify test dependencies are loadable -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;;; Commentary: + +;; Loaded by tests/Makefile's check-deps target after eask has prepared the +;; test environment. Keep dependency discovery inside Emacs so package.el, +;; package-vc, Eask, Nix, and pre-populated load-path setups all work the same +;; way: a dependency is available if Emacs can require it. +;; +;; DUET has no third-party runtime dependencies yet; cl-lib is the one feature +;; the core leans on (cl-defstruct lands in Phase 2). The dev tooling +;; (undercover, elisp-lint, package-lint) is verified by the targets that use +;; it, not here. + +;;; Code: + +(when noninteractive + (package-initialize)) + +(defconst duet-check-deps-required-features + '(cl-lib) + "Features required by the duet test suite.") + +(defun duet-check-deps--missing-features () + "Return required test features that cannot be loaded." + (let (missing) + (dolist (feature duet-check-deps-required-features (nreverse missing)) + (unless (require feature nil t) + (push feature missing))))) + +(let ((missing (duet-check-deps--missing-features))) + (if missing + (progn + (message "Missing Emacs Lisp test dependencies: %s" + (mapconcat #'symbol-name missing ", ")) + (message "Run `make setup' from the project root, or make these features available on load-path.") + (kill-emacs 1)) + (message "Required Emacs Lisp dependencies are loadable: %s" + (mapconcat #'symbol-name duet-check-deps-required-features ", ")))) + +;;; check-deps.el ends here diff --git a/tests/run-coverage-file.el b/tests/run-coverage-file.el new file mode 100644 index 0000000..550fc90 --- /dev/null +++ b/tests/run-coverage-file.el @@ -0,0 +1,50 @@ +;;; run-coverage-file.el --- Undercover setup for per-file coverage runs -*- lexical-binding: t; -*- + +;;; Commentary: +;; Loaded by `make coverage' before each test file runs, BEFORE duet.el is +;; loaded. Instrumenting must happen first so the subsequent load picks up the +;; instrumented source. +;; +;; Coverage data is merged across per-file invocations into a single simplecov +;; JSON at .coverage/simplecov.json (under the project root). + +;;; Code: + +(unless (require 'undercover nil t) + (message "") + (message "ERROR: undercover not installed.") + (message "Run 'make setup' to install development dependencies.") + (message "") + (kill-emacs 1)) + +;; Resolve project root from this file's location so undercover patterns and +;; the report-file path don't depend on default-directory at load time. +(defvar run-coverage--project-root + (file-name-directory + (directory-file-name + (file-name-directory (or load-file-name buffer-file-name)))) + "Absolute path to the duet project root.") + +;; Force coverage collection in non-CI environments. Must be set after loading +;; undercover because the library's top-level form +;; `(setq undercover-force-coverage (getenv "UNDERCOVER_FORCE"))' would +;; otherwise overwrite the value. +(setq undercover-force-coverage t) + +;; Local runs emit simplecov for whatever local tooling wants it. CI sets +;; CI=true (GitHub Actions does this automatically), so we emit a coveralls +;; JSON instead and leave it on disk for the upload action to pick up. The +;; `undercover' macro splices each configuration list into `(list ,@it)', which +;; evaluates the elements. Wildcard strings have to stay atoms — using the +;; `(:files ...)' form lets us evaluate `expand-file-name' to an absolute path. +(undercover (:files (expand-file-name "duet.el" run-coverage--project-root)) + (:report-format (if (getenv "CI") 'coveralls 'simplecov)) + (:report-file (expand-file-name + (if (getenv "CI") + ".coverage/coveralls.json" + ".coverage/simplecov.json") + run-coverage--project-root)) + (:merge-report t) + (:send-report nil)) + +;;; run-coverage-file.el ends here diff --git a/tests/test-bootstrap.el b/tests/test-bootstrap.el new file mode 100644 index 0000000..a545cea --- /dev/null +++ b/tests/test-bootstrap.el @@ -0,0 +1,40 @@ +;;; test-bootstrap.el --- Common test initialization for duet -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;; Author: Craig Jennings + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see . + +;;; Commentary: + +;; Shared initialization for all duet test files. Handles package setup, +;; dependency loading, and loading the package source. +;; +;; Usage: (require 'test-bootstrap (expand-file-name "test-bootstrap.el")) + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'cl-lib) + +;; Load duet from the parent directory. +(load (expand-file-name "../duet.el") nil t) + +(provide 'test-bootstrap) +;;; test-bootstrap.el ends here diff --git a/tests/test-duet-complexity.el b/tests/test-duet-complexity.el new file mode 100644 index 0000000..4929a74 --- /dev/null +++ b/tests/test-duet-complexity.el @@ -0,0 +1,126 @@ +;;; test-duet-complexity.el --- Tests for the duet complexity scanner -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;; Author: Craig Jennings + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see . + +;;; Commentary: + +;; Unit tests for scripts/duet-complexity.el — the homegrown McCabe branch +;; counter behind `make complexity'. The counting logic is pure (operates on +;; already-read forms), so Normal/Boundary/Error cases need no I/O; only the +;; file-scanning tests touch a temp file. + +;;; Code: + +(require 'ert) +(require 'duet-complexity + (expand-file-name "../scripts/duet-complexity.el" + (file-name-directory (or load-file-name buffer-file-name)))) + +;;; Normal cases — base count and single constructs + +(ert-deftest test-duet-complexity-empty-body-is-one () + "A function with no decision points has complexity 1." + (should (= 1 (duet-complexity-of-form '(defun f ()))))) + +(ert-deftest test-duet-complexity-single-if-is-two () + "One `if' adds a single decision point." + (should (= 2 (duet-complexity-of-form '(defun f (x) (if x 1 2)))))) + +(ert-deftest test-duet-complexity-when-unless-each-add-one () + "`when' and `unless' each add one decision point." + (should (= 2 (duet-complexity-of-form '(defun f (x) (when x 1))))) + (should (= 2 (duet-complexity-of-form '(defun f (x) (unless x 1)))))) + +(ert-deftest test-duet-complexity-loops-add-one () + "Looping forms each add one decision point." + (should (= 2 (duet-complexity-of-form '(defun f (xs) (dolist (x xs) x))))) + (should (= 2 (duet-complexity-of-form '(defun f (n) (dotimes (i n) i))))) + (should (= 2 (duet-complexity-of-form '(defun f (x) (while x (setq x nil))))))) + +(ert-deftest test-duet-complexity-cond-one-per-clause () + "`cond' adds one decision point per clause." + (should (= 4 (duet-complexity-of-form + '(defun f (x) (cond (a 1) (b 2) (t 3))))))) + +(ert-deftest test-duet-complexity-nested-decisions-accumulate () + "A nested branch inside another counts in addition to the outer one." + (should (= 3 (duet-complexity-of-form + '(defun f (x) (when x (if x 1 2))))))) + +;;; Boundary cases — boolean operators, quoting, pattern matching + +(ert-deftest test-duet-complexity-and-adds-operands-minus-one () + "`and' adds one decision point per short-circuit (operands minus one)." + (should (= 3 (duet-complexity-of-form '(defun f (a b c) (and a b c)))))) + +(ert-deftest test-duet-complexity-or-two-operands-adds-one () + "`or' with two operands adds a single decision point." + (should (= 2 (duet-complexity-of-form '(defun f (a b) (or a b)))))) + +(ert-deftest test-duet-complexity-quoted-data-is-not-counted () + "Branch-looking forms inside a quote are data, not control flow." + (should (= 1 (duet-complexity-of-form '(defun f () '(if a b c)))))) + +(ert-deftest test-duet-complexity-pcase-counts-clauses-not-patterns () + "`pcase' counts one per clause; an `or' pattern is not a boolean `or'." + (should (= 4 (duet-complexity-of-form + '(defun f (x) (pcase x ((or 1 2) 'a) (3 'b) (_ 'c))))))) + +(ert-deftest test-duet-complexity-condition-case-counts-handlers () + "`condition-case' adds one decision point per handler." + (should (= 3 (duet-complexity-of-form + '(defun f () (condition-case err (foo) (error 1) (quit 2))))))) + +(ert-deftest test-duet-complexity-handles-defmacro-and-cl-defun () + "Defun-like heads other than `defun' are measured the same way." + (should (= 2 (duet-complexity-of-form '(defmacro f (x) (if x 1 2))))) + (should (= 2 (duet-complexity-of-form '(cl-defun f (x) (if x 1 2)))))) + +;;; File scanning + +(ert-deftest test-duet-complexity-scan-file-returns-name-score-pairs () + "Scanning a file returns one (NAME . COMPLEXITY) pair per defun-like form." + (let ((file (make-temp-file "duet-cx" nil ".el" + "(defun simple () 1)\n(defun branchy (x) (if x (when x 1) 2))\n"))) + (unwind-protect + (let ((results (duet-complexity-scan-file file))) + (should (equal 1 (cdr (assq 'simple results)))) + (should (equal 3 (cdr (assq 'branchy results)))) + (should (= 2 (length results)))) + (delete-file file)))) + +(ert-deftest test-duet-complexity-scan-file-ignores-non-defuns () + "Top-level forms that are not defun-like are skipped by the scanner." + (let ((file (make-temp-file "duet-cx" nil ".el" + "(defvar x 1)\n(require 'foo)\n(defun real (y) (if y 1 2))\n"))) + (unwind-protect + (let ((results (duet-complexity-scan-file file))) + (should (= 1 (length results))) + (should (equal 2 (cdr (assq 'real results))))) + (delete-file file)))) + +;;; Threshold gate + +(ert-deftest test-duet-complexity-over-threshold-filters () + "Only functions above the threshold are returned." + (let ((results '((a . 3) (b . 11) (c . 10) (d . 15)))) + (should (equal '((b . 11) (d . 15)) + (duet-complexity-over-threshold results 10))))) + +(provide 'test-duet-complexity) +;;; test-duet-complexity.el ends here diff --git a/tests/test-duet-smoke.el b/tests/test-duet-smoke.el new file mode 100644 index 0000000..a101dd2 --- /dev/null +++ b/tests/test-duet-smoke.el @@ -0,0 +1,36 @@ +;;; test-duet-smoke.el --- Harness smoke test for duet -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;; Author: Craig Jennings + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see . + +;;; Commentary: + +;; Plumbing-proof smoke test: confirms the harness loads the package and the +;; entry command is defined. Behavior coverage lives in the per-area test +;; files added by later phases. + +;;; Code: + +(require 'test-bootstrap (expand-file-name "test-bootstrap.el")) + +(ert-deftest test-duet-smoke-feature-loaded () + "The package source loads and defines its entry command." + (should (featurep 'duet)) + (should (commandp 'duet))) + +(provide 'test-duet-smoke) +;;; test-duet-smoke.el ends here -- cgit v1.2.3