diff options
| -rw-r--r-- | modules/dev-fkeys.el | 196 | ||||
| -rw-r--r-- | tests/test-dev-fkeys--f6-buffer-is-test-file-p.el | 108 | ||||
| -rw-r--r-- | tests/test-dev-fkeys--f6-current-file-tests-impl.el | 125 | ||||
| -rw-r--r-- | tests/test-dev-fkeys--f6-current-file-tests.el | 29 | ||||
| -rw-r--r-- | tests/test-dev-fkeys--f6-language-detect.el | 80 | ||||
| -rw-r--r-- | tests/test-dev-fkeys--f6-source-stem.el | 84 | ||||
| -rw-r--r-- | tests/test-dev-fkeys--f6-test-runner-cmd-for.el | 112 | ||||
| -rw-r--r-- | tests/test-dev-fkeys--f6-test-runner.el | 76 |
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 |
