aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-03 17:20:54 -0500
committerCraig Jennings <c@cjennings.net>2026-05-03 17:20:54 -0500
commit1d9d252e8b9e1385337cd0af087a7007f8e62da8 (patch)
tree4c774bbed6db0b0167e0235a39d5aeaab021ce9c
parent2c94acd52cc92dc4ebefd999dbca771367cc3090 (diff)
downloaddotemacs-1d9d252e8b9e1385337cd0af087a7007f8e62da8.tar.gz
dotemacs-1d9d252e8b9e1385337cd0af087a7007f8e62da8.zip
feat(dev-fkeys): add F6 test runner menu (Phase 2a)
I extended `dev-fkeys.el` with the F6 dispatcher half of the spec. F6 prompts via `completing-read` between two candidates: "All tests" delegates to `projectile-test-project`, and "Current file's tests" detects the buffer's language by extension, derives the runner command, and pipes through `compile' from the projectile root. C-F6 is the fast path straight to "Current file's tests". Per-language coverage: - Elisp source files map to `make test-name TEST=^test-<stem>-`. Elisp test files run with `make test-file FILE=<rel-path>` so a per-helper file like `test-foo--bar.el' runs only its own tests. - Python source files map to `pytest tests/test_<stem>.py'. Python test files run with `pytest <rel-path>'. - Go runs the package containing the file: `go test ./<rel-dir>'. Source and test files use the same command since Go test scope is per-package. Limit: this runs every `_test.go' in the package, not just the buffer's file. Phase 2b can refine via test-name discovery. - TypeScript and JavaScript are detected but punted for v1. The runner-command builder returns nil and the orchestrator signals a user-error rather than guessing. The F6 binding moved from the Phase 1 stopgap (`projectile-test-project') to `cj/f6-test-runner'. C-F6 is newly bound to `cj/f6-current-file-tests'. M-F6 stays unbound, reserved for Phase 2b's "Run a test..." menu entry. TDD: 68 new tests across 7 files. Production code split into small testable internals (`cj/--f6-language-detect', `cj/--f6-buffer-is-test-file-p', `cj/--f6-source-stem', `cj/--f6-test-runner-cmd-for', `cj/--f6-current-file-tests-impl') plus two thin interactive wrappers. Smoke tests confirm bindings register on load. I also updated the module commentary with the Phase 2b plan, the capture-then-filter approach for tree-sitter discovery, and a pointer to Emacs bug #79687. The bug is the predicate-syntax mismatch that breaks `:match' / `:equal' / `:pred' queries on Emacs 30.2 with libtree-sitter 0.26. The fix lives on Emacs master (commit b0143530), targets Emacs 31, and has not been backported to the emacs-30 branch as of today. Phase 2b will use queries without predicates and filter results in Elisp, sidestepping the issue. Mike Olson's `treesit-predicate-rewrite.el' applies the same idea to font-lock if you want it before Phase 2b lands.
-rw-r--r--modules/dev-fkeys.el196
-rw-r--r--tests/test-dev-fkeys--f6-buffer-is-test-file-p.el108
-rw-r--r--tests/test-dev-fkeys--f6-current-file-tests-impl.el125
-rw-r--r--tests/test-dev-fkeys--f6-current-file-tests.el29
-rw-r--r--tests/test-dev-fkeys--f6-language-detect.el80
-rw-r--r--tests/test-dev-fkeys--f6-source-stem.el84
-rw-r--r--tests/test-dev-fkeys--f6-test-runner-cmd-for.el112
-rw-r--r--tests/test-dev-fkeys--f6-test-runner.el76
8 files changed, 798 insertions, 12 deletions
diff --git a/modules/dev-fkeys.el b/modules/dev-fkeys.el
index d2c70131..0bb720b1 100644
--- a/modules/dev-fkeys.el
+++ b/modules/dev-fkeys.el
@@ -7,13 +7,33 @@
;; C-F4 fast path: compile only (no-op on interpreted projects)
;; M-F4 fast path: clean + rebuild (no-op on interpreted projects)
;; S-F4 recompile (built-in)
-;; F6 project tests (Phase 1 stopgap; Phase 2 will replace with the
-;; polyglot test runner spec'd in todo.org)
+;; F6 completing-read of test candidates: All tests / Current file's tests
+;; C-F6 fast path: current file's tests
;;
-;; Project-type detection runs against the projectile root and falls back to
-;; 'unknown when no marker matches. Interpreted markers are checked before
-;; compiled markers, so a Python or Node project that also has a Makefile
-;; for tasks classifies as interpreted.
+;; F4 project-type detection runs against the projectile root and falls back
+;; to \\='unknown when no marker matches. Interpreted markers are checked
+;; before compiled markers, so a Python or Node project that also has a
+;; Makefile for tasks classifies as interpreted.
+;;
+;; F6 \"All tests\" delegates to `projectile-test-project'. F6 \"Current
+;; file's tests\" detects the language by extension, derives the runner
+;; command (elisp via the project Makefile, Python via pytest, Go via the
+;; package), and pipes through `compile' from the projectile root.
+;; TypeScript / JavaScript are detected but punted for v1 — the function
+;; signals a user-error rather than guessing a runner.
+;;
+;; M-F6 is reserved for Phase 2b (\"Run a test...\" menu entry with
+;; per-language test-name discovery). Phase 2b also adds buffer-local
+;; last-test memory and tree-sitter-based discovery for Python / Go /
+;; TypeScript. The tree-sitter discovery uses a capture-then-filter pattern
+;; (queries without `:match' / `:equal' / `:pred' predicates, with the
+;; pattern filter applied in Elisp) to sidestep Emacs bug #79687 — Emacs
+;; 30.2 emits unsuffixed `#match' predicates that libtree-sitter 0.26
+;; rejects. The fix lives on Emacs master (commit b0143530) and is
+;; targeted at Emacs 31; it has not been backported to the emacs-30
+;; branch as of 2026-05-03. See Mike Olson's writeup at
+;; https://mwolson.org/blog/emacs/2026-04-20-fixing-typescript-ts-mode-in-emacs-30-2/
+;; for the same workaround applied to font-lock.
;;
;; F7 (coverage) is wired in coverage-core.el. F5 is reserved for the debug
;; ticket and intentionally left unbound here.
@@ -147,6 +167,139 @@ a single Compile entry that calls plain `compile'."
('interpreted '(("Run" . run-only)))
(_ '(("Compile" . compile-plain)))))
+;; ---------- F6 language detection ----------
+
+(defconst cj/--f6-extension-language-map
+ '(("el" . elisp)
+ ("py" . python)
+ ("go" . go)
+ ("ts" . typescript)
+ ("tsx" . typescript)
+ ("js" . javascript)
+ ("jsx" . javascript))
+ "Map of file-extension string to language symbol.
+Used by `cj/--f6-language-detect'.")
+
+(defun cj/--f6-language-detect (filename)
+ "Classify FILENAME's language by extension.
+Returns one of \\='elisp, \\='python, \\='go, \\='typescript, \\='javascript,
+or \\='unknown. Match is case-insensitive. Nil or extensionless input
+returns \\='unknown."
+ (if (or (null filename) (string-empty-p filename))
+ 'unknown
+ (let ((ext (file-name-extension filename)))
+ (or (and ext
+ (cdr (assoc (downcase ext) cj/--f6-extension-language-map)))
+ 'unknown))))
+
+;; ---------- F6 test-file detection ----------
+
+(defun cj/--f6-buffer-is-test-file-p (filename)
+ "Return non-nil if FILENAME's basename matches a test-file naming convention.
+Per language: elisp basenames start with `test-'; Python basenames start
+with `test_' or end with `_test.py'; Go basenames end with `_test.go';
+TypeScript / JavaScript basenames contain `.test.' or `.spec.'. Files
+whose extension we don't classify return nil even if their name happens
+to match a generic pattern."
+ (when (and filename (not (string-empty-p filename)))
+ (let ((language (cj/--f6-language-detect filename))
+ (base (file-name-nondirectory filename)))
+ (pcase language
+ ('elisp (string-prefix-p "test-" base))
+ ('python (or (string-prefix-p "test_" base)
+ (string-suffix-p "_test.py" base)))
+ ('go (string-suffix-p "_test.go" base))
+ ((or 'typescript 'javascript)
+ (or (string-match-p "\\.test\\." base)
+ (string-match-p "\\.spec\\." base)))
+ (_ nil)))))
+
+;; ---------- F6 source-stem extraction ----------
+
+(defun cj/--f6-source-stem (filename)
+ "Return the source-module stem from FILENAME, or nil for nil/empty input.
+Strips directory, extension, and any test-pattern prefix or suffix.
+For elisp test files like `test-foo--bar.el', drops everything from
+`--' onward so the result is the source module name (`foo'). Unsupported
+languages fall back to the basename without extension."
+ (when (and filename (not (string-empty-p filename)))
+ (let ((language (cj/--f6-language-detect filename))
+ (base (file-name-base filename)))
+ (pcase language
+ ('elisp
+ (let ((stripped (if (string-prefix-p "test-" base)
+ (substring base 5)
+ base)))
+ (if (string-match "--" stripped)
+ (substring stripped 0 (match-beginning 0))
+ stripped)))
+ ('python
+ (cond
+ ((string-prefix-p "test_" base) (substring base 5))
+ ((string-suffix-p "_test" base) (substring base 0 -5))
+ (t base)))
+ ('go
+ (if (string-suffix-p "_test" base)
+ (substring base 0 -5)
+ base))
+ (_ base)))))
+
+;; ---------- F6 test-runner command builder ----------
+
+(defun cj/--f6-test-runner-cmd-for (language is-test-file rel-path stem rel-dir)
+ "Return shell command to run tests for the given primitives, or nil.
+LANGUAGE is the language symbol; IS-TEST-FILE is non-nil when the file
+itself is a test file; REL-PATH and REL-DIR are the path and directory
+relative to project root; STEM is the source-module stem.
+
+For elisp test files the command runs only that file via
+`make test-file FILE='. For elisp source files it picks up the matching
+tests by name regex via `make test-name TEST=^test-<stem>-'. Python
+source files map to `pytest tests/test_<stem>.py'; Python test files run
+the file directly. Go runs the package containing the file.
+TypeScript / JavaScript and unknown languages return nil."
+ (pcase language
+ ('elisp
+ (if is-test-file
+ (format "make test-file FILE=%s" rel-path)
+ (format "make test-name TEST=^test-%s-" stem)))
+ ('python
+ (if is-test-file
+ (format "pytest %s" rel-path)
+ (format "pytest tests/test_%s.py" stem)))
+ ('go
+ (format "go test ./%s" rel-dir))
+ (_ nil)))
+
+;; ---------- F6 current-file orchestrator ----------
+
+(defun cj/--f6-current-file-tests-impl (file project-root)
+ "Run the tests for FILE within PROJECT-ROOT.
+Detects language, derives the runner command via
+`cj/--f6-test-runner-cmd-for', and invokes `compile' with
+`default-directory' bound to PROJECT-ROOT. Signals `user-error' for nil
+inputs or files in languages without a runner."
+ (unless file
+ (user-error "F6: no file backing this buffer"))
+ (unless project-root
+ (user-error "F6: no project detected"))
+ (let* ((language (cj/--f6-language-detect file))
+ (rel-path (file-relative-name file project-root))
+ (rel-dir (file-relative-name (file-name-directory file) project-root))
+ (rel-dir (cond
+ ((string= rel-dir "./") "")
+ ((string-suffix-p "/" rel-dir)
+ (substring rel-dir 0 -1))
+ (t rel-dir)))
+ (stem (cj/--f6-source-stem file))
+ (is-test (cj/--f6-buffer-is-test-file-p file))
+ (cmd (cj/--f6-test-runner-cmd-for
+ language is-test rel-path stem rel-dir)))
+ (unless cmd
+ (user-error "F6: no test runner for %s files" language))
+ (let ((default-directory project-root))
+ (compile cmd))))
+
;; ---------- Interactive wrappers ----------
(defun cj/f4-compile-and-run ()
@@ -178,6 +331,28 @@ get a no-op message. Outside any project, falls back to interactive
('interpreted (message "C-F4: not a compiled language"))
(_ (call-interactively #'compile)))))
+(defun cj/f6-test-runner ()
+ "F6 top-level test menu.
+Prompts via `completing-read' between \"All tests\" (delegates to
+`projectile-test-project') and \"Current file's tests\" (delegates to
+`cj/--f6-current-file-tests-impl')."
+ (interactive)
+ (let* ((candidates '("All tests" "Current file's tests"))
+ (label (completing-read "F6: " candidates nil t nil nil (car candidates))))
+ (pcase label
+ ("All tests" (projectile-test-project nil))
+ ("Current file's tests"
+ (cj/--f6-current-file-tests-impl
+ (buffer-file-name) (cj/--f4-project-root))))))
+
+(defun cj/f6-current-file-tests ()
+ "C-F6 fast path: run tests for the current buffer's file.
+Resolves `buffer-file-name' and projectile root, then delegates to
+`cj/--f6-current-file-tests-impl'."
+ (interactive)
+ (cj/--f6-current-file-tests-impl
+ (buffer-file-name) (cj/--f4-project-root)))
+
(defun cj/f4-clean-rebuild ()
"M-F4 fast path: clean + rebuild.
Compiled projects run the heuristic clean + projectile-compile-project
@@ -197,12 +372,9 @@ message."
(keymap-global-set "C-<f4>" #'cj/f4-compile-only)
(keymap-global-set "M-<f4>" #'cj/f4-clean-rebuild)
(keymap-global-set "S-<f4>" #'recompile)
-;; Phase 1 stopgap. Phase 2 replaces this with the polyglot test runner
-;; spec'd in todo.org. Without a global F6 binding here, the per-language
-;; F6→format bindings in prog-c/python/shell would have nothing to fall
-;; back on after this commit drops them, leaving F6 on its useless Emacs
-;; default (`2C-command').
-(keymap-global-set "<f6>" #'projectile-test-project)
+(keymap-global-set "<f6>" #'cj/f6-test-runner)
+(keymap-global-set "C-<f6>" #'cj/f6-current-file-tests)
+;; M-<f6> reserved for Phase 2b ("Run a test..." with last-test memory).
(provide 'dev-fkeys)
;;; dev-fkeys.el ends here.
diff --git a/tests/test-dev-fkeys--f6-buffer-is-test-file-p.el b/tests/test-dev-fkeys--f6-buffer-is-test-file-p.el
new file mode 100644
index 00000000..b2ed95b9
--- /dev/null
+++ b/tests/test-dev-fkeys--f6-buffer-is-test-file-p.el
@@ -0,0 +1,108 @@
+;;; test-dev-fkeys--f6-buffer-is-test-file-p.el --- Tests for cj/--f6-buffer-is-test-file-p -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Tests for the test-file detector. Naming heuristics per language:
+;;
+;; elisp: basename starts with "test-"
+;; python: basename starts with "test_" OR ends with "_test.py"
+;; go: basename ends with "_test.go"
+;; ts/js: basename contains ".test." or ".spec."
+;;
+;; Anything else (including unsupported languages) returns nil.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'dev-fkeys)
+
+;;; Normal Cases — Elisp
+
+(ert-deftest test-dev-fkeys-f6-buffer-is-test-file-p-elisp-test ()
+ "Normal: an elisp file with `test-` prefix is a test file."
+ (should (cj/--f6-buffer-is-test-file-p "tests/test-foo.el")))
+
+(ert-deftest test-dev-fkeys-f6-buffer-is-test-file-p-elisp-source ()
+ "Normal: an elisp file without `test-` prefix is not a test file."
+ (should-not (cj/--f6-buffer-is-test-file-p "modules/foo.el")))
+
+;;; Normal Cases — Python
+
+(ert-deftest test-dev-fkeys-f6-buffer-is-test-file-p-python-prefix ()
+ "Normal: a Python file with `test_` prefix is a test file."
+ (should (cj/--f6-buffer-is-test-file-p "tests/test_foo.py")))
+
+(ert-deftest test-dev-fkeys-f6-buffer-is-test-file-p-python-suffix ()
+ "Normal: a Python file with `_test.py` suffix is a test file."
+ (should (cj/--f6-buffer-is-test-file-p "pkg/foo_test.py")))
+
+(ert-deftest test-dev-fkeys-f6-buffer-is-test-file-p-python-source ()
+ "Normal: a Python file with neither prefix nor suffix is not a test file."
+ (should-not (cj/--f6-buffer-is-test-file-p "pkg/foo.py")))
+
+;;; Normal Cases — Go
+
+(ert-deftest test-dev-fkeys-f6-buffer-is-test-file-p-go-test ()
+ "Normal: a Go file with `_test.go` suffix is a test file."
+ (should (cj/--f6-buffer-is-test-file-p "pkg/foo_test.go")))
+
+(ert-deftest test-dev-fkeys-f6-buffer-is-test-file-p-go-source ()
+ "Normal: a Go file without `_test` suffix is not a test file."
+ (should-not (cj/--f6-buffer-is-test-file-p "pkg/foo.go")))
+
+;;; Normal Cases — TS / JS
+
+(ert-deftest test-dev-fkeys-f6-buffer-is-test-file-p-typescript-test ()
+ "Normal: a .ts file with `.test.` infix is a test file."
+ (should (cj/--f6-buffer-is-test-file-p "src/foo.test.ts")))
+
+(ert-deftest test-dev-fkeys-f6-buffer-is-test-file-p-typescript-spec ()
+ "Normal: a .ts file with `.spec.` infix is a test file."
+ (should (cj/--f6-buffer-is-test-file-p "src/foo.spec.ts")))
+
+(ert-deftest test-dev-fkeys-f6-buffer-is-test-file-p-javascript-test ()
+ "Normal: a .js file with `.test.` infix is a test file."
+ (should (cj/--f6-buffer-is-test-file-p "src/foo.test.js")))
+
+(ert-deftest test-dev-fkeys-f6-buffer-is-test-file-p-typescript-source ()
+ "Normal: a .ts file with no test/spec marker is not a test file."
+ (should-not (cj/--f6-buffer-is-test-file-p "src/foo.ts")))
+
+;;; Boundary Cases
+
+(ert-deftest test-dev-fkeys-f6-buffer-is-test-file-p-double-dash-elisp ()
+ "Boundary: `test-foo--bar.el' (project's per-helper convention) is a test file."
+ (should (cj/--f6-buffer-is-test-file-p "tests/test-foo--bar.el")))
+
+(ert-deftest test-dev-fkeys-f6-buffer-is-test-file-p-test-in-name-elisp ()
+ "Boundary: a non-test elisp file that just happens to contain `test` somewhere
+in the basename is not a test file (must start with `test-`)."
+ (should-not (cj/--f6-buffer-is-test-file-p "modules/contest.el")))
+
+(ert-deftest test-dev-fkeys-f6-buffer-is-test-file-p-just-test-py ()
+ "Boundary: `test.py' alone is not a test file (no underscore separator)."
+ (should-not (cj/--f6-buffer-is-test-file-p "test.py")))
+
+(ert-deftest test-dev-fkeys-f6-buffer-is-test-file-p-go-suffix-only ()
+ "Boundary: only `_test.go' suffix counts; `test_foo.go' does not (Go
+convention is suffix, not prefix)."
+ (should-not (cj/--f6-buffer-is-test-file-p "pkg/test_foo.go")))
+
+;;; Error Cases
+
+(ert-deftest test-dev-fkeys-f6-buffer-is-test-file-p-nil ()
+ "Error: nil filename returns nil without erroring."
+ (should-not (cj/--f6-buffer-is-test-file-p nil)))
+
+(ert-deftest test-dev-fkeys-f6-buffer-is-test-file-p-empty ()
+ "Error: empty string returns nil."
+ (should-not (cj/--f6-buffer-is-test-file-p "")))
+
+(ert-deftest test-dev-fkeys-f6-buffer-is-test-file-p-unsupported-language ()
+ "Error: a file in an unsupported language returns nil even if the basename
+matches a generic pattern."
+ (should-not (cj/--f6-buffer-is-test-file-p "test-foo.rs"))
+ (should-not (cj/--f6-buffer-is-test-file-p "foo_test.rb")))
+
+(provide 'test-dev-fkeys--f6-buffer-is-test-file-p)
+;;; test-dev-fkeys--f6-buffer-is-test-file-p.el ends here
diff --git a/tests/test-dev-fkeys--f6-current-file-tests-impl.el b/tests/test-dev-fkeys--f6-current-file-tests-impl.el
new file mode 100644
index 00000000..ecc7a4f4
--- /dev/null
+++ b/tests/test-dev-fkeys--f6-current-file-tests-impl.el
@@ -0,0 +1,125 @@
+;;; test-dev-fkeys--f6-current-file-tests-impl.el --- Tests for cj/--f6-current-file-tests-impl -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Tests for the "Current file's tests" orchestrator. Takes (FILE
+;; PROJECT-ROOT), composes the per-language test-runner command, and runs
+;; it through `compile' with `default-directory' bound to PROJECT-ROOT.
+;; Errors when no file, no project root, or no test runner is available
+;; for the file's language.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'dev-fkeys)
+
+;;; Normal Cases
+
+(ert-deftest test-dev-fkeys-f6-current-file-tests-impl-elisp-source ()
+ "Normal: an elisp source file runs `make test-name TEST=^test-<stem>-'.
+
+Components integrated:
+- `cj/--f6-current-file-tests-impl' (unit under test)
+- `cj/--f6-language-detect' (real)
+- `cj/--f6-source-stem' (real)
+- `cj/--f6-buffer-is-test-file-p' (real)
+- `cj/--f6-test-runner-cmd-for' (real)
+- `compile' (MOCKED — captures cmd and default-directory)"
+ (let (seen-cmd seen-dir)
+ (cl-letf (((symbol-function 'compile)
+ (lambda (cmd) (setq seen-cmd cmd
+ seen-dir default-directory))))
+ (cj/--f6-current-file-tests-impl
+ "/home/u/proj/modules/foo.el"
+ "/home/u/proj/")
+ (should (string= seen-cmd "make test-name TEST=^test-foo-"))
+ (should (string= (file-name-as-directory seen-dir)
+ (file-name-as-directory "/home/u/proj/"))))))
+
+(ert-deftest test-dev-fkeys-f6-current-file-tests-impl-elisp-test-file ()
+ "Normal: an elisp test file runs `make test-file FILE=<rel-path>'."
+ (let (seen-cmd)
+ (cl-letf (((symbol-function 'compile)
+ (lambda (cmd) (setq seen-cmd cmd))))
+ (cj/--f6-current-file-tests-impl
+ "/home/u/proj/tests/test-foo.el"
+ "/home/u/proj/")
+ (should (string= seen-cmd "make test-file FILE=tests/test-foo.el")))))
+
+(ert-deftest test-dev-fkeys-f6-current-file-tests-impl-python-source ()
+ "Normal: a Python source file maps to `pytest tests/test_<stem>.py'."
+ (let (seen-cmd)
+ (cl-letf (((symbol-function 'compile)
+ (lambda (cmd) (setq seen-cmd cmd))))
+ (cj/--f6-current-file-tests-impl
+ "/home/u/proj/pkg/foo.py"
+ "/home/u/proj/")
+ (should (string= seen-cmd "pytest tests/test_foo.py")))))
+
+(ert-deftest test-dev-fkeys-f6-current-file-tests-impl-go-source ()
+ "Normal: a Go source file runs the package via `go test ./<rel-dir>'."
+ (let (seen-cmd)
+ (cl-letf (((symbol-function 'compile)
+ (lambda (cmd) (setq seen-cmd cmd))))
+ (cj/--f6-current-file-tests-impl
+ "/home/u/proj/pkg/foo.go"
+ "/home/u/proj/")
+ (should (string= seen-cmd "go test ./pkg")))))
+
+;;; Boundary Cases
+
+(ert-deftest test-dev-fkeys-f6-current-file-tests-impl-go-source-at-root ()
+ "Boundary: a Go source file at project root runs `go test ./'."
+ (let (seen-cmd)
+ (cl-letf (((symbol-function 'compile)
+ (lambda (cmd) (setq seen-cmd cmd))))
+ (cj/--f6-current-file-tests-impl
+ "/home/u/proj/main.go"
+ "/home/u/proj/")
+ (should (string= seen-cmd "go test ./")))))
+
+(ert-deftest test-dev-fkeys-f6-current-file-tests-impl-elisp-double-dash-test ()
+ "Boundary: a per-helper elisp test file runs `make test-file FILE=...' so
+just that file's tests run, not the whole module's prefix."
+ (let (seen-cmd)
+ (cl-letf (((symbol-function 'compile)
+ (lambda (cmd) (setq seen-cmd cmd))))
+ (cj/--f6-current-file-tests-impl
+ "/home/u/proj/tests/test-foo--bar.el"
+ "/home/u/proj/")
+ (should (string= seen-cmd "make test-file FILE=tests/test-foo--bar.el")))))
+
+;;; Error Cases
+
+(ert-deftest test-dev-fkeys-f6-current-file-tests-impl-nil-file-errors ()
+ "Error: nil FILE signals a user-error."
+ (cl-letf (((symbol-function 'compile) (lambda (_cmd) nil)))
+ (should-error (cj/--f6-current-file-tests-impl nil "/home/u/proj/")
+ :type 'user-error)))
+
+(ert-deftest test-dev-fkeys-f6-current-file-tests-impl-nil-root-errors ()
+ "Error: nil PROJECT-ROOT signals a user-error."
+ (cl-letf (((symbol-function 'compile) (lambda (_cmd) nil)))
+ (should-error (cj/--f6-current-file-tests-impl
+ "/home/u/proj/modules/foo.el" nil)
+ :type 'user-error)))
+
+(ert-deftest test-dev-fkeys-f6-current-file-tests-impl-unsupported-language-errors ()
+ "Error: a file with no language-specific runner signals a user-error
+naming the language."
+ (cl-letf (((symbol-function 'compile) (lambda (_cmd) nil)))
+ (should-error (cj/--f6-current-file-tests-impl
+ "/home/u/proj/src/foo.test.ts" "/home/u/proj/")
+ :type 'user-error)))
+
+(ert-deftest test-dev-fkeys-f6-current-file-tests-impl-unknown-language-errors ()
+ "Error: an unknown extension signals a user-error rather than running
+something the user didn't ask for."
+ (cl-letf (((symbol-function 'compile) (lambda (_cmd) nil)))
+ (should-error (cj/--f6-current-file-tests-impl
+ "/home/u/proj/Makefile" "/home/u/proj/")
+ :type 'user-error)))
+
+(provide 'test-dev-fkeys--f6-current-file-tests-impl)
+;;; test-dev-fkeys--f6-current-file-tests-impl.el ends here
diff --git a/tests/test-dev-fkeys--f6-current-file-tests.el b/tests/test-dev-fkeys--f6-current-file-tests.el
new file mode 100644
index 00000000..3f6adc25
--- /dev/null
+++ b/tests/test-dev-fkeys--f6-current-file-tests.el
@@ -0,0 +1,29 @@
+;;; test-dev-fkeys--f6-current-file-tests.el --- Smoke tests for cj/f6-current-file-tests -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Smoke tests for the C-F6 fast path. Resolves buffer-file-name and the
+;; projectile root, then delegates to `cj/--f6-current-file-tests-impl'.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'dev-fkeys)
+
+;;; Normal Cases
+
+(ert-deftest test-dev-fkeys-f6-current-file-tests-routes-to-impl ()
+ "Normal: C-F6 invokes the orchestrator with buffer file and projectile root."
+ (let (seen-file seen-root)
+ (cl-letf (((symbol-function 'buffer-file-name) (lambda () "/p/foo.el"))
+ ((symbol-function 'cj/--f4-project-root) (lambda () "/p/"))
+ ((symbol-function 'cj/--f6-current-file-tests-impl)
+ (lambda (file root)
+ (setq seen-file file seen-root root))))
+ (cj/f6-current-file-tests)
+ (should (string= seen-file "/p/foo.el"))
+ (should (string= seen-root "/p/")))))
+
+(provide 'test-dev-fkeys--f6-current-file-tests)
+;;; test-dev-fkeys--f6-current-file-tests.el ends here
diff --git a/tests/test-dev-fkeys--f6-language-detect.el b/tests/test-dev-fkeys--f6-language-detect.el
new file mode 100644
index 00000000..93dd6d8b
--- /dev/null
+++ b/tests/test-dev-fkeys--f6-language-detect.el
@@ -0,0 +1,80 @@
+;;; test-dev-fkeys--f6-language-detect.el --- Tests for cj/--f6-language-detect -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Tests for the language classifier used by the F6 test runner. Detection
+;; runs purely off the file extension. The classifier returns one of
+;; \\='elisp / \\='python / \\='go / \\='typescript / \\='javascript / \\='unknown.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'dev-fkeys)
+
+;;; Normal Cases
+
+(ert-deftest test-dev-fkeys-f6-language-detect-elisp ()
+ "Normal: .el classifies as elisp."
+ (should (eq (cj/--f6-language-detect "foo.el") 'elisp)))
+
+(ert-deftest test-dev-fkeys-f6-language-detect-python ()
+ "Normal: .py classifies as python."
+ (should (eq (cj/--f6-language-detect "foo.py") 'python)))
+
+(ert-deftest test-dev-fkeys-f6-language-detect-go ()
+ "Normal: .go classifies as go."
+ (should (eq (cj/--f6-language-detect "foo.go") 'go)))
+
+(ert-deftest test-dev-fkeys-f6-language-detect-typescript ()
+ "Normal: .ts and .tsx classify as typescript."
+ (should (eq (cj/--f6-language-detect "foo.ts") 'typescript))
+ (should (eq (cj/--f6-language-detect "foo.tsx") 'typescript)))
+
+(ert-deftest test-dev-fkeys-f6-language-detect-javascript ()
+ "Normal: .js and .jsx classify as javascript."
+ (should (eq (cj/--f6-language-detect "foo.js") 'javascript))
+ (should (eq (cj/--f6-language-detect "foo.jsx") 'javascript)))
+
+;;; Boundary Cases
+
+(ert-deftest test-dev-fkeys-f6-language-detect-with-directory-path ()
+ "Boundary: classifier ignores the path, looks only at extension."
+ (should (eq (cj/--f6-language-detect "/abs/path/to/foo.el") 'elisp))
+ (should (eq (cj/--f6-language-detect "rel/dir/foo.py") 'python)))
+
+(ert-deftest test-dev-fkeys-f6-language-detect-test-file-name ()
+ "Boundary: a filename that is itself a test file still classifies by extension.
+The test-file-vs-source distinction is a separate helper."
+ (should (eq (cj/--f6-language-detect "tests/test-foo.el") 'elisp))
+ (should (eq (cj/--f6-language-detect "tests/test_foo.py") 'python))
+ (should (eq (cj/--f6-language-detect "pkg/foo_test.go") 'go)))
+
+(ert-deftest test-dev-fkeys-f6-language-detect-uppercase-extension ()
+ "Boundary: extension match is case-insensitive (.PY, .EL, etc.)."
+ (should (eq (cj/--f6-language-detect "foo.EL") 'elisp))
+ (should (eq (cj/--f6-language-detect "foo.PY") 'python)))
+
+(ert-deftest test-dev-fkeys-f6-language-detect-no-extension ()
+ "Boundary: a filename with no extension classifies as unknown."
+ (should (eq (cj/--f6-language-detect "Makefile") 'unknown))
+ (should (eq (cj/--f6-language-detect "README") 'unknown)))
+
+(ert-deftest test-dev-fkeys-f6-language-detect-unsupported-extension ()
+ "Boundary: an extension we do not classify returns unknown."
+ (should (eq (cj/--f6-language-detect "foo.rs") 'unknown))
+ (should (eq (cj/--f6-language-detect "foo.rb") 'unknown))
+ (should (eq (cj/--f6-language-detect "foo.txt") 'unknown)))
+
+;;; Error Cases
+
+(ert-deftest test-dev-fkeys-f6-language-detect-nil-filename ()
+ "Error: nil filename returns unknown without erroring (defensive against
+buffers that aren't backed by files)."
+ (should (eq (cj/--f6-language-detect nil) 'unknown)))
+
+(ert-deftest test-dev-fkeys-f6-language-detect-empty-filename ()
+ "Error: empty string returns unknown."
+ (should (eq (cj/--f6-language-detect "") 'unknown)))
+
+(provide 'test-dev-fkeys--f6-language-detect)
+;;; test-dev-fkeys--f6-language-detect.el ends here
diff --git a/tests/test-dev-fkeys--f6-source-stem.el b/tests/test-dev-fkeys--f6-source-stem.el
new file mode 100644
index 00000000..bdf85b5f
--- /dev/null
+++ b/tests/test-dev-fkeys--f6-source-stem.el
@@ -0,0 +1,84 @@
+;;; test-dev-fkeys--f6-source-stem.el --- Tests for cj/--f6-source-stem -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Tests for the stem extractor. Given any source-or-test filename, returns
+;; the source module name with directory, extension, and any test-pattern
+;; prefix/suffix stripped. Used to map source files to their tests and
+;; vice versa.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'dev-fkeys)
+
+;;; Normal Cases — Elisp
+
+(ert-deftest test-dev-fkeys-f6-source-stem-elisp-source ()
+ "Normal: a regular elisp source file's stem is its basename."
+ (should (string= (cj/--f6-source-stem "modules/foo.el") "foo")))
+
+(ert-deftest test-dev-fkeys-f6-source-stem-elisp-test ()
+ "Normal: an elisp test file's stem strips the `test-' prefix."
+ (should (string= (cj/--f6-source-stem "tests/test-foo.el") "foo")))
+
+(ert-deftest test-dev-fkeys-f6-source-stem-elisp-test-double-dash ()
+ "Normal: an elisp per-helper test file `test-<module>--<helper>.el' yields
+the module name (everything from `--' onward is dropped)."
+ (should (string= (cj/--f6-source-stem "tests/test-dev-fkeys--detect-project-type.el")
+ "dev-fkeys")))
+
+;;; Normal Cases — Python
+
+(ert-deftest test-dev-fkeys-f6-source-stem-python-source ()
+ "Normal: a regular Python source file's stem is its basename."
+ (should (string= (cj/--f6-source-stem "pkg/foo.py") "foo")))
+
+(ert-deftest test-dev-fkeys-f6-source-stem-python-test-prefix ()
+ "Normal: a `test_<name>.py' file's stem strips the `test_' prefix."
+ (should (string= (cj/--f6-source-stem "tests/test_foo.py") "foo")))
+
+(ert-deftest test-dev-fkeys-f6-source-stem-python-test-suffix ()
+ "Normal: a `<name>_test.py' file's stem strips the `_test' suffix."
+ (should (string= (cj/--f6-source-stem "pkg/foo_test.py") "foo")))
+
+;;; Normal Cases — Go
+
+(ert-deftest test-dev-fkeys-f6-source-stem-go-source ()
+ "Normal: a regular Go source file's stem is its basename."
+ (should (string= (cj/--f6-source-stem "pkg/foo.go") "foo")))
+
+(ert-deftest test-dev-fkeys-f6-source-stem-go-test ()
+ "Normal: a `<name>_test.go' file's stem strips the `_test' suffix."
+ (should (string= (cj/--f6-source-stem "pkg/foo_test.go") "foo")))
+
+;;; Boundary Cases
+
+(ert-deftest test-dev-fkeys-f6-source-stem-elisp-multi-segment-name ()
+ "Boundary: hyphenated module names round-trip cleanly."
+ (should (string= (cj/--f6-source-stem "modules/calendar-sync.el") "calendar-sync"))
+ (should (string= (cj/--f6-source-stem "tests/test-calendar-sync.el") "calendar-sync")))
+
+(ert-deftest test-dev-fkeys-f6-source-stem-elisp-test-with-dashes-after-double-dash ()
+ "Boundary: a per-helper test file with hyphens in both module and helper:
+`test-foo-bar--baz-qux.el' → `foo-bar' (stops at first `--')."
+ (should (string= (cj/--f6-source-stem "tests/test-foo-bar--baz-qux.el")
+ "foo-bar")))
+
+(ert-deftest test-dev-fkeys-f6-source-stem-unknown-language-falls-back-to-basename ()
+ "Boundary: an unsupported extension just returns the basename without extension."
+ (should (string= (cj/--f6-source-stem "foo.rs") "foo"))
+ (should (string= (cj/--f6-source-stem "foo.txt") "foo")))
+
+;;; Error Cases
+
+(ert-deftest test-dev-fkeys-f6-source-stem-nil-returns-nil ()
+ "Error: nil filename returns nil without erroring."
+ (should (null (cj/--f6-source-stem nil))))
+
+(ert-deftest test-dev-fkeys-f6-source-stem-empty-returns-nil ()
+ "Error: empty string returns nil."
+ (should (null (cj/--f6-source-stem ""))))
+
+(provide 'test-dev-fkeys--f6-source-stem)
+;;; test-dev-fkeys--f6-source-stem.el ends here
diff --git a/tests/test-dev-fkeys--f6-test-runner-cmd-for.el b/tests/test-dev-fkeys--f6-test-runner-cmd-for.el
new file mode 100644
index 00000000..d9f8f464
--- /dev/null
+++ b/tests/test-dev-fkeys--f6-test-runner-cmd-for.el
@@ -0,0 +1,112 @@
+;;; test-dev-fkeys--f6-test-runner-cmd-for.el --- Tests for cj/--f6-test-runner-cmd-for -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Tests for the per-language test-runner-command builder.
+;;
+;; Inputs are five primitives the orchestrator pre-computes:
+;;
+;; LANGUAGE symbol from `cj/--f6-language-detect'
+;; IS-TEST-FILE boolean from `cj/--f6-buffer-is-test-file-p'
+;; REL-PATH file path relative to project root
+;; STEM source module stem from `cj/--f6-source-stem'
+;; REL-DIR file's directory relative to project root
+;;
+;; Returns a shell command string to pipe through `compile', or nil for
+;; languages we don't have a runner for. The TS / JS handlers are punted
+;; for v1 — they return nil.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'dev-fkeys)
+
+;;; Normal Cases — Elisp
+
+(ert-deftest test-dev-fkeys-f6-cmd-for-elisp-test-file ()
+ "Normal: an elisp test file runs via `make test-file FILE=<path>'."
+ (should (string=
+ (cj/--f6-test-runner-cmd-for
+ 'elisp t "tests/test-foo.el" "foo" "tests")
+ "make test-file FILE=tests/test-foo.el")))
+
+(ert-deftest test-dev-fkeys-f6-cmd-for-elisp-source-file ()
+ "Normal: an elisp source file runs via `make test-name TEST=^test-<stem>-'.
+The trailing hyphen anchors the regex so test names from a different
+module that happen to share a prefix don't get pulled in."
+ (should (string=
+ (cj/--f6-test-runner-cmd-for
+ 'elisp nil "modules/foo.el" "foo" "modules")
+ "make test-name TEST=^test-foo-")))
+
+;;; Normal Cases — Python
+
+(ert-deftest test-dev-fkeys-f6-cmd-for-python-test-file ()
+ "Normal: a Python test file runs via `pytest <rel-path>'."
+ (should (string=
+ (cj/--f6-test-runner-cmd-for
+ 'python t "tests/test_foo.py" "foo" "tests")
+ "pytest tests/test_foo.py")))
+
+(ert-deftest test-dev-fkeys-f6-cmd-for-python-source-file ()
+ "Normal: a Python source file runs via `pytest tests/test_<stem>.py'."
+ (should (string=
+ (cj/--f6-test-runner-cmd-for
+ 'python nil "pkg/foo.py" "foo" "pkg")
+ "pytest tests/test_foo.py")))
+
+;;; Normal Cases — Go
+
+(ert-deftest test-dev-fkeys-f6-cmd-for-go-test-file ()
+ "Normal: a Go test file runs the package via `go test ./<rel-dir>'."
+ (should (string=
+ (cj/--f6-test-runner-cmd-for
+ 'go t "pkg/foo_test.go" "foo" "pkg")
+ "go test ./pkg")))
+
+(ert-deftest test-dev-fkeys-f6-cmd-for-go-source-file ()
+ "Normal: a Go source file runs the same package via `go test ./<rel-dir>'.
+Go test scope is per-package; running the source's package picks up its
+test files."
+ (should (string=
+ (cj/--f6-test-runner-cmd-for
+ 'go nil "pkg/foo.go" "foo" "pkg")
+ "go test ./pkg")))
+
+;;; Boundary Cases
+
+(ert-deftest test-dev-fkeys-f6-cmd-for-elisp-test-file-with-double-dash ()
+ "Boundary: a per-helper test file runs only that file, not the whole
+test-name prefix. `make test-file FILE=...' is precise; `test-name'
+would over-match."
+ (should (string=
+ (cj/--f6-test-runner-cmd-for
+ 'elisp t "tests/test-foo--bar.el" "foo" "tests")
+ "make test-file FILE=tests/test-foo--bar.el")))
+
+(ert-deftest test-dev-fkeys-f6-cmd-for-go-source-at-root ()
+ "Boundary: a Go source file at project root runs `go test ./'."
+ (should (string=
+ (cj/--f6-test-runner-cmd-for
+ 'go nil "main.go" "main" "")
+ "go test ./")))
+
+;;; Error Cases
+
+(ert-deftest test-dev-fkeys-f6-cmd-for-typescript-returns-nil ()
+ "Error: TypeScript is punted for v1 and returns nil."
+ (should (null (cj/--f6-test-runner-cmd-for
+ 'typescript t "src/foo.test.ts" "foo" "src"))))
+
+(ert-deftest test-dev-fkeys-f6-cmd-for-javascript-returns-nil ()
+ "Error: JavaScript is punted for v1 and returns nil."
+ (should (null (cj/--f6-test-runner-cmd-for
+ 'javascript t "src/foo.test.js" "foo" "src"))))
+
+(ert-deftest test-dev-fkeys-f6-cmd-for-unknown-returns-nil ()
+ "Error: an unknown language returns nil."
+ (should (null (cj/--f6-test-runner-cmd-for
+ 'unknown nil "Makefile" "Makefile" ""))))
+
+(provide 'test-dev-fkeys--f6-test-runner-cmd-for)
+;;; test-dev-fkeys--f6-test-runner-cmd-for.el ends here
diff --git a/tests/test-dev-fkeys--f6-test-runner.el b/tests/test-dev-fkeys--f6-test-runner.el
new file mode 100644
index 00000000..23129539
--- /dev/null
+++ b/tests/test-dev-fkeys--f6-test-runner.el
@@ -0,0 +1,76 @@
+;;; test-dev-fkeys--f6-test-runner.el --- Smoke tests for cj/f6-test-runner -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Smoke tests for the F6 top-level menu. The wrapper:
+;;
+;; 1. Prompts via completing-read with two candidates.
+;; 2. \"All tests\" invokes `projectile-test-project'.
+;; 3. \"Current file's tests\" invokes `cj/--f6-current-file-tests-impl'
+;; with the buffer's file and the project root.
+;;
+;; The orchestrator and helpers are tested in their own files; this file
+;; just confirms the wiring.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'dev-fkeys)
+
+;;; Normal Cases
+
+(ert-deftest test-dev-fkeys-f6-test-runner-prompts-with-completing-read ()
+ "Normal: F6 prompts with the two-entry candidate list."
+ (let (seen-candidates)
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (_prompt collection &rest _)
+ (setq seen-candidates collection)
+ (car collection)))
+ ((symbol-function 'projectile-test-project)
+ (lambda (_arg) nil))
+ ((symbol-function 'cj/--f4-project-root) (lambda () "/p/"))
+ ((symbol-function 'cj/--f6-current-file-tests-impl)
+ (lambda (_f _r) nil)))
+ (cj/f6-test-runner)
+ (should (member "All tests" seen-candidates))
+ (should (member "Current file's tests" seen-candidates)))))
+
+(ert-deftest test-dev-fkeys-f6-test-runner-all-tests-routes-to-projectile ()
+ "Normal: choosing 'All tests' invokes projectile-test-project."
+ (let ((calls 0))
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (&rest _) "All tests"))
+ ((symbol-function 'projectile-test-project)
+ (lambda (_arg) (cl-incf calls)))
+ ((symbol-function 'cj/--f4-project-root) (lambda () "/p/"))
+ ((symbol-function 'cj/--f6-current-file-tests-impl)
+ (lambda (_f _r) nil)))
+ (cj/f6-test-runner)
+ (should (= calls 1)))))
+
+(ert-deftest test-dev-fkeys-f6-test-runner-current-file-routes-to-impl ()
+ "Normal: choosing 'Current file's tests' invokes the orchestrator with
+the buffer file and projectile root.
+
+Components integrated:
+- `cj/f6-test-runner' (unit under test)
+- `completing-read' (MOCKED — picks the second label)
+- `cj/--f4-project-root' (MOCKED — fixed root)
+- `cj/--f6-current-file-tests-impl' (MOCKED — captures args)
+- `buffer-file-name' (MOCKED via cl-letf)"
+ (let (seen-file seen-root)
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (&rest _) "Current file's tests"))
+ ((symbol-function 'projectile-test-project) (lambda (_arg) nil))
+ ((symbol-function 'cj/--f4-project-root) (lambda () "/p/"))
+ ((symbol-function 'buffer-file-name) (lambda () "/p/foo.el"))
+ ((symbol-function 'cj/--f6-current-file-tests-impl)
+ (lambda (file root)
+ (setq seen-file file seen-root root))))
+ (cj/f6-test-runner)
+ (should (string= seen-file "/p/foo.el"))
+ (should (string= seen-root "/p/")))))
+
+(provide 'test-dev-fkeys--f6-test-runner)
+;;; test-dev-fkeys--f6-test-runner.el ends here