aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-06 10:31:30 -0500
committerCraig Jennings <c@cjennings.net>2026-06-06 10:31:30 -0500
commit95dbb5abdbb746cf5da9f7926740d17205ac8d55 (patch)
tree0e807d43d8f8ce32b3790efc716c433d35ceca3c /tests
parent6ecd1e9bf1e3d0cdd3861077318541e193ca4532 (diff)
downloadduet-95dbb5abdbb746cf5da9f7926740d17205ac8d55.tar.gz
duet-95dbb5abdbb746cf5da9f7926740d17205ac8d55.zip
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.
Diffstat (limited to 'tests')
-rw-r--r--tests/.gitkeep0
-rw-r--r--tests/Makefile307
-rw-r--r--tests/check-deps.el43
-rw-r--r--tests/run-coverage-file.el50
-rw-r--r--tests/test-bootstrap.el40
-rw-r--r--tests/test-duet-complexity.el126
-rw-r--r--tests/test-duet-smoke.el36
7 files changed, 602 insertions, 0 deletions
diff --git a/tests/.gitkeep b/tests/.gitkeep
deleted file mode 100644
index e69de29..0000000
--- a/tests/.gitkeep
+++ /dev/null
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 <c@cjennings.net>
+
+;; 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 <http://www.gnu.org/licenses/>.
+
+;;; 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 <c@cjennings.net>
+
+;; 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 <http://www.gnu.org/licenses/>.
+
+;;; 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 <c@cjennings.net>
+
+;; 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 <http://www.gnu.org/licenses/>.
+
+;;; 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