summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/test-runner.el301
-rw-r--r--tests/test-test-runner.el359
2 files changed, 579 insertions, 81 deletions
diff --git a/modules/test-runner.el b/modules/test-runner.el
index b66efc81..f143cd0f 100644
--- a/modules/test-runner.el
+++ b/modules/test-runner.el
@@ -2,20 +2,64 @@
;; author: Craig Jennings <c@cjennings.net>
;;
;;; Commentary:
-;; Provides utilities for running ERT tests with focus/unfocus workflow
+
+;; This module provides a powerful ERT test runner with focus/unfocus workflow
+;; for efficient test-driven development in Emacs Lisp projects.
+;;
+;; PURPOSE:
+;;
+;; When working on large Emacs Lisp projects with many test files, you often
+;; want to focus on running just the tests relevant to your current work without
+;; waiting for the entire suite to run. This module provides a smart test runner
+;; that supports both running all tests and focusing on specific test files.
+;;
+;; WORKFLOW:
+;;
+;; 1. Run all tests initially to establish baseline (C-; t R)
+;; 2. Add test files to focus while working on a feature (C-; t a)
+;; 3. Run focused tests repeatedly as you develop (C-; t r)
+;; 4. Add more test files as needed (C-; t b from within test buffer)
+;; 5. View your focused test list at any time (C-; t v)
+;; 6. Clear focus and run all tests before finishing (C-; t c, then C-; t R)
+;;
+;; PROJECT INTEGRATION:
+;;
+;; - Automatically discovers test directories in Projectile projects
+;; (looks for "test" or "tests" under project root)
+;; - Falls back to ~/.emacs.d/tests if not in a Projectile project
+;; - Test files must match pattern: test-*.el
+;;
+;; SPECIAL BEHAVIORS:
+;;
+;; - Smart test running: Automatically runs all or focused tests based on mode
+;; - Test extraction: Discovers test names via regex to run specific tests
+;; - At-point execution: Run individual test at cursor position (C-; t .)
+;; - Error handling: Continues loading tests even if individual files fail
+;;
+;; KEYBINDINGS:
;;
-;; Tests should be located in the Projectile project test directories,
-;; typically "test" or "tests" under the project root.
-;; Falls back to =~/.emacs.d/tests= if not in a Projectile project.
+;; C-; t L Load all test files
+;; C-; t R Run all tests (full suite)
+;; C-; t r Run tests smartly (all or focused based on mode)
+;; C-; t . Run test at point
+;; C-; t a Add test file to focus (with completion)
+;; C-; t b Add current buffer's test file to focus
+;; C-; t c Clear all focused test files
+;; C-; t v View list of focused test files
+;; C-; t t Toggle mode between 'all and 'focused
;;
-;; The default mode is to load and run all tests.
+;; RECOMMENDED USAGE:
;;
-;; To focus on running a specific set of test files:
-;; - Toggle the mode to "focus" mode
-;; - Add specific test files to the list of tests in "focus"
-;; - Running tests (smartly) will now just run those tests
+;; While implementing a feature:
+;; - Add the main test file for the feature you're working on
+;; - Add any related test files that might be affected
+;; - Use C-; t r to repeatedly run just those focused tests
+;; - This provides fast feedback during development
;;
-;; Don't forget to run all tests again in default mode at least once before finishing.
+;; Before committing:
+;; - Clear the focus with C-; t c
+;; - Run the full suite with C-; t R to ensure nothing broke
+;; - Verify all tests pass before pushing changes
;;
;;; Code:
@@ -35,7 +79,7 @@ Each element is a filename (without path) to run.")
(defvar cj/test-mode 'all
"Current test execution mode.
-Either 'all (run all tests) or 'focused (run only focused tests).")
+Either \\='all (run all tests) or \\='focused (run only focused tests).")
(defvar cj/test-last-results nil
"Results from the last test run.")
@@ -45,8 +89,9 @@ Either 'all (run all tests) or 'focused (run only focused tests).")
(defun cj/test--get-test-directory ()
"Return the test directory path for the current project.
-If in a Projectile project, prefers a 'test' or 'tests' directory inside the project root.
-Falls back to =cj/test-global-directory= if not found or not in a project."
+If in a Projectile project, prefers \\='test or \\='tests directory
+inside the project root. Falls back to `cj/test-global-directory'
+if not found or not in a project."
(require 'projectile)
(let ((project-root (ignore-errors (projectile-project-root))))
(if (not (and project-root (file-directory-p project-root)))
@@ -66,6 +111,25 @@ Falls back to =cj/test-global-directory= if not found or not in a project."
(mapcar #'file-name-nondirectory
(directory-files dir t "^test-.*\\.el$")))))
+(defun cj/test--do-load-files (dir files)
+ "Load test FILES from DIR.
+Returns: (cons \\='success loaded-count) on success,
+ (cons \\='error (list failed-files errors)) on errors."
+ (let ((loaded-count 0)
+ (errors '()))
+ (dolist (file files)
+ (condition-case err
+ (progn
+ (load-file file)
+ (setq loaded-count (1+ loaded-count)))
+ (error
+ (push (cons (file-name-nondirectory file)
+ (error-message-string err))
+ errors))))
+ (if (null errors)
+ (cons 'success loaded-count)
+ (cons 'error (list loaded-count (nreverse errors))))))
+
(defun cj/test-load-all ()
"Load all test files from the appropriate test directory."
(interactive)
@@ -73,19 +137,26 @@ Falls back to =cj/test-global-directory= if not found or not in a project."
(let ((dir (cj/test--get-test-directory)))
(unless (file-directory-p dir)
(user-error "Test directory %s does not exist" dir))
- (let ((test-files (directory-files dir t "^test-.*\\.el$"))
- (loaded-count 0))
- (dolist (file test-files)
- (condition-case err
- (progn
- (load-file file)
- (setq loaded-count (1+ loaded-count))
- (message "Loaded test file: %s" (file-name-nondirectory file)))
- (error
- (message "Error loading %s: %s"
- (file-name-nondirectory file)
- (error-message-string err)))))
- (message "Loaded %d test file(s)" loaded-count))))
+ (let ((test-files (directory-files dir t "^test-.*\\.el$")))
+ (pcase (cj/test--do-load-files dir test-files)
+ (`(success . ,count)
+ (message "Loaded %d test file(s)" count))
+ (`(error ,count ,errors)
+ (dolist (err errors)
+ (message "Error loading %s: %s" (car err) (cdr err)))
+ (message "Loaded %d test file(s) with %d error(s)" count (length errors)))))))
+
+(defun cj/test--do-focus-add (filename available-files focused-files)
+ "Add FILENAME to focused test files.
+AVAILABLE-FILES is the list of all available test files.
+FOCUSED-FILES is the current list of focused files.
+Returns: \\='success if added successfully,
+ \\='already-focused if file is already focused,
+ \\='not-available if file is not in available-files."
+ (cond
+ ((not (member filename available-files)) 'not-available)
+ ((member filename focused-files) 'already-focused)
+ (t 'success)))
(defun cj/test-focus-add ()
"Select test file(s) to add to the focused list."
@@ -105,27 +176,63 @@ Falls back to =cj/test-global-directory= if not found or not in a project."
unfocused-files
nil t)
(user-error "All test files are already focused"))))
- (push selected cj/test-focused-files)
- (message "Added to focus: %s" selected)
- (when (called-interactively-p 'interactive)
- (cj/test-view-focused))))))
+ (pcase (cj/test--do-focus-add selected available-files cj/test-focused-files)
+ ('success
+ (push selected cj/test-focused-files)
+ (message "Added to focus: %s" selected)
+ (when (called-interactively-p 'interactive)
+ (cj/test-view-focused)))
+ ('already-focused
+ (message "Already focused: %s" selected))
+ ('not-available
+ (user-error "File not available: %s" selected)))))))
+
+(defun cj/test--do-focus-add-file (filepath testdir focused-files)
+ "Validate and add FILEPATH to focused list.
+TESTDIR is the test directory path.
+FOCUSED-FILES is the current list of focused files.
+Returns: \\='success if added successfully,
+ \\='no-file if filepath is nil,
+ \\='not-in-testdir if file is not inside test directory,
+ \\='already-focused if file is already focused.
+Second value is the relative filename if successful."
+ (cond
+ ((null filepath) (cons 'no-file nil))
+ ((not (string-prefix-p (file-truename testdir) (file-truename filepath)))
+ (cons 'not-in-testdir nil))
+ (t
+ (let ((relative (file-relative-name filepath testdir)))
+ (if (member relative focused-files)
+ (cons 'already-focused relative)
+ (cons 'success relative))))))
(defun cj/test-focus-add-this-buffer-file ()
"Add the current buffer's file to the focused test list."
(interactive)
(let ((file (buffer-file-name))
(dir (cj/test--get-test-directory)))
- (unless file
- (user-error "Current buffer is not visiting a file"))
- (unless (string-prefix-p (file-truename dir) (file-truename file))
- (user-error "File is not inside the test directory: %s" dir))
- (let ((relative (file-relative-name file dir)))
- (if (member relative cj/test-focused-files)
- (message "Already focused: %s" relative)
- (push relative cj/test-focused-files)
- (message "Added to focus: %s" relative)
- (when (called-interactively-p 'interactive)
- (cj/test-view-focused))))))
+ (pcase (cj/test--do-focus-add-file file dir cj/test-focused-files)
+ (`(no-file . ,_)
+ (user-error "Current buffer is not visiting a file"))
+ (`(not-in-testdir . ,_)
+ (user-error "File is not inside the test directory: %s" dir))
+ (`(already-focused . ,relative)
+ (message "Already focused: %s" relative))
+ (`(success . ,relative)
+ (push relative cj/test-focused-files)
+ (message "Added to focus: %s" relative)
+ (when (called-interactively-p 'interactive)
+ (cj/test-view-focused))))))
+
+(defun cj/test--do-focus-remove (filename focused-files)
+ "Remove FILENAME from FOCUSED-FILES.
+Returns: \\='success if removed successfully,
+ \\='empty-list if focused-files is empty,
+ \\='not-found if filename is not in focused-files."
+ (cond
+ ((null focused-files) 'empty-list)
+ ((not (member filename focused-files)) 'not-found)
+ (t 'success)))
(defun cj/test-focus-remove ()
"Remove a test file from the focused list."
@@ -135,11 +242,17 @@ Falls back to =cj/test-global-directory= if not found or not in a project."
(let ((selected (completing-read "Remove from focus: "
cj/test-focused-files
nil t)))
- (setq cj/test-focused-files
- (delete selected cj/test-focused-files))
- (message "Removed from focus: %s" selected)
- (when (called-interactively-p 'interactive)
- (cj/test-view-focused)))))
+ (pcase (cj/test--do-focus-remove selected cj/test-focused-files)
+ ('success
+ (setq cj/test-focused-files
+ (delete selected cj/test-focused-files))
+ (message "Removed from focus: %s" selected)
+ (when (called-interactively-p 'interactive)
+ (cj/test-view-focused)))
+ ('not-found
+ (message "File not in focused list: %s" selected))
+ ('empty-list
+ (user-error "No focused files to remove"))))))
(defun cj/test-focus-clear ()
"Clear all focused test files."
@@ -161,31 +274,40 @@ Returns a list of test name symbols defined in the file."
(push (match-string 1) test-names)))
test-names))
+(defun cj/test--do-get-focused-tests (focused-files test-dir)
+ "Get test names from FOCUSED-FILES in TEST-DIR.
+Returns: (cons \\='success (list test-names loaded-count)) if successful,
+ (cons \\='no-tests nil) if no tests found,
+ (cons \\='empty-list nil) if focused-files is empty."
+ (if (null focused-files)
+ (cons 'empty-list nil)
+ (let ((all-test-names '())
+ (loaded-count 0))
+ (dolist (file focused-files)
+ (let ((full-path (expand-file-name file test-dir)))
+ (when (file-exists-p full-path)
+ (load-file full-path)
+ (setq loaded-count (1+ loaded-count))
+ (let ((test-names (cj/test--extract-test-names full-path)))
+ (setq all-test-names (append all-test-names test-names))))))
+ (if (null all-test-names)
+ (cons 'no-tests nil)
+ (cons 'success (list all-test-names loaded-count))))))
+
(defun cj/test-run-focused ()
"Run only the focused test files."
(interactive)
- (if (null cj/test-focused-files)
- (user-error "No focused files set. Use =cj/test-focus-add' first")
- (let ((all-test-names '())
- (loaded-count 0)
- (dir (cj/test--get-test-directory)))
- ;; Load the focused files and collect their test names
- (dolist (file cj/test-focused-files)
- (let ((full-path (expand-file-name file dir)))
- (when (file-exists-p full-path)
- (load-file full-path)
- (setq loaded-count (1+ loaded-count))
- ;; Extract test names from this file
- (let ((test-names (cj/test--extract-test-names full-path)))
- (setq all-test-names (append all-test-names test-names))))))
- (if (null all-test-names)
- (message "No tests found in focused files")
- ;; Build a regexp that matches any of our test names
- (let ((pattern (regexp-opt all-test-names)))
- (message "Running %d test(s) from %d focused file(s)"
- (length all-test-names) loaded-count)
- ;; Run only the tests we found
- (ert (concat "^" pattern "$")))))))
+ (let ((dir (cj/test--get-test-directory)))
+ (pcase (cj/test--do-get-focused-tests cj/test-focused-files dir)
+ (`(empty-list . ,_)
+ (user-error "No focused files set. Use =cj/test-focus-add' first"))
+ (`(no-tests . ,_)
+ (message "No tests found in focused files"))
+ (`(success ,test-names ,loaded-count)
+ (let ((pattern (regexp-opt test-names)))
+ (message "Running %d test(s) from %d focused file(s)"
+ (length test-names) loaded-count)
+ (ert (concat "^" pattern "$")))))))
(defun cj/test--ensure-test-dir-in-load-path ()
"Ensure the directory returned by cj/test--get-test-directory is in `load-path`."
@@ -193,23 +315,28 @@ Returns a list of test name symbols defined in the file."
(when (and dir (file-directory-p dir))
(add-to-list 'load-path dir))))
+(defun cj/test--extract-test-at-pos ()
+ "Extract test name at current position.
+Returns: test name symbol if found, nil otherwise."
+ (save-excursion
+ (beginning-of-defun)
+ (condition-case nil
+ (let ((form (read (current-buffer))))
+ (when (and (listp form)
+ (eq (car form) 'ert-deftest)
+ (symbolp (cadr form)))
+ (cadr form)))
+ (error nil))))
+
(defun cj/run-test-at-point ()
"Run the ERT test at point.
If point is inside an `ert-deftest` definition, run that test only.
Otherwise, message that no test is found."
(interactive)
- (let ((original-point (point)))
- (save-excursion
- (beginning-of-defun)
- (condition-case nil
- (let ((form (read (current-buffer))))
- (if (and (listp form)
- (eq (car form) 'ert-deftest)
- (symbolp (cadr form)))
- (ert (cadr form))
- (message "Not in an ERT test method.")))
- (error (message "No ERT test methods found at point."))))
- (goto-char original-point)))
+ (let ((test-name (cj/test--extract-test-at-pos)))
+ (if test-name
+ (ert test-name)
+ (message "Not in an ERT test method."))))
(defun cj/test-run-all ()
"Load and run all tests."
@@ -252,8 +379,20 @@ Otherwise, message that no test is found."
"t" #'cj/test-toggle-mode)
(keymap-set cj/custom-keymap "t" cj/testrunner-map)
+
+;; which-key integration
(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-; t" "test runner menu"))
+ (which-key-add-key-based-replacements
+ "C-; t" "test runner menu"
+ "C-; t L" "load all tests"
+ "C-; t R" "run all tests"
+ "C-; t r" "run smart"
+ "C-; t ." "run test at point"
+ "C-; t a" "add to focus"
+ "C-; t b" "add buffer to focus"
+ "C-; t c" "clear focus"
+ "C-; t v" "view focused"
+ "C-; t t" "toggle mode"))
(provide 'test-runner)
;;; test-runner.el ends here
diff --git a/tests/test-test-runner.el b/tests/test-test-runner.el
new file mode 100644
index 00000000..0edc0d65
--- /dev/null
+++ b/tests/test-test-runner.el
@@ -0,0 +1,359 @@
+;;; test-test-runner.el --- Tests for test-runner.el -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for test-runner.el - ERT test runner with focus/unfocus workflow.
+;;
+;; Testing approach:
+;; - Tests focus on internal `cj/test--do-*` functions (pure business logic)
+;; - File system operations use temp directories
+;; - Tests are isolated with setup/teardown
+;; - Tests verify return values, not user messages
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Load the module (ignore keymap error in batch mode)
+(condition-case nil
+ (require 'test-runner)
+ (error nil))
+
+;;; Test Utilities
+
+(defvar test-testrunner--temp-dir nil
+ "Temporary directory for test files during tests.")
+
+(defvar test-testrunner--original-focused-files nil
+ "Backup of focused files list before test.")
+
+(defun test-testrunner-setup ()
+ "Setup test environment before each test."
+ ;; Backup current state
+ (setq test-testrunner--original-focused-files cj/test-focused-files)
+ ;; Reset to clean state
+ (setq cj/test-focused-files '())
+ ;; Create temp directory for file tests
+ (setq test-testrunner--temp-dir (make-temp-file "test-runner-test" t)))
+
+(defun test-testrunner-teardown ()
+ "Clean up test environment after each test."
+ ;; Restore state
+ (setq cj/test-focused-files test-testrunner--original-focused-files)
+ ;; Clean up temp directory
+ (when (and test-testrunner--temp-dir
+ (file-directory-p test-testrunner--temp-dir))
+ (delete-directory test-testrunner--temp-dir t))
+ (setq test-testrunner--temp-dir nil))
+
+(defun test-testrunner-create-test-file (filename content)
+ "Create test file FILENAME with CONTENT in temp directory."
+ (let ((filepath (expand-file-name filename test-testrunner--temp-dir)))
+ (with-temp-file filepath
+ (insert content))
+ filepath))
+
+;;; Normal Cases - Load Files
+
+(ert-deftest test-testrunner-load-files-success ()
+ "Should successfully load test files."
+ (test-testrunner-setup)
+ (let* ((file1 (test-testrunner-create-test-file "test-simple.el"
+ "(defun test-func () t)"))
+ (file2 (test-testrunner-create-test-file "test-other.el"
+ "(defun other-func () nil)"))
+ (result (cj/test--do-load-files test-testrunner--temp-dir
+ (list file1 file2))))
+ (should (eq (car result) 'success))
+ (should (= (cdr result) 2)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-load-files-with-errors ()
+ "Should handle errors during file loading."
+ (test-testrunner-setup)
+ (let* ((good-file (test-testrunner-create-test-file "test-good.el"
+ "(defun good () t)"))
+ (bad-file (test-testrunner-create-test-file "test-bad.el"
+ "(defun bad ( "))
+ (result (cj/test--do-load-files test-testrunner--temp-dir
+ (list good-file bad-file))))
+ (should (eq (car result) 'error))
+ (should (= (nth 1 result) 1)) ; loaded-count
+ (should (= (length (nth 2 result)) 1))) ; errors list
+ (test-testrunner-teardown))
+
+;;; Normal Cases - Focus Add
+
+(ert-deftest test-testrunner-focus-add-success ()
+ "Should successfully add file to focus."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-focus-add "test-foo.el"
+ '("test-foo.el" "test-bar.el")
+ '())))
+ (should (eq result 'success)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-focus-add-already-focused ()
+ "Should detect already focused file."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-focus-add "test-foo.el"
+ '("test-foo.el" "test-bar.el")
+ '("test-foo.el"))))
+ (should (eq result 'already-focused)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-focus-add-not-available ()
+ "Should detect file not in available list."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-focus-add "test-missing.el"
+ '("test-foo.el" "test-bar.el")
+ '())))
+ (should (eq result 'not-available)))
+ (test-testrunner-teardown))
+
+;;; Normal Cases - Focus Add File
+
+(ert-deftest test-testrunner-focus-add-file-success ()
+ "Should successfully validate and add file to focus."
+ (test-testrunner-setup)
+ (let* ((filepath (expand-file-name "test-foo.el" test-testrunner--temp-dir))
+ (result (cj/test--do-focus-add-file filepath test-testrunner--temp-dir '())))
+ (should (eq (car result) 'success))
+ (should (string= (cdr result) "test-foo.el")))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-focus-add-file-no-file ()
+ "Should detect nil filepath."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-focus-add-file nil test-testrunner--temp-dir '())))
+ (should (eq (car result) 'no-file)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-focus-add-file-not-in-testdir ()
+ "Should detect file outside test directory."
+ (test-testrunner-setup)
+ (let* ((filepath "/tmp/outside-test.el")
+ (result (cj/test--do-focus-add-file filepath test-testrunner--temp-dir '())))
+ (should (eq (car result) 'not-in-testdir)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-focus-add-file-already-focused ()
+ "Should detect already focused file."
+ (test-testrunner-setup)
+ (let* ((filepath (expand-file-name "test-foo.el" test-testrunner--temp-dir))
+ (result (cj/test--do-focus-add-file filepath
+ test-testrunner--temp-dir
+ '("test-foo.el"))))
+ (should (eq (car result) 'already-focused))
+ (should (string= (cdr result) "test-foo.el")))
+ (test-testrunner-teardown))
+
+;;; Normal Cases - Focus Remove
+
+(ert-deftest test-testrunner-focus-remove-success ()
+ "Should successfully remove file from focus."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-focus-remove "test-foo.el" '("test-foo.el" "test-bar.el"))))
+ (should (eq result 'success)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-focus-remove-empty-list ()
+ "Should detect empty focused list."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-focus-remove "test-foo.el" '())))
+ (should (eq result 'empty-list)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-focus-remove-not-found ()
+ "Should detect file not in focused list."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-focus-remove "test-missing.el" '("test-foo.el"))))
+ (should (eq result 'not-found)))
+ (test-testrunner-teardown))
+
+;;; Normal Cases - Get Focused Tests
+
+(ert-deftest test-testrunner-get-focused-tests-success ()
+ "Should extract test names from focused files."
+ (test-testrunner-setup)
+ (let* ((file1 (test-testrunner-create-test-file "test-first.el"
+ "(ert-deftest test-alpha-one () (should t))\n(ert-deftest test-alpha-two () (should t))"))
+ (result (cj/test--do-get-focused-tests '("test-first.el") test-testrunner--temp-dir)))
+ (should (eq (car result) 'success))
+ (should (= (length (nth 1 result)) 2)) ; 2 test names
+ (should (= (nth 2 result) 1))) ; 1 file loaded
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-get-focused-tests-empty-list ()
+ "Should detect empty focused files list."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-get-focused-tests '() test-testrunner--temp-dir)))
+ (should (eq (car result) 'empty-list)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-get-focused-tests-no-tests ()
+ "Should detect when no tests found in files."
+ (test-testrunner-setup)
+ (test-testrunner-create-test-file "test-empty.el" "(defun not-a-test () t)")
+ (let ((result (cj/test--do-get-focused-tests '("test-empty.el") test-testrunner--temp-dir)))
+ (should (eq (car result) 'no-tests)))
+ (test-testrunner-teardown))
+
+;;; Normal Cases - Extract Test Names
+
+(ert-deftest test-testrunner-extract-test-names-simple ()
+ "Should extract test names from file."
+ (test-testrunner-setup)
+ (let* ((file (test-testrunner-create-test-file "test-simple.el"
+ "(ert-deftest test-foo () (should t))\n(ert-deftest test-bar () (should nil))"))
+ (names (cj/test--extract-test-names file)))
+ (should (= (length names) 2))
+ (should (member "test-foo" names))
+ (should (member "test-bar" names)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-extract-test-names-with-whitespace ()
+ "Should extract test names with various whitespace."
+ (test-testrunner-setup)
+ (let* ((file (test-testrunner-create-test-file "test-whitespace.el"
+ "(ert-deftest test-spaces () (should t))\n (ert-deftest test-indent () t)"))
+ (names (cj/test--extract-test-names file)))
+ (should (= (length names) 2))
+ (should (member "test-spaces" names))
+ (should (member "test-indent" names)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-extract-test-names-no-tests ()
+ "Should return empty list when no tests in file."
+ (test-testrunner-setup)
+ (let* ((file (test-testrunner-create-test-file "test-none.el"
+ "(defun not-a-test () t)"))
+ (names (cj/test--extract-test-names file)))
+ (should (null names)))
+ (test-testrunner-teardown))
+
+;;; Normal Cases - Extract Test at Position
+
+(ert-deftest test-testrunner-extract-test-at-pos-found ()
+ "Should extract test name at point."
+ (test-testrunner-setup)
+ (with-temp-buffer
+ (insert "(ert-deftest test-sample ()\n (should t))")
+ (goto-char (point-min))
+ (let ((name (cj/test--extract-test-at-pos)))
+ (should (eq name 'test-sample))))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-extract-test-at-pos-not-found ()
+ "Should return nil when not in a test."
+ (test-testrunner-setup)
+ (with-temp-buffer
+ (insert "(defun regular-function ()\n (message \"hi\"))")
+ (goto-char (point-min))
+ (let ((name (cj/test--extract-test-at-pos)))
+ (should (null name))))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-extract-test-at-pos-invalid-syntax ()
+ "Should return nil for invalid syntax."
+ (test-testrunner-setup)
+ (with-temp-buffer
+ (insert "(ert-deftest")
+ (goto-char (point-min))
+ (let ((name (cj/test--extract-test-at-pos)))
+ (should (null name))))
+ (test-testrunner-teardown))
+
+;;; Boundary Cases - Load Files
+
+(ert-deftest test-testrunner-load-files-empty-list ()
+ "Should handle empty file list."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-load-files test-testrunner--temp-dir '())))
+ (should (eq (car result) 'success))
+ (should (= (cdr result) 0)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-load-files-nonexistent ()
+ "Should handle nonexistent files."
+ (test-testrunner-setup)
+ (let* ((fake-file (expand-file-name "nonexistent.el" test-testrunner--temp-dir))
+ (result (cj/test--do-load-files test-testrunner--temp-dir (list fake-file))))
+ (should (eq (car result) 'error))
+ (should (= (nth 1 result) 0))) ; 0 files loaded
+ (test-testrunner-teardown))
+
+;;; Boundary Cases - Focus Add
+
+(ert-deftest test-testrunner-focus-add-single-available ()
+ "Should add when only one file available."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-focus-add "test-only.el" '("test-only.el") '())))
+ (should (eq result 'success)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-focus-add-case-sensitive ()
+ "Should be case-sensitive for filenames."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-focus-add "Test-Foo.el"
+ '("test-foo.el")
+ '())))
+ (should (eq result 'not-available)))
+ (test-testrunner-teardown))
+
+;;; Boundary Cases - Get Focused Tests
+
+(ert-deftest test-testrunner-get-focused-tests-multiple-files ()
+ "Should collect tests from multiple files."
+ (test-testrunner-setup)
+ (test-testrunner-create-test-file "test-first.el"
+ "(ert-deftest test-beta-one () t)")
+ (test-testrunner-create-test-file "test-second.el"
+ "(ert-deftest test-beta-two () t)")
+ (let ((result (cj/test--do-get-focused-tests '("test-first.el" "test-second.el")
+ test-testrunner--temp-dir)))
+ (should (eq (car result) 'success))
+ (should (= (length (nth 1 result)) 2)) ; 2 tests total
+ (should (= (nth 2 result) 2))) ; 2 files loaded
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-get-focused-tests-skip-nonexistent ()
+ "Should skip nonexistent files."
+ (test-testrunner-setup)
+ (test-testrunner-create-test-file "test-exists.el"
+ "(ert-deftest test-gamma-one () t)")
+ (let ((result (cj/test--do-get-focused-tests '("test-exists.el" "test-missing.el")
+ test-testrunner--temp-dir)))
+ (should (eq (car result) 'success))
+ (should (= (length (nth 1 result)) 1)) ; 1 test found
+ (should (= (nth 2 result) 1))) ; 1 file loaded (missing skipped)
+ (test-testrunner-teardown))
+
+;;; Boundary Cases - Extract Test Names
+
+(ert-deftest test-testrunner-extract-test-names-hyphens-underscores ()
+ "Should handle test names with hyphens and underscores."
+ (test-testrunner-setup)
+ (let* ((file (test-testrunner-create-test-file "test-names.el"
+ "(ert-deftest test-with-hyphens () t)\n(ert-deftest test_with_underscores () t)"))
+ (names (cj/test--extract-test-names file)))
+ (should (= (length names) 2))
+ (should (member "test-with-hyphens" names))
+ (should (member "test_with_underscores" names)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-extract-test-names-ignore-comments ()
+ "Should not extract test names from comments."
+ (test-testrunner-setup)
+ (let* ((file (test-testrunner-create-test-file "test-comments.el"
+ ";; (ert-deftest test-commented () t)\n(ert-deftest test-real () t)"))
+ (names (cj/test--extract-test-names file)))
+ (should (= (length names) 1))
+ (should (member "test-real" names)))
+ (test-testrunner-teardown))
+
+(provide 'test-test-runner)
+;;; test-test-runner.el ends here