diff options
| -rw-r--r-- | init.el | 31 | ||||
| -rw-r--r-- | modules/ai-conversations.el | 3 | ||||
| -rw-r--r-- | modules/browser-config.el | 81 | ||||
| -rw-r--r-- | modules/flyspell-and-abbrev.el | 2 | ||||
| -rw-r--r-- | modules/font-config.el | 2 | ||||
| -rw-r--r-- | modules/jumper.el | 255 | ||||
| -rw-r--r-- | modules/lipsum-generator.el | 9 | ||||
| -rw-r--r-- | modules/lorem-optimum.el (renamed from modules/lorem-generator.el) | 102 | ||||
| -rw-r--r-- | modules/reconcile-open-repos.el | 1 | ||||
| -rw-r--r-- | modules/test-runner.el | 331 | ||||
| -rw-r--r-- | modules/wip.el | 1 | ||||
| -rw-r--r-- | tests/test-browser-config.el | 277 | ||||
| -rw-r--r-- | tests/test-jumper.el | 352 | ||||
| -rw-r--r-- | tests/test-lorem-optimum-benchmark.el | 227 | ||||
| -rw-r--r-- | tests/test-lorem-optimum.el | 242 | ||||
| -rw-r--r-- | tests/test-test-runner.el | 359 |
16 files changed, 2002 insertions, 273 deletions
@@ -1,4 +1,4 @@ -w;;; init.el --- Emacs Init File -*- lexical-binding: t; coding: utf-8; -*- +;;; init.el --- Emacs Init File -*- lexical-binding: t; coding: utf-8; -*- ;; author: Craig Jennings <c@cjennings.net> ;;; Commentary: @@ -27,24 +27,25 @@ w;;; init.el --- Emacs Init File -*- lexical-binding: t; coding: utf-8; -*- ;; -------------------------- Utilities And Libraries -------------------------- (require 'custom-case) ;; operations for upper/lower/title case -(require 'custom-comments) ;; operations with comments (tests done) -(require 'custom-datetime) ;; date/timestamp insertion in various formats (too trivial) -(require 'custom-file-buffer) ;; custom buffer and file operations and keymap (tests done) -(require 'custom-line-paragraph) ;; operations on lines and paragraphs (tests done) -(require 'custom-misc) ;; miscellaneous functions (tests done) -(require 'custom-ordering) ;; ordering and sorting operations (tests done) -(require 'custom-text-enclose) ;; operations to append, prepend, and surround text (tests done) -(require 'custom-whitespace) ;; whitespace operations (tests done) +(require 'custom-comments) ;; operations with comments +(require 'custom-datetime) ;; date/timestamp insertion in various formats +(require 'custom-file-buffer) ;; custom buffer and file operations and keymap +(require 'custom-line-paragraph) ;; operations on lines and paragraphs +(require 'custom-misc) ;; miscellaneous functions +(require 'custom-ordering) ;; ordering and sorting operations +(require 'custom-text-enclose) ;; operations to append, prepend, and surround text +(require 'custom-whitespace) ;; whitespace operations (require 'external-open) ;; files to open outside of Emacs (require 'media-utils) ;; download and play urls ;; ------------------------- System Level Functionality ------------------------ (require 'auth-config) ;; emacs gnupg integration -(require 'keyboard-macros) ;; keyboard macro management (tests done) +(require 'keyboard-macros) ;; keyboard macro management (require 'system-utils) ;; timers, process monitor (require 'text-config) ;; text settings and functionality -(require 'undead-buffers) ;; bury rather than kill buffers you choose (tests done) +(require 'undead-buffers) ;; bury rather than kill buffers you choose +(require 'browser-config) ;; browser configuration/integration ;; ------------------------ User Interface Configuration ----------------------- @@ -112,7 +113,7 @@ w;;; init.el --- Emacs Init File -*- lexical-binding: t; coding: utf-8; -*- (require 'org-export-config) (require 'org-gcal-config) (require 'org-refile-config) ;; refile org-branches -(require 'org-roam-config) ;; personal knowledge management in org mode (tests added) +(require 'org-roam-config) ;; personal knowledge management in org mode (require 'org-webclipper) ;; "instapaper" to org-roam workflow ;; (require 'org-noter-config) ;; wip @@ -142,10 +143,10 @@ w;;; init.el --- Emacs Init File -*- lexical-binding: t; coding: utf-8; -*- (require 'games-config) ;; ------------------------------ Modules In Test ------------------------------ -(require 'browser-config) + ;;(require 'wip) -;;(require 'lipsum-generator) -;;(require 'jumper) +(require 'lorem-optimum) +(require 'jumper) ;; ---------------------------------- Wrap Up ---------------------------------- diff --git a/modules/ai-conversations.el b/modules/ai-conversations.el index 92549176..4f97d761 100644 --- a/modules/ai-conversations.el +++ b/modules/ai-conversations.el @@ -159,7 +159,6 @@ Expect FILENAME to match _YYYYMMDD-HHMMSS.gptel." (or (get-buffer buf-name) (user-error "Could not create or find *AI-Assistant* buffer")))) -;;;###autoload (defun cj/gptel-save-conversation () "Save the current AI-Assistant buffer to a .gptel file. @@ -188,7 +187,6 @@ Enable autosave for subsequent AI responses to the same file." (setq-local cj/gptel-autosave-enabled t)) (message "Conversation saved to: %s" filepath)))) -;;;###autoload (defun cj/gptel-delete-conversation () "Delete a saved GPTel conversation file (chronologically sorted candidates)." (interactive) @@ -218,7 +216,6 @@ Enable autosave for subsequent AI responses to the same file." (when (looking-at "^\n+") (delete-region (point) (match-end 0))))) -;;;###autoload (defun cj/gptel-load-conversation () "Load a saved GPTel conversation into the AI-Assistant buffer. diff --git a/modules/browser-config.el b/modules/browser-config.el index fddc02e6..52c3b8a6 100644 --- a/modules/browser-config.el +++ b/modules/browser-config.el @@ -80,19 +80,44 @@ Returns the browser plist if found, nil otherwise." cj/saved-browser-choice)) (error nil)))) -(defun cj/apply-browser-choice (browser-plist) - "Apply the browser settings from BROWSER-PLIST." - (when browser-plist +(defun cj/--do-apply-browser-choice (browser-plist) + "Apply the browser settings from BROWSER-PLIST. +Returns: \\='success if applied successfully, + \\='invalid-plist if browser-plist is nil or missing required keys." + (if (null browser-plist) + 'invalid-plist (let ((browse-fn (plist-get browser-plist :function)) (executable (plist-get browser-plist :executable)) (path (plist-get browser-plist :path)) (program-var (plist-get browser-plist :program-var))) - ;; Set the main browse-url function - (setq browse-url-browser-function browse-fn) - ;; Set the specific browser program variable if it exists - (when program-var - (set program-var (or path executable))) - (message "Default browser set to: %s" (plist-get browser-plist :name))))) + (if (null browse-fn) + 'invalid-plist + ;; Set the main browse-url function + (setq browse-url-browser-function browse-fn) + ;; Set the specific browser program variable if it exists + (when program-var + (set program-var (or path executable))) + 'success)))) + +(defun cj/apply-browser-choice (browser-plist) + "Apply the browser settings from BROWSER-PLIST." + (pcase (cj/--do-apply-browser-choice browser-plist) + ('success (message "Default browser set to: %s" (plist-get browser-plist :name))) + ('invalid-plist (message "Invalid browser configuration")))) + +(defun cj/--do-choose-browser (browser-plist) + "Save and apply BROWSER-PLIST as the default browser. +Returns: \\='success if browser was saved and applied, + \\='save-failed if save operation failed, + \\='invalid-plist if browser-plist is invalid." + (condition-case _err + (progn + (cj/save-browser-choice browser-plist) + (let ((result (cj/--do-apply-browser-choice browser-plist))) + (if (eq result 'success) + 'success + 'invalid-plist))) + (error 'save-failed))) (defun cj/choose-browser () "Interactively choose a browser from available options. @@ -107,21 +132,39 @@ Persists the choice for future sessions." (string= (plist-get b :name) choice)) browsers))) (when selected - (cj/save-browser-choice selected) - (cj/apply-browser-choice selected)))))) + (pcase (cj/--do-choose-browser selected) + ('success (message "Default browser set to: %s" (plist-get selected :name))) + ('save-failed (message "Failed to save browser choice")) + ('invalid-plist (message "Invalid browser configuration")))))))) ;; Initialize: Load saved choice or use first available browser -(defun cj/initialize-browser () - "Initialize browser configuration on startup." +(defun cj/--do-initialize-browser () + "Initialize browser configuration. +Returns: (cons \\='loaded browser-plist) if saved choice was loaded, + (cons \\='first-available browser-plist) if using first discovered browser, + (cons \\='no-browsers nil) if no browsers found." (let ((saved-choice (cj/load-browser-choice))) (if saved-choice - (cj/apply-browser-choice saved-choice) - ;; No saved choice - try to set first available browser silently + (cons 'loaded saved-choice) + ;; No saved choice - try to set first available browser (let ((browsers (cj/discover-browsers))) - (when browsers - (cj/apply-browser-choice (car browsers)) - (message "No browser configured. Using %s. Run M-x cj/choose-browser to change." - (plist-get (car browsers) :name))))))) + (if browsers + (cons 'first-available (car browsers)) + (cons 'no-browsers nil)))))) + +(defun cj/initialize-browser () + "Initialize browser configuration on startup." + (let ((result (cj/--do-initialize-browser))) + (pcase (car result) + ('loaded + (cj/--do-apply-browser-choice (cdr result))) + ('first-available + (let ((browser (cdr result))) + (cj/--do-apply-browser-choice browser) + (message "No browser configured. Using %s. Run M-x cj/choose-browser to change." + (plist-get browser :name)))) + ('no-browsers + (message "No supported browsers found"))))) ;; Run initialization (cj/initialize-browser) diff --git a/modules/flyspell-and-abbrev.el b/modules/flyspell-and-abbrev.el index 12e0d348..379fc7b2 100644 --- a/modules/flyspell-and-abbrev.el +++ b/modules/flyspell-and-abbrev.el @@ -111,7 +111,6 @@ ;; ------------------------------ Flyspell Toggle ------------------------------ ;; easy toggling flyspell and also leverage the 'for-buffer-type' functionality. -;;;###autoload (defun cj/flyspell-toggle () "Turn Flyspell on if it is off, or off if it is on. @@ -198,7 +197,6 @@ buffer." (downcase misspelled-word) nil))) -;;;###autoload (defun cj/flyspell-then-abbrev (p) "Find and correct the previous misspelled word, creating an abbrev. diff --git a/modules/font-config.el b/modules/font-config.el index 1541f55f..eea09da6 100644 --- a/modules/font-config.el +++ b/modules/font-config.el @@ -142,7 +142,6 @@ If FRAME is nil, uses the selected frame." ;; ----------------------------- Font Install Check ---------------------------- ;; convenience function to indicate whether a font is available by name. -;;;###autoload (defun cj/font-installed-p (font-name) "Check if font with FONT-NAME is available." (if (find-font (font-spec :name font-name)) @@ -224,7 +223,6 @@ If FRAME is nil, uses the selected frame." ;; -------------------------- Display Available Fonts -------------------------- ;; display all available fonts on the system in a side panel -;;;###autoload (defun cj/display-available-fonts () "Display a list of all font faces with sample text in another read-only buffer." (interactive) diff --git a/modules/jumper.el b/modules/jumper.el index e1025472..67d930aa 100644 --- a/modules/jumper.el +++ b/modules/jumper.el @@ -10,24 +10,76 @@ ;; Jumper provides a simple way to store and jump between locations ;; in your codebase without needing to remember register assignments. +;; +;; PURPOSE: +;; +;; When working on large codebases, you often need to jump between +;; multiple related locations: a function definition, its tests, its +;; callers, configuration files, etc. Emacs registers are perfect for +;; this, but require you to remember which register you assigned to +;; which location. Jumper automates register management, letting you +;; focus on your work instead of bookkeeping. +;; +;; WORKFLOW: +;; +;; 1. Navigate to an important location in your code +;; 2. Press M-SPC SPC to store it (automatically assigned to register 0) +;; 3. Continue working, storing more locations as needed (registers 1-9) +;; 4. Press M-SPC j to jump back to any stored location +;; 5. Select from the list using completion (shows file, line, context) +;; 6. Press M-SPC d to remove locations you no longer need +;; +;; RECOMMENDED USAGE: +;; +;; Store locations temporarily while working on a feature: +;; - Store the main function you're implementing +;; - Store the test file where you're writing tests +;; - Store the caller that needs updating +;; - Store the documentation that needs changes +;; - Jump between them freely as you work +;; - Clear them when done with the feature +;; +;; SPECIAL BEHAVIORS: +;; +;; - Duplicate prevention: Storing the same location twice shows a message +;; instead of wasting a register slot. +;; +;; - Single location toggle: When only one location is stored, M-SPC j +;; toggles between that location and your current position. Perfect for +;; rapid back-and-forth between two related files. +;; +;; - Last location tracking: The last position before each jump is saved +;; in register 'z', allowing quick "undo" of navigation. +;; +;; - Smart selection: With multiple locations, completing-read shows +;; helpful context: "[0] filename.el:42 - function definition..." +;; +;; KEYBINDINGS: +;; +;; M-SPC SPC Store current location in next available register +;; M-SPC j Jump to a stored location (with completion) +;; M-SPC d Delete a stored location from the list +;; +;; CONFIGURATION: +;; +;; You can customize the prefix key and maximum locations: +;; +;; (setq jumper-prefix-key "C-c j") ; Change prefix key +;; (setq jumper-max-locations 20) ; Store up to 20 locations +;; +;; Note: Changing jumper-max-locations requires restarting Emacs or +;; manually reinitializing jumper--registers. ;;; Code: -(defgroup jumper nil - "Quick navigation between stored locations." - :group 'convenience) +(require 'cl-lib) -(defcustom jumper-prefix-key "M-SPC" +(defvar jumper-prefix-key "M-SPC" "Prefix key for jumper commands. +Note that using M-SPC will override the default binding to just-one-space.") -Note that using M-SPC will override the default binding to just-one-space." - :type 'string - :group 'jumper) - -(defcustom jumper-max-locations 10 - "Maximum number of locations to store." - :type 'integer - :group 'jumper) +(defvar jumper-max-locations 10 + "Maximum number of locations to store.") ;; Internal variables (defvar jumper--registers (make-vector jumper-max-locations nil) @@ -50,12 +102,10 @@ Note that using M-SPC will override the default binding to just-one-space." "Check if current location is already stored." (let ((key (jumper--location-key)) (found nil)) - (dotimes (i - jumper--next-index found) + (dotimes (i jumper--next-index found) (let* ((reg (aref jumper--registers i)) - (pos (get-register reg)) - (marker (and pos (registerv-data pos)))) - (when marker + (marker (get-register reg))) + (when (and marker (markerp marker)) (save-current-buffer (set-buffer (marker-buffer marker)) (save-excursion @@ -70,9 +120,8 @@ Note that using M-SPC will override the default binding to just-one-space." (defun jumper--format-location (index) "Format location at INDEX for display." (let* ((reg (aref jumper--registers index)) - (pos (get-register reg)) - (marker (and pos (registerv-data pos)))) - (when marker + (marker (get-register reg))) + (when (and marker (markerp marker)) (save-current-buffer (set-buffer (marker-buffer marker)) (save-excursion @@ -86,49 +135,83 @@ Note that using M-SPC will override the default binding to just-one-space." (min (+ (line-beginning-position) 40) (line-end-position))))))))) +(defun jumper--do-store-location () + "Store current location in the next free register. +Returns: \\='already-exists if location is already stored, + \\='no-space if all registers are full, + register character if successfully stored." + (cond + ((jumper--location-exists-p) 'already-exists) + ((not (jumper--register-available-p)) 'no-space) + (t + (let ((reg (+ ?0 jumper--next-index))) + (point-to-register reg) + (aset jumper--registers jumper--next-index reg) + (setq jumper--next-index (1+ jumper--next-index)) + reg)))) + (defun jumper-store-location () "Store current location in the next free register." (interactive) - (if (jumper--location-exists-p) - (message "Location already stored") - (if (jumper--register-available-p) - (let ((reg (+ ?0 jumper--next-index))) - (point-to-register reg) - (aset jumper--registers jumper--next-index reg) - (setq jumper--next-index (1+ jumper--next-index)) - (message "Location stored in register %c" reg)) - (message "Sorry - all jump locations are filled!")))) + (pcase (jumper--do-store-location) + ('already-exists (message "Location already stored")) + ('no-space (message "Sorry - all jump locations are filled!")) + (reg (message "Location stored in register %c" reg)))) + +(defun jumper--do-jump-to-location (target-idx) + "Jump to location at TARGET-IDX. +TARGET-IDX: -1 for last location, 0-9 for stored locations, nil for toggle. +Returns: \\='no-locations if no locations stored, + \\='already-there if at the only location (toggle case), + \\='jumped if successfully jumped." + (cond + ((= jumper--next-index 0) 'no-locations) + ;; Toggle behavior when target-idx is nil and only 1 location + ((and (null target-idx) (= jumper--next-index 1)) + (if (jumper--location-exists-p) + 'already-there + (let ((reg (aref jumper--registers 0))) + (point-to-register jumper--last-location-register) + (jump-to-register reg) + 'jumped))) + ;; Jump to specific target + (t + (if (= target-idx -1) + ;; Jumping to last location - don't overwrite it + (jump-to-register jumper--last-location-register) + ;; Jumping to stored location - save current for "last" + (progn + (point-to-register jumper--last-location-register) + (jump-to-register (aref jumper--registers target-idx)))) + 'jumped))) (defun jumper-jump-to-location () "Jump to a stored location." (interactive) - (if (= jumper--next-index 0) - (message "No locations stored") - (if (= jumper--next-index 1) - ;; Special case for one location - toggle behavior - (let ((reg (aref jumper--registers 0))) - (if (jumper--location-exists-p) - (message "You're already at the stored location") - (point-to-register jumper--last-location-register) - (jump-to-register reg) - (message "Jumped to location"))) - ;; Multiple locations - use completing-read - (let* ((locations - (cl-loop for i from 0 below jumper--next-index - for fmt = (jumper--format-location i) - when fmt collect (cons fmt i))) - ;; Add last location if available - (last-pos (get-register jumper--last-location-register)) - (locations (if last-pos - (cons (cons "[z] Last location" -1) locations) - locations)) - (choice (completing-read "Jump to: " locations nil t)) - (idx (cdr (assoc choice locations)))) - (point-to-register jumper--last-location-register) - (if (= idx -1) - (jump-to-register jumper--last-location-register) - (jump-to-register (aref jumper--registers idx))) - (message "Jumped to location"))))) + (cond + ;; No locations + ((= jumper--next-index 0) + (message "No locations stored")) + ;; Single location - toggle + ((= jumper--next-index 1) + (pcase (jumper--do-jump-to-location nil) + ('already-there (message "You're already at the stored location")) + ('jumped (message "Jumped to location")))) + ;; Multiple locations - prompt user + (t + (let* ((locations + (cl-loop for i from 0 below jumper--next-index + for fmt = (jumper--format-location i) + when fmt collect (cons fmt i))) + ;; Add last location if available + (last-pos (get-register jumper--last-location-register)) + (locations (if last-pos + (cons (cons "[z] Last location" -1) locations) + locations)) + (choice (completing-read "Jump to: " locations nil t)) + (idx (cdr (assoc choice locations)))) + (jumper--do-jump-to-location idx) + (message "Jumped to location"))))) (defun jumper--reorder-registers (removed-idx) "Reorder registers after removing the one at REMOVED-IDX." @@ -139,32 +222,40 @@ Note that using M-SPC will override the default binding to just-one-space." (aset jumper--registers i next-reg)))) (setq jumper--next-index (1- jumper--next-index))) +(defun jumper--do-remove-location (index) + "Remove location at INDEX. +Returns: \\='no-locations if no locations stored, + \\='cancelled if index is -1, + t if successfully removed." + (cond + ((= jumper--next-index 0) 'no-locations) + ((= index -1) 'cancelled) + (t + (jumper--reorder-registers index) + t))) + (defun jumper-remove-location () "Remove a stored location." (interactive) (if (= jumper--next-index 0) - (message "No locations stored") - (let* ((locations - (cl-loop for i from 0 below jumper--next-index - for fmt = (jumper--format-location i) - when fmt collect (cons fmt i))) - (locations (cons (cons "Cancel" -1) locations)) - (choice (completing-read "Remove location: " locations nil t)) - (idx (cdr (assoc choice locations)))) - (if (= idx -1) - (message "Operation cancelled") - (jumper--reorder-registers idx) - (message "Location removed"))))) - -(defvar jumper-map - (let ((map (make-sparse-keymap))) - (define-key map (kbd "SPC") #'jumper-store-location) - (define-key map (kbd "j") #'jumper-jump-to-location) - (define-key map (kbd "d") #'jumper-remove-location) - map) - "Keymap for jumper commands.") - -;;;###autoload + (message "No locations stored") + (let* ((locations + (cl-loop for i from 0 below jumper--next-index + for fmt = (jumper--format-location i) + when fmt collect (cons fmt i))) + (locations (cons (cons "Cancel" -1) locations)) + (choice (completing-read "Remove location: " locations nil t)) + (idx (cdr (assoc choice locations)))) + (pcase (jumper--do-remove-location idx) + ('cancelled (message "Operation cancelled")) + ('t (message "Location removed")))))) + +(defvar-keymap jumper-map + :doc "Keymap for jumper commands" + "SPC" #'jumper-store-location + "j" #'jumper-jump-to-location + "d" #'jumper-remove-location) + (defun jumper-setup-keys () "Setup default keybindings for jumper." (interactive) @@ -173,5 +264,13 @@ Note that using M-SPC will override the default binding to just-one-space." ;; Call jumper-setup-keys when the package is loaded (jumper-setup-keys) +;; which-key integration +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "M-SPC" "jumper menu" + "M-SPC SPC" "store location" + "M-SPC j" "jump to location" + "M-SPC d" "remove location")) + (provide 'jumper) ;;; jumper.el ends here. diff --git a/modules/lipsum-generator.el b/modules/lipsum-generator.el index b328b989..11ed8caa 100644 --- a/modules/lipsum-generator.el +++ b/modules/lipsum-generator.el @@ -129,25 +129,21 @@ Defaults to 'liber-primus.txt' in the modules directory." (when candidates (nth (random (length candidates)) candidates)))) -;;;###autoload (defvar cj/lipsum-chain (cj/markov-chain-create) "Global Markov chain for lipsum generation.") -;;;###autoload (defun cj/lipsum-reset () "Reset the global lipsum Markov chain." (interactive) (setq cj/lipsum-chain (cj/markov-chain-create)) (message "cj/lipsum-chain reset.")) -;;;###autoload (defun cj/lipsum-learn-region (beg end) "Learn text from region." (interactive "r") (cj/markov-learn cj/lipsum-chain (buffer-substring-no-properties beg end)) (message "Learned from region.")) -;;;###autoload (defun cj/lipsum-learn-buffer () "Learn from entire buffer." (interactive) @@ -155,7 +151,6 @@ Defaults to 'liber-primus.txt' in the modules directory." (buffer-substring-no-properties (point-min) (point-max))) (message "Learned from buffer.")) -;;;###autoload (defun cj/lipsum-learn-file (file) "Learn from FILE containing plain text." (interactive "fTrain from file: ") @@ -164,12 +159,10 @@ Defaults to 'liber-primus.txt' in the modules directory." (cj/markov-learn cj/lipsum-chain (buffer-string))) (message "Learned from file: %s" file)) -;;;###autoload (defun cj/lipsum (n) "Return N words of lorem ipsum." (cj/markov-generate cj/lipsum-chain n '("Lorem" "ipsum"))) -;;;###autoload (defun cj/lipsum-insert (n) "Insert N words of lorem ipsum at point." (interactive "nNumber of words: ") @@ -181,7 +174,6 @@ Defaults to 'liber-primus.txt' in the modules directory." (defconst cj/lipsum-title-max 8) (defconst cj/lipsum-title-small 3) -;;;###autoload (defun cj/lipsum-title () "Generate a pseudo-Latin title." (interactive) @@ -205,7 +197,6 @@ Defaults to 'liber-primus.txt' in the modules directory." ;;; Paragraphs -;;;###autoload (defun cj/lipsum-paragraphs (count &optional min max) "Insert COUNT paragraphs of lipsum. Each paragraph has a random length between MIN and MAX words. diff --git a/modules/lorem-generator.el b/modules/lorem-optimum.el index 6148dfdc..6ccca55f 100644 --- a/modules/lorem-generator.el +++ b/modules/lorem-optimum.el @@ -1,4 +1,4 @@ -;;; lorem-generator.el --- Fake Latin Text Generator -*- coding: utf-8; lexical-binding: t; -*- +;;; lorem-optimum.el --- Fake Latin Text Generator -*- coding: utf-8; lexical-binding: t; -*- ;; ;; Author: Craig Jennings ;; Version: 0.5 @@ -24,6 +24,19 @@ (require 'cl-lib) +;;; Configuration + +(defvar cj/lipsum-training-file "assets/liber-primus.txt" + "Default training file name (relative to `user-emacs-directory`).") + +(defvar cj/lipsum-default-file + (expand-file-name cj/lipsum-training-file user-emacs-directory) + "Default training file for cj-lipsum. + +This should be a plain UTF-8 text file with hundreds of Latin words +or sentences. By default it points to the file specified in +`cj/lipsum-training-file` relative to `user-emacs-directory`.") + (cl-defstruct (cj/markov-chain (:constructor cj/markov-chain-create)) "An order-two Markov chain." @@ -31,25 +44,45 @@ (keys nil)) (defun cj/markov-tokenize (text) - "Split TEXT into tokens: words and punctuation separately." - (let ((case-fold-search nil)) - (split-string text "\\b" t "[[:space:]\n]+"))) - + "Split TEXT into tokens: words and punctuation separately. +Returns a list of words and punctuation marks as separate tokens." + (let ((tokens '()) + (pos 0) + (len (length text))) + (while (< pos len) + (cond + ;; Skip whitespace + ((string-match-p "[[:space:]]" (substring text pos (1+ pos))) + (setq pos (1+ pos))) + ;; Match word (sequence of alphanumeric characters) + ((string-match "\\`\\([[:alnum:]]+\\)" (substring text pos)) + (let ((word (match-string 1 (substring text pos)))) + (push word tokens) + (setq pos (+ pos (length word))))) + ;; Match punctuation (single character) + ((string-match "\\`\\([[:punct:]]\\)" (substring text pos)) + (let ((punct (match-string 1 (substring text pos)))) + (push punct tokens) + (setq pos (+ pos (length punct))))) + ;; Skip any other character + (t (setq pos (1+ pos))))) + (nreverse tokens))) (defun cj/markov-learn (chain text) "Add TEXT into the Markov CHAIN with tokenized input." - (let* ((words (cj/markov-tokenize text)) + (let* ((word-list (cj/markov-tokenize text)) + ;; Convert to vector for O(1) access instead of O(n) with nth + (words (vconcat word-list)) (len (length words))) (cl-loop for i from 0 to (- len 3) - for a = (nth i words) - for b = (nth (1+ i) words) - for c = (nth (+ i 2) words) + for a = (aref words i) + for b = (aref words (1+ i)) + for c = (aref words (+ i 2)) do (let* ((bigram (list a b)) (nexts (gethash bigram (cj/markov-chain-map chain)))) (puthash bigram (cons c nexts) (cj/markov-chain-map chain))))) - (setf (cj/markov-chain-keys chain) - (cl-loop for k being the hash-keys of (cj/markov-chain-map chain) - collect k))) + ;; Invalidate cached keys after learning new data + (setf (cj/markov-chain-keys chain) nil)) (defun cj/markov-fix-capitalization (sentence) "Capitalize the first word and the first word after .!? in SENTENCE." @@ -94,7 +127,7 @@ (defun cj/markov-generate (chain n &optional start) "Generate a sentence of N tokens from CHAIN." - (when (cj/markov-chain-keys chain) + (when (> (hash-table-count (cj/markov-chain-map chain)) 0) (let* ((state (or (and start (gethash start (cj/markov-chain-map chain)) start) @@ -116,33 +149,37 @@ (cj/markov-join-tokens tokens)))) (defun cj/markov-random-key (chain) - (nth (random (length (cj/markov-chain-keys chain))) - (cj/markov-chain-keys chain))) + "Return a random bigram key from CHAIN. +Builds and caches the keys list lazily if not already cached." + (unless (cj/markov-chain-keys chain) + ;; Lazily build keys list only when needed + (setf (cj/markov-chain-keys chain) + (cl-loop for k being the hash-keys of (cj/markov-chain-map chain) + collect k))) + (let ((keys (cj/markov-chain-keys chain))) + (when keys + (nth (random (length keys)) keys)))) (defun cj/markov-next-word (chain bigram) (let ((candidates (gethash bigram (cj/markov-chain-map chain)))) (when candidates (nth (random (length candidates)) candidates)))) -;;;###autoload (defvar cj/lipsum-chain (cj/markov-chain-create) "Global Markov chain for lipsum generation.") -;;;###autoload (defun cj/lipsum-reset () "Reset the global lipsum Markov chain." (interactive) (setq cj/lipsum-chain (cj/markov-chain-create)) (message "cj/lipsum-chain reset.")) -;;;###autoload (defun cj/lipsum-learn-region (beg end) "Learn text from region." (interactive "r") (cj/markov-learn cj/lipsum-chain (buffer-substring-no-properties beg end)) (message "Learned from region.")) -;;;###autoload (defun cj/lipsum-learn-buffer () "Learn from entire buffer." (interactive) @@ -150,7 +187,6 @@ (buffer-substring-no-properties (point-min) (point-max))) (message "Learned from buffer.")) -;;;###autoload (defun cj/lipsum-learn-file (file) "Learn from FILE containing plain text." (interactive "fTrain from file: ") @@ -159,12 +195,10 @@ (cj/markov-learn cj/lipsum-chain (buffer-string))) (message "Learned from file: %s" file)) -;;;###autoload (defun cj/lipsum (n) "Return N words of lorem ipsum." (cj/markov-generate cj/lipsum-chain n '("Lorem" "ipsum"))) -;;;###autoload (defun cj/lipsum-insert (n) "Insert N words of lorem ipsum at point." (interactive "nNumber of words: ") @@ -176,7 +210,6 @@ (defconst cj/lipsum-title-max 8) (defconst cj/lipsum-title-small 3) -;;;###autoload (defun cj/lipsum-title () "Generate a pseudo-Latin title." (interactive) @@ -190,6 +223,7 @@ (or (cj/markov-next-word cj/lipsum-chain state) (cadr (cj/markov-random-key cj/lipsum-chain)))))) collect (replace-regexp-in-string "^[[:punct:]]+\\|[[:punct:]]+$" "" w)))) + ;; Filter empty strings from generated words (setq words (cl-remove-if #'string-empty-p words)) (mapconcat (lambda (word idx) @@ -200,7 +234,6 @@ ;;; Paragraphs -;;;###autoload (defun cj/lipsum-paragraphs (count &optional min max) "Insert COUNT paragraphs of lipsum. @@ -213,23 +246,6 @@ Defaults: MIN=30, MAX=80." (let ((len (+ min (random (1+ (- max min)))))) (insert (cj/lipsum len) "\n\n"))))) -;;; Customization - -(defgroup cj-lipsum nil - "Pseudo-Latin lorem ipsum text generator." - :prefix "cj/lipsum-" - :group 'text) - -(defcustom cj/lipsum-default-file - (expand-file-name "latin.txt" - (file-name-directory (or load-file-name buffer-file-name))) - "Default training file for cj-lipsum. - -This should be a plain UTF-8 text file with hundreds of Latin words -or sentences. By default it points to the bundled `latin.txt`." - :type 'file - :group 'cj-lipsum) - ;;; Initialization: train on default file (defun cj/lipsum--init () "Initialize cj-lipsum by learning from `cj/lipsum-default-file`." @@ -240,5 +256,5 @@ or sentences. By default it points to the bundled `latin.txt`." (cj/lipsum--init) -(provide 'lorem-generator) -;;; lorem-generator.el ends here. +(provide 'lorem-optimum) +;;; lorem-optimum.el ends here. diff --git a/modules/reconcile-open-repos.el b/modules/reconcile-open-repos.el index 648de222..2e48e45d 100644 --- a/modules/reconcile-open-repos.el +++ b/modules/reconcile-open-repos.el @@ -73,7 +73,6 @@ Magit for review." ;; ---------------------------- Check For Open Work ---------------------------- -;;;###autoload (defun cj/check-for-open-work () "Check all project directories for open work." (interactive) diff --git a/modules/test-runner.el b/modules/test-runner.el index b4c40820..125a8d20 100644 --- a/modules/test-runner.el +++ b/modules/test-runner.el @@ -2,26 +2,75 @@ ;; 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: ;; -;; 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. +;; - 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 ;; -;; The default mode is to load and run all tests. +;; SPECIAL BEHAVIORS: ;; -;; 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 +;; - 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 ;; -;; Don't forget to run all tests again in default mode at least once before finishing. +;; KEYBINDINGS: +;; +;; 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 +;; +;; RECOMMENDED USAGE: +;; +;; 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 +;; +;; 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: (require 'ert) (require 'cl-lib) +;;; External Variables and Functions + +(defvar cj/custom-keymap) ; Defined in init.el +(declare-function projectile-project-root "projectile" ()) + ;;; Variables (defvar cj/test-global-directory nil @@ -35,19 +84,19 @@ 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.") ;;; Core Functions -;;;###autoload (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))) @@ -60,15 +109,32 @@ Falls back to =cj/test-global-directory= if not found or not in a project." ((file-directory-p tests-dir) tests-dir) (t cj/test-global-directory)))))) -;;;###autoload (defun cj/test--get-test-files () - "Return a list of test file names (without path) in the appropriate test directory." + "Return list of test file names (without path) in test directory." (let ((dir (cj/test--get-test-directory))) (when (file-directory-p dir) (mapcar #'file-name-nondirectory (directory-files dir t "^test-.*\\.el$"))))) -;;;###autoload +(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) @@ -76,21 +142,27 @@ 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)))) - -;;;###autoload + (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." (interactive) @@ -109,30 +181,64 @@ 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)))))) -;;;###autoload (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)))))) - -;;;###autoload + (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." (interactive) @@ -141,13 +247,18 @@ 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")))))) -;;;###autoload (defun cj/test-focus-clear () "Clear all focused test files." (interactive) @@ -168,73 +279,82 @@ Returns a list of test name symbols defined in the file." (push (match-string 1) test-names))) test-names)) -;;;###autoload +(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`." + "Ensure test directory is in `load-path'." (let ((dir (cj/test--get-test-directory))) (when (and dir (file-directory-p dir)) (add-to-list 'load-path dir)))) -;;;###autoload +(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))) - -;;;###autoload + (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." (interactive) (cj/test-load-all) (ert t)) -;;;###autoload (defun cj/test-toggle-mode () - "Toggle between 'all and 'focused test execution modes." + "Toggle between \\='all and \\='focused test execution modes." (interactive) (setq cj/test-mode (if (eq cj/test-mode 'all) 'focused 'all)) (message "Test mode: %s" cj/test-mode)) -;;;###autoload (defun cj/test-view-focused () "Display test files in focus." (interactive) @@ -243,7 +363,6 @@ Otherwise, message that no test is found." (message "Focused files: %s" (mapconcat 'identity cj/test-focused-files ", ")))) -;;;###autoload (defun cj/test-run-smart () "Run tests based on current mode (all or focused)." (interactive) @@ -265,8 +384,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/modules/wip.el b/modules/wip.el index 314881d2..80b3295d 100644 --- a/modules/wip.el +++ b/modules/wip.el @@ -35,7 +35,6 @@ (list nil s "command"))) (t (user-error "Error: cj/system-cmd expects a string or a symbol")))) -;;;###autoload (defun cj/system-cmd (cmd) "Run CMD (string or symbol naming a string) detached via the shell. Shell expansions like $(...) are supported. Output is silenced. diff --git a/tests/test-browser-config.el b/tests/test-browser-config.el new file mode 100644 index 00000000..6ab756dd --- /dev/null +++ b/tests/test-browser-config.el @@ -0,0 +1,277 @@ +;;; test-browser-config.el --- Tests for browser-config.el -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for browser-config.el - browser selection and configuration. +;; +;; Testing approach: +;; - Tests focus on internal `cj/--do-*` functions (pure business logic) +;; - File I/O tests use temp files +;; - executable-find is stubbed to control available browsers +;; - Each test is 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 with temp file to avoid polluting real config +(defvar test-browser--temp-choice-file nil + "Temporary file for browser choice during tests.") + +(defun test-browser-setup () + "Setup test environment before each test." + (setq test-browser--temp-choice-file (make-temp-file "browser-choice-test" nil ".el")) + (setq cj/browser-choice-file test-browser--temp-choice-file)) + +(defun test-browser-teardown () + "Clean up test environment after each test." + (when (and test-browser--temp-choice-file + (file-exists-p test-browser--temp-choice-file)) + (delete-file test-browser--temp-choice-file)) + (setq test-browser--temp-choice-file nil)) + +;; Now require the module +(require 'browser-config) + +;;; Helper Functions + +(defun test-browser-make-plist (name &optional executable path) + "Create a test browser plist with NAME, EXECUTABLE, and PATH." + (list :function 'eww-browse-url + :name name + :executable executable + :path path + :program-var nil)) + +;;; Normal Cases - Discover Browsers + +(ert-deftest test-browser-discover-finds-eww () + "Should always find built-in EWW browser." + (test-browser-setup) + (let ((browsers (cj/discover-browsers))) + (should (cl-find-if (lambda (b) (string= (plist-get b :name) "EWW (Emacs Browser)")) + browsers))) + (test-browser-teardown)) + +(ert-deftest test-browser-discover-deduplicates-names () + "Should not return duplicate browser names." + (test-browser-setup) + (let ((browsers (cj/discover-browsers)) + (names (mapcar (lambda (b) (plist-get b :name)) (cj/discover-browsers)))) + (should (= (length names) (length (cl-remove-duplicates names :test 'string=))))) + (test-browser-teardown)) + +;;; Normal Cases - Apply Browser Choice + +(ert-deftest test-browser-apply-valid-browser () + "Should successfully apply a valid browser configuration." + (test-browser-setup) + (let ((browser (test-browser-make-plist "Test Browser"))) + (let ((result (cj/--do-apply-browser-choice browser))) + (should (eq result 'success)) + (should (eq browse-url-browser-function 'eww-browse-url)))) + (test-browser-teardown)) + +(ert-deftest test-browser-apply-sets-program-var () + "Should set browser program variable if specified." + (test-browser-setup) + (let ((browser (list :function 'browse-url-chrome + :name "Chrome" + :executable "chrome" + :path "/usr/bin/chrome" + :program-var 'browse-url-chrome-program))) + (cj/--do-apply-browser-choice browser) + (should (string= browse-url-chrome-program "/usr/bin/chrome"))) + (test-browser-teardown)) + +;;; Normal Cases - Save and Load + +(ert-deftest test-browser-save-and-load-choice () + "Should save and load browser choice correctly." + (test-browser-setup) + (let ((browser (test-browser-make-plist "Saved Browser" "firefox" "/usr/bin/firefox"))) + (cj/save-browser-choice browser) + (let ((loaded (cj/load-browser-choice))) + (should loaded) + (should (string= (plist-get loaded :name) "Saved Browser")) + (should (string= (plist-get loaded :executable) "firefox")))) + (test-browser-teardown)) + +;;; Normal Cases - Choose Browser + +(ert-deftest test-browser-choose-saves-and-applies () + "Should save and apply browser choice." + (test-browser-setup) + (let ((browser (test-browser-make-plist "Test"))) + (let ((result (cj/--do-choose-browser browser))) + (should (eq result 'success)) + ;; Verify it was saved + (let ((loaded (cj/load-browser-choice))) + (should (string= (plist-get loaded :name) "Test"))))) + (test-browser-teardown)) + +;;; Normal Cases - Initialize Browser + +(ert-deftest test-browser-initialize-with-saved-choice () + "Should load and use saved browser choice." + (test-browser-setup) + (let ((browser (test-browser-make-plist "Saved"))) + (cj/save-browser-choice browser) + (let ((result (cj/--do-initialize-browser))) + (should (eq (car result) 'loaded)) + (should (plist-get (cdr result) :name)) + (should (string= (plist-get (cdr result) :name) "Saved")))) + (test-browser-teardown)) + +(ert-deftest test-browser-initialize-without-saved-choice () + "Should use first available browser when no saved choice." + (test-browser-setup) + ;; Delete any saved choice + (when (file-exists-p cj/browser-choice-file) + (delete-file cj/browser-choice-file)) + (let ((result (cj/--do-initialize-browser))) + (should (eq (car result) 'first-available)) + (should (plist-get (cdr result) :name))) + (test-browser-teardown)) + +;;; Boundary Cases - Apply Browser + +(ert-deftest test-browser-apply-nil-plist () + "Should return 'invalid-plist for nil browser." + (test-browser-setup) + (let ((result (cj/--do-apply-browser-choice nil))) + (should (eq result 'invalid-plist))) + (test-browser-teardown)) + +(ert-deftest test-browser-apply-missing-function () + "Should return 'invalid-plist when :function is missing." + (test-browser-setup) + (let ((browser (list :name "Bad Browser" :function nil))) + (let ((result (cj/--do-apply-browser-choice browser))) + (should (eq result 'invalid-plist)))) + (test-browser-teardown)) + +(ert-deftest test-browser-apply-with-nil-path () + "Should handle nil path for built-in browser." + (test-browser-setup) + (let ((browser (test-browser-make-plist "EWW" nil nil))) + (let ((result (cj/--do-apply-browser-choice browser))) + (should (eq result 'success)))) + (test-browser-teardown)) + +;;; Boundary Cases - Save and Load + +(ert-deftest test-browser-load-nonexistent-file () + "Should return nil when loading from nonexistent file." + (test-browser-setup) + (when (file-exists-p cj/browser-choice-file) + (delete-file cj/browser-choice-file)) + (let ((result (cj/load-browser-choice))) + (should (null result))) + (test-browser-teardown)) + +(ert-deftest test-browser-load-corrupt-file () + "Should return nil when loading corrupt file." + (test-browser-setup) + (with-temp-file cj/browser-choice-file + (insert "this is not valid elisp {{{")) + (let ((result (cj/load-browser-choice))) + (should (null result))) + (test-browser-teardown)) + +(ert-deftest test-browser-load-file-without-variable () + "Should return nil when file doesn't define expected variable." + (test-browser-setup) + (with-temp-file cj/browser-choice-file + (insert "(setq some-other-variable 'foo)")) + ;; Unset any previously loaded variable + (makunbound 'cj/saved-browser-choice) + (let ((result (cj/load-browser-choice))) + (should (null result))) + (test-browser-teardown)) + +;;; Boundary Cases - Choose Browser + +(ert-deftest test-browser-choose-empty-plist () + "Should handle empty plist gracefully." + (test-browser-setup) + (let ((result (cj/--do-choose-browser nil))) + (should (eq result 'invalid-plist))) + (test-browser-teardown)) + +;;; Error Cases - File Operations + +(ert-deftest test-browser-save-to-readonly-location () + "Should return 'save-failed when cannot write file." + (test-browser-setup) + ;; Make file read-only + (with-temp-file cj/browser-choice-file + (insert ";; test")) + (set-file-modes cj/browser-choice-file #o444) + (let ((browser (test-browser-make-plist "Test")) + (result nil)) + (setq result (cj/--do-choose-browser browser)) + ;; Restore permissions before teardown + (set-file-modes cj/browser-choice-file #o644) + (should (eq result 'save-failed))) + (test-browser-teardown)) + +;;; Browser Discovery Tests + +(ert-deftest test-browser-discover-returns-plists () + "Should return properly formatted browser plists." + (test-browser-setup) + (let ((browsers (cj/discover-browsers))) + (should (> (length browsers) 0)) + (dolist (browser browsers) + (should (plist-member browser :function)) + (should (plist-member browser :name)) + (should (plist-member browser :executable)) + (should (plist-member browser :path)))) + (test-browser-teardown)) + +(ert-deftest test-browser-format-location-keys () + "Should have all required keys in browser plist." + (test-browser-setup) + (let ((browsers (cj/discover-browsers))) + (when browsers + (let ((browser (car browsers))) + (should (plist-get browser :function)) + (should (plist-get browser :name))))) + (test-browser-teardown)) + +;;; Integration Tests + +(ert-deftest test-browser-full-cycle () + "Should handle full save-load-apply cycle." + (test-browser-setup) + (let ((browser (test-browser-make-plist "Cycle Test" "test-browser" "/usr/bin/test"))) + ;; Choose (save and apply) + (should (eq (cj/--do-choose-browser browser) 'success)) + ;; Verify it was saved + (let ((loaded (cj/load-browser-choice))) + (should loaded) + (should (string= (plist-get loaded :name) "Cycle Test"))) + ;; Initialize should load the saved choice + (let ((result (cj/--do-initialize-browser))) + (should (eq (car result) 'loaded)) + (should (string= (plist-get (cdr result) :name) "Cycle Test")))) + (test-browser-teardown)) + +(ert-deftest test-browser-overwrite-choice () + "Should overwrite previous browser choice." + (test-browser-setup) + (let ((browser1 (test-browser-make-plist "First")) + (browser2 (test-browser-make-plist "Second"))) + (cj/--do-choose-browser browser1) + (cj/--do-choose-browser browser2) + (let ((loaded (cj/load-browser-choice))) + (should (string= (plist-get loaded :name) "Second")))) + (test-browser-teardown)) + +(provide 'test-browser-config) +;;; test-browser-config.el ends here diff --git a/tests/test-jumper.el b/tests/test-jumper.el new file mode 100644 index 00000000..fa65d3f4 --- /dev/null +++ b/tests/test-jumper.el @@ -0,0 +1,352 @@ +;;; test-jumper.el --- Tests for jumper.el -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for jumper.el - location navigation using registers. +;; +;; Testing approach: +;; - Tests focus on internal `jumper--do-*` functions (pure business logic) +;; - Interactive wrappers are thin UI layers and tested minimally +;; - Each test is isolated with setup/teardown to reset global state +;; - 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 +(require 'jumper) + +;;; Test Utilities + +(defvar test-jumper--original-registers nil + "Backup of jumper registers before test.") + +(defvar test-jumper--original-index nil + "Backup of jumper index before test.") + +(defun test-jumper-setup () + "Reset jumper state before each test." + ;; Backup current state + (setq test-jumper--original-registers jumper--registers) + (setq test-jumper--original-index jumper--next-index) + ;; Reset to clean state + (setq jumper--registers (make-vector jumper-max-locations nil)) + (setq jumper--next-index 0)) + +(defun test-jumper-teardown () + "Restore jumper state after each test." + (setq jumper--registers test-jumper--original-registers) + (setq jumper--next-index test-jumper--original-index)) + +;;; Normal Cases - Store Location + +(ert-deftest test-jumper-store-first-location () + "Should store first location and return register character." + (test-jumper-setup) + (with-temp-buffer + (insert "test content") + (goto-char (point-min)) + (let ((result (jumper--do-store-location))) + (should (= result ?0)) + (should (= jumper--next-index 1)))) + (test-jumper-teardown)) + +(ert-deftest test-jumper-store-multiple-locations () + "Should store multiple locations in sequence." + (test-jumper-setup) + (with-temp-buffer + (insert "line 1\nline 2\nline 3") + (goto-char (point-min)) + (should (= (jumper--do-store-location) ?0)) + (forward-line 1) + (should (= (jumper--do-store-location) ?1)) + (forward-line 1) + (should (= (jumper--do-store-location) ?2)) + (should (= jumper--next-index 3))) + (test-jumper-teardown)) + +(ert-deftest test-jumper-store-duplicate-location () + "Should detect and reject duplicate locations." + (test-jumper-setup) + (with-temp-buffer + (insert "test content") + (goto-char (point-min)) + (should (= (jumper--do-store-location) ?0)) + (should (eq (jumper--do-store-location) 'already-exists)) + (should (= jumper--next-index 1))) + (test-jumper-teardown)) + +;;; Normal Cases - Jump to Location + +(ert-deftest test-jumper-jump-to-stored-location () + "Should jump to a previously stored location." + (test-jumper-setup) + (with-temp-buffer + (insert "line 1\nline 2\nline 3") + (goto-char (point-min)) + (jumper--do-store-location) + (goto-char (point-max)) + (let ((result (jumper--do-jump-to-location 0))) + (should (eq result 'jumped)) + (should (= (point) (point-min))))) + (test-jumper-teardown)) + +(ert-deftest test-jumper-jump-toggle-with-single-location () + "Should toggle between current and stored location." + (test-jumper-setup) + (with-temp-buffer + (insert "line 1\nline 2\nline 3") + (goto-char (point-min)) + (jumper--do-store-location) + ;; Move away + (goto-char (point-max)) + ;; Toggle should jump back + (let ((result (jumper--do-jump-to-location nil))) + (should (eq result 'jumped)) + (should (= (point) (point-min))))) + (test-jumper-teardown)) + +(ert-deftest test-jumper-jump-already-at-location () + "Should detect when already at the only stored location." + (test-jumper-setup) + (with-temp-buffer + (insert "line 1\nline 2") + (goto-char (point-min)) + (jumper--do-store-location) + ;; Try to toggle while at the location + (let ((result (jumper--do-jump-to-location nil))) + (should (eq result 'already-there)))) + (test-jumper-teardown)) + +;;; Normal Cases - Remove Location + +(ert-deftest test-jumper-remove-location () + "Should remove a stored location." + (test-jumper-setup) + (with-temp-buffer + (insert "test content") + (goto-char (point-min)) + (jumper--do-store-location) + (let ((result (jumper--do-remove-location 0))) + (should (eq result t)) + (should (= jumper--next-index 0)))) + (test-jumper-teardown)) + +(ert-deftest test-jumper-remove-reorders-registers () + "Should reorder registers after removal from middle." + (test-jumper-setup) + (with-temp-buffer + (insert "line 1\nline 2\nline 3") + (goto-char (point-min)) + (jumper--do-store-location) ; Register 0 + (forward-line 1) + (jumper--do-store-location) ; Register 1 + (forward-line 1) + (jumper--do-store-location) ; Register 2 + ;; Remove middle (index 1) + (jumper--do-remove-location 1) + (should (= jumper--next-index 2)) + ;; What was at index 2 should now be at index 1 + (should (= (aref jumper--registers 1) ?2))) + (test-jumper-teardown)) + +;;; Boundary Cases - Store Location + +(ert-deftest test-jumper-store-at-capacity () + "Should successfully store location at maximum capacity." + (test-jumper-setup) + (with-temp-buffer + (insert "test content") + (goto-char (point-min)) + ;; Fill to capacity + (dotimes (i jumper-max-locations) + (forward-char 1) + (should (= (jumper--do-store-location) (+ ?0 i)))) + (should (= jumper--next-index jumper-max-locations))) + (test-jumper-teardown)) + +(ert-deftest test-jumper-store-when-full () + "Should return 'no-space when all registers are full." + (test-jumper-setup) + (with-temp-buffer + (insert "01234567890123456789") + (goto-char (point-min)) + ;; Fill to capacity + (dotimes (i jumper-max-locations) + (forward-char 1) + (jumper--do-store-location)) + ;; Try to store one more + (forward-char 1) + (should (eq (jumper--do-store-location) 'no-space)) + (should (= jumper--next-index jumper-max-locations))) + (test-jumper-teardown)) + +(ert-deftest test-jumper-store-in-different-buffers () + "Should store locations across different buffers." + (test-jumper-setup) + (with-temp-buffer + (insert "buffer 1") + (goto-char (point-min)) + (should (= (jumper--do-store-location) ?0)) + (with-temp-buffer + (insert "buffer 2") + (goto-char (point-min)) + (should (= (jumper--do-store-location) ?1)) + (should (= jumper--next-index 2)))) + (test-jumper-teardown)) + +;;; Boundary Cases - Jump to Location + +(ert-deftest test-jumper-jump-with-no-locations () + "Should return 'no-locations when nothing is stored." + (test-jumper-setup) + (with-temp-buffer + (insert "test") + (let ((result (jumper--do-jump-to-location 0))) + (should (eq result 'no-locations)))) + (test-jumper-teardown)) + +(ert-deftest test-jumper-jump-to-first-location () + "Should jump to location at index 0." + (test-jumper-setup) + (with-temp-buffer + (insert "line 1\nline 2") + (goto-char (point-min)) + (jumper--do-store-location) + (forward-line 1) + (jumper--do-store-location) + (goto-char (point-max)) + (jumper--do-jump-to-location 0) + (should (= (point) (point-min)))) + (test-jumper-teardown)) + +(ert-deftest test-jumper-jump-to-last-location () + "Should jump to last location (register 'z)." + (test-jumper-setup) + (with-temp-buffer + (insert "line 1\nline 2\nline 3") + (goto-char (point-min)) + (jumper--do-store-location) + (let ((line2-pos (line-beginning-position 2))) + (goto-char line2-pos) + ;; Jump to location 0 (this stores current location in 'z) + (jumper--do-jump-to-location 0) + (should (= (point) (point-min))) + ;; Jump to last location should go back to line 2 + (let ((result (jumper--do-jump-to-location -1))) + (should (eq result 'jumped)) + (should (= (point) line2-pos))))) + (test-jumper-teardown)) + +(ert-deftest test-jumper-jump-to-max-index () + "Should jump to location at maximum index." + (test-jumper-setup) + (with-temp-buffer + (insert "0123456789012345678") + (goto-char (point-min)) + ;; Store at all positions + (dotimes (i jumper-max-locations) + (forward-char 1) + (jumper--do-store-location)) + (goto-char (point-min)) + ;; Jump to last one (index 9, which is at position 10) + (jumper--do-jump-to-location (1- jumper-max-locations)) + (should (= (point) (1+ jumper-max-locations)))) + (test-jumper-teardown)) + +;;; Boundary Cases - Remove Location + +(ert-deftest test-jumper-remove-first-location () + "Should remove location at index 0." + (test-jumper-setup) + (with-temp-buffer + (insert "line 1\nline 2") + (goto-char (point-min)) + (jumper--do-store-location) + (forward-line 1) + (jumper--do-store-location) + (jumper--do-remove-location 0) + (should (= jumper--next-index 1)) + ;; What was at index 1 should now be at index 0 + (should (= (aref jumper--registers 0) ?1))) + (test-jumper-teardown)) + +(ert-deftest test-jumper-remove-last-location () + "Should remove location at last index." + (test-jumper-setup) + (with-temp-buffer + (insert "line 1\nline 2\nline 3") + (goto-char (point-min)) + (jumper--do-store-location) + (forward-line 1) + (jumper--do-store-location) + (forward-line 1) + (jumper--do-store-location) + (jumper--do-remove-location 2) + (should (= jumper--next-index 2))) + (test-jumper-teardown)) + +(ert-deftest test-jumper-remove-with-cancel () + "Should return 'cancelled when index is -1." + (test-jumper-setup) + (with-temp-buffer + (insert "test") + (goto-char (point-min)) + (jumper--do-store-location) + (let ((result (jumper--do-remove-location -1))) + (should (eq result 'cancelled)) + (should (= jumper--next-index 1)))) + (test-jumper-teardown)) + +;;; Error Cases + +(ert-deftest test-jumper-remove-when-empty () + "Should return 'no-locations when removing from empty list." + (test-jumper-setup) + (let ((result (jumper--do-remove-location 0))) + (should (eq result 'no-locations))) + (test-jumper-teardown)) + +;;; Helper Function Tests + +(ert-deftest test-jumper-location-key-format () + "Should generate unique location keys." + (with-temp-buffer + (insert "line 1\nline 2") + (goto-char (point-min)) + (let ((key1 (jumper--location-key))) + (forward-line 1) + (let ((key2 (jumper--location-key))) + (should-not (string= key1 key2)) + ;; Keys should contain buffer name and position info + (should (string-match-p ":" key1)) + (should (string-match-p ":" key2)))))) + +(ert-deftest test-jumper-register-available-p () + "Should correctly report register availability." + (test-jumper-setup) + (should (jumper--register-available-p)) + ;; Fill to capacity + (setq jumper--next-index jumper-max-locations) + (should-not (jumper--register-available-p)) + (test-jumper-teardown)) + +(ert-deftest test-jumper-format-location () + "Should format location for display." + (test-jumper-setup) + (with-temp-buffer + (insert "test line with some content") + (goto-char (point-min)) + (jumper--do-store-location) + (let ((formatted (jumper--format-location 0))) + (should formatted) + (should (string-match-p "\\[0\\]" formatted)) + (should (string-match-p "test line" formatted)))) + (test-jumper-teardown)) + +(provide 'test-jumper) +;;; test-jumper.el ends here diff --git a/tests/test-lorem-optimum-benchmark.el b/tests/test-lorem-optimum-benchmark.el new file mode 100644 index 00000000..d3ca2873 --- /dev/null +++ b/tests/test-lorem-optimum-benchmark.el @@ -0,0 +1,227 @@ +;;; test-lorem-optimum-benchmark.el --- Performance tests for lorem-optimum.el -*- lexical-binding: t; -*- + +;;; Commentary: +;; Benchmark and performance tests for the Markov chain implementation. +;; +;; These tests measure: +;; - Learning time scaling with input size +;; - Multiple learning operations (exposes key rebuild overhead) +;; - Generation time scaling +;; - Memory usage (hash table growth) +;; +;; Performance baseline targets (on modern hardware): +;; - Learn 1000 words: < 10ms +;; - Learn 10,000 words: < 100ms +;; - 100 learn operations of 100 words each: < 500ms (current bottleneck!) +;; - Generate 100 words: < 5ms + +;;; 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 +(require 'lorem-optimum) + +;;; Benchmark Helpers + +(defun benchmark-time (func) + "Time execution of FUNC and return milliseconds." + (let ((start (current-time))) + (funcall func) + (let ((end (current-time))) + (* 1000.0 (float-time (time-subtract end start)))))) + +(defun generate-test-text (word-count) + "Generate WORD-COUNT words of test text with some repetition." + (let ((words '("lorem" "ipsum" "dolor" "sit" "amet" "consectetur" + "adipiscing" "elit" "sed" "do" "eiusmod" "tempor" + "incididunt" "ut" "labore" "et" "dolore" "magna" "aliqua")) + (result '())) + (dotimes (i word-count) + (push (nth (mod i (length words)) words) result) + (when (zerop (mod i 10)) + (push "." result))) + (mapconcat #'identity (nreverse result) " "))) + +(defun benchmark-report (name time-ms) + "Report benchmark NAME with TIME-MS." + (message "BENCHMARK [%s]: %.2f ms" name time-ms)) + +;;; Learning Performance Tests + +(ert-deftest benchmark-learn-1k-words () + "Benchmark learning 1000 words." + (let* ((text (generate-test-text 1000)) + (chain (cj/markov-chain-create)) + (time (benchmark-time + (lambda () (cj/markov-learn chain text))))) + (benchmark-report "Learn 1K words" time) + (should (< time 50.0)))) ; Should be < 50ms + +(ert-deftest benchmark-learn-10k-words () + "Benchmark learning 10,000 words." + (let* ((text (generate-test-text 10000)) + (chain (cj/markov-chain-create)) + (time (benchmark-time + (lambda () (cj/markov-learn chain text))))) + (benchmark-report "Learn 10K words" time) + (should (< time 500.0)))) ; Should be < 500ms + +(ert-deftest benchmark-learn-100k-words () + "Benchmark learning 100,000 words (stress test)." + :tags '(:slow) + (let* ((text (generate-test-text 100000)) + (chain (cj/markov-chain-create)) + (time (benchmark-time + (lambda () (cj/markov-learn chain text))))) + (benchmark-report "Learn 100K words" time) + ;; This may be slow due to key rebuild + (message "Hash table size: %d bigrams" + (hash-table-count (cj/markov-chain-map chain))))) + +;;; Multiple Learning Operations (Exposes Quadratic Behavior) + +(ert-deftest benchmark-multiple-learns-10x100 () + "Benchmark 10 learn operations of 100 words each." + (let ((chain (cj/markov-chain-create)) + (times '())) + (dotimes (i 10) + (let* ((text (generate-test-text 100)) + (time (benchmark-time + (lambda () (cj/markov-learn chain text))))) + (push time times))) + (let ((total (apply #'+ times)) + (avg (/ (apply #'+ times) 10.0)) + (max-time (apply #'max times))) + (benchmark-report "10x learn 100 words - TOTAL" total) + (benchmark-report "10x learn 100 words - AVG" avg) + (benchmark-report "10x learn 100 words - MAX" max-time) + (message "Times: %S" (nreverse times)) + ;; Note: Watch if later operations are slower (quadratic behavior) + (should (< total 100.0))))) ; Total should be < 100ms + +(ert-deftest benchmark-multiple-learns-100x100 () + "Benchmark 100 learn operations of 100 words each (key rebuild overhead)." + :tags '(:slow) + (let ((chain (cj/markov-chain-create)) + (times '()) + (measurements '())) + (dotimes (i 100) + (let* ((text (generate-test-text 100)) + (time (benchmark-time + (lambda () (cj/markov-learn chain text))))) + (push time times) + ;; Sample measurements every 10 iterations + (when (zerop (mod i 10)) + (push (cons i time) measurements)))) + (let ((total (apply #'+ times)) + (avg (/ (apply #'+ times) 100.0)) + (first-10-avg (/ (apply #'+ (last times 10)) 10.0)) + (last-10-avg (/ (apply #'+ (seq-take times 10)) 10.0))) + (benchmark-report "100x learn 100 words - TOTAL" total) + (benchmark-report "100x learn 100 words - AVG" avg) + (benchmark-report "100x learn - First 10 AVG" first-10-avg) + (benchmark-report "100x learn - Last 10 AVG" last-10-avg) + (message "Sampled times (iteration, ms): %S" (nreverse measurements)) + (message "Hash table size: %d bigrams" + (hash-table-count (cj/markov-chain-map chain))) + ;; This exposes the quadratic behavior: last operations much slower + (when (> last-10-avg (* 2.0 first-10-avg)) + (message "WARNING: Learning slows down significantly over time!") + (message " First 10 avg: %.2f ms" first-10-avg) + (message " Last 10 avg: %.2f ms" last-10-avg) + (message " Ratio: %.1fx slower" (/ last-10-avg first-10-avg)))))) + +;;; Generation Performance Tests + +(ert-deftest benchmark-generate-100-words () + "Benchmark generating 100 words." + (let* ((text (generate-test-text 1000)) + (chain (cj/markov-chain-create))) + (cj/markov-learn chain text) + (let ((time (benchmark-time + (lambda () (cj/markov-generate chain 100))))) + (benchmark-report "Generate 100 words" time) + (should (< time 20.0))))) ; Should be < 20ms + +(ert-deftest benchmark-generate-1000-words () + "Benchmark generating 1000 words." + (let* ((text (generate-test-text 10000)) + (chain (cj/markov-chain-create))) + (cj/markov-learn chain text) + (let ((time (benchmark-time + (lambda () (cj/markov-generate chain 1000))))) + (benchmark-report "Generate 1000 words" time) + (should (< time 100.0))))) ; Should be < 100ms + +;;; Tokenization Performance Tests + +(ert-deftest benchmark-tokenize-10k-words () + "Benchmark tokenizing 10,000 words." + (let* ((text (generate-test-text 10000)) + (time (benchmark-time + (lambda () (cj/markov-tokenize text))))) + (benchmark-report "Tokenize 10K words" time) + (should (< time 50.0)))) ; Tokenization should be fast + +;;; Memory/Size Tests + +(ert-deftest benchmark-chain-growth () + "Measure hash table growth with increasing input." + (let ((chain (cj/markov-chain-create)) + (sizes '())) + (dolist (word-count '(100 500 1000 5000 10000)) + (let ((text (generate-test-text word-count))) + (cj/markov-learn chain text) + (let ((size (hash-table-count (cj/markov-chain-map chain)))) + (push (cons word-count size) sizes) + (message "After %d words: %d unique bigrams" word-count size)))) + (message "Growth pattern: %S" (nreverse sizes)))) + +;;; Comparison: Tokenization vs Learning + +(ert-deftest benchmark-tokenize-vs-learn () + "Compare tokenization time to total learning time." + (let* ((text (generate-test-text 5000)) + (tokenize-time (benchmark-time + (lambda () (cj/markov-tokenize text)))) + (chain (cj/markov-chain-create)) + (learn-time (benchmark-time + (lambda () (cj/markov-learn chain text))))) + (benchmark-report "Tokenize 5K words" tokenize-time) + (benchmark-report "Learn 5K words (total)" learn-time) + (message "Tokenization is %.1f%% of total learning time" + (* 100.0 (/ tokenize-time learn-time))))) + +;;; Real-world Scenario + +(ert-deftest benchmark-realistic-usage () + "Benchmark realistic usage: learn from multiple sources, generate paragraphs." + (let ((chain (cj/markov-chain-create)) + (learn-total 0.0) + (gen-total 0.0)) + ;; Simulate learning from 10 different sources + (dotimes (i 10) + (let ((text (generate-test-text 500))) + (setq learn-total + (+ learn-total + (benchmark-time (lambda () (cj/markov-learn chain text))))))) + + ;; Generate 5 paragraphs + (dotimes (i 5) + (setq gen-total + (+ gen-total + (benchmark-time (lambda () (cj/markov-generate chain 50)))))) + + (benchmark-report "Realistic: 10 learns (500 words each)" learn-total) + (benchmark-report "Realistic: 5 generations (50 words each)" gen-total) + (benchmark-report "Realistic: TOTAL TIME" (+ learn-total gen-total)) + (message "Final chain size: %d bigrams" + (hash-table-count (cj/markov-chain-map chain))))) + +(provide 'test-lorem-optimum-benchmark) +;;; test-lorem-optimum-benchmark.el ends here diff --git a/tests/test-lorem-optimum.el b/tests/test-lorem-optimum.el new file mode 100644 index 00000000..ca2e52f4 --- /dev/null +++ b/tests/test-lorem-optimum.el @@ -0,0 +1,242 @@ +;;; test-lorem-optimum.el --- Tests for lorem-optimum.el -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for lorem-optimum.el Markov chain text generation. +;; +;; Tests cover: +;; - Tokenization +;; - Learning and chain building +;; - Text generation +;; - Capitalization fixing +;; - Token joining + +;;; 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 +(require 'lorem-optimum) + +;;; Test Helpers + +(defun test-chain () + "Create a fresh test chain." + (cj/markov-chain-create)) + +(defun test-learn (text) + "Create a chain and learn TEXT." + (let ((chain (test-chain))) + (cj/markov-learn chain text) + chain)) + +;;; Tokenization Tests + +(ert-deftest test-tokenize-simple () + "Should tokenize simple words." + (let ((result (cj/markov-tokenize "hello world"))) + (should (equal result '("hello" "world"))))) + +(ert-deftest test-tokenize-with-punctuation () + "Should separate punctuation as tokens." + (let ((result (cj/markov-tokenize "Hello, world!"))) + (should (equal result '("Hello" "," "world" "!"))))) + +(ert-deftest test-tokenize-multiple-spaces () + "Should handle multiple spaces." + (let ((result (cj/markov-tokenize "hello world"))) + (should (equal result '("hello" "world"))))) + +(ert-deftest test-tokenize-newlines () + "Should handle newlines as whitespace." + (let ((result (cj/markov-tokenize "hello\nworld"))) + (should (equal result '("hello" "world"))))) + +(ert-deftest test-tokenize-mixed-punctuation () + "Should tokenize complex punctuation." + (let ((result (cj/markov-tokenize "one, two; three."))) + (should (equal result '("one" "," "two" ";" "three" "."))))) + +(ert-deftest test-tokenize-empty () + "Should handle empty string." + (let ((result (cj/markov-tokenize ""))) + (should (null result)))) + +(ert-deftest test-tokenize-whitespace-only () + "Should return nil for whitespace only." + (let ((result (cj/markov-tokenize " \n\t "))) + (should (null result)))) + +;;; Markov Learn Tests + +(ert-deftest test-learn-basic () + "Should learn simple text." + (let ((chain (test-learn "one two three four"))) + (should (cj/markov-chain-p chain)) + (should (> (hash-table-count (cj/markov-chain-map chain)) 0)))) + +(ert-deftest test-learn-creates-bigrams () + "Should create bigram mappings." + (let ((chain (test-learn "one two three"))) + (should (gethash '("one" "two") (cj/markov-chain-map chain))))) + +(ert-deftest test-learn-stores-following-word () + "Should store following word for bigram." + (let ((chain (test-learn "one two three"))) + (should (member "three" (gethash '("one" "two") (cj/markov-chain-map chain)))))) + +(ert-deftest test-learn-builds-keys-list () + "Should build keys list lazily when accessed." + (let ((chain (test-learn "one two three four"))) + ;; Keys are built lazily, so initially nil + (should (null (cj/markov-chain-keys chain))) + ;; After calling random-key, keys should be built + (cj/markov-random-key chain) + (should (> (length (cj/markov-chain-keys chain)) 0)))) + +(ert-deftest test-learn-repeated-patterns () + "Should accumulate repeated patterns." + (let ((chain (test-learn "one two three one two four"))) + (let ((nexts (gethash '("one" "two") (cj/markov-chain-map chain)))) + (should (= (length nexts) 2)) + (should (member "three" nexts)) + (should (member "four" nexts))))) + +(ert-deftest test-learn-incremental () + "Should support incremental learning." + (let ((chain (test-chain))) + (cj/markov-learn chain "one two three") + (cj/markov-learn chain "four five six") + (should (> (hash-table-count (cj/markov-chain-map chain)) 0)))) + +;;; Token Joining Tests + +(ert-deftest test-join-simple-words () + "Should join words with spaces." + (let ((result (cj/markov-join-tokens '("hello" "world")))) + (should (string-match-p "^Hello world" result)))) + +(ert-deftest test-join-with-punctuation () + "Should attach punctuation without spaces." + (let ((result (cj/markov-join-tokens '("hello" "," "world")))) + (should (string-match-p "Hello, world" result)))) + +(ert-deftest test-join-capitalizes-first () + "Should capitalize first word." + (let ((result (cj/markov-join-tokens '("hello" "world")))) + (should (string-match-p "^H" result)))) + +(ert-deftest test-join-adds-period () + "Should add period if missing." + (let ((result (cj/markov-join-tokens '("hello" "world")))) + (should (string-match-p "\\.$" result)))) + +(ert-deftest test-join-preserves-existing-period () + "Should not double-add period." + (let ((result (cj/markov-join-tokens '("hello" "world" ".")))) + (should (string-match-p "\\.$" result)) + (should-not (string-match-p "\\.\\.$" result)))) + +(ert-deftest test-join-empty-tokens () + "Should handle empty token list." + (let ((result (cj/markov-join-tokens '()))) + (should (equal result ".")))) + +;;; Capitalization Tests + +(ert-deftest test-capitalize-first-word () + "Should capitalize first word." + (let ((result (cj/markov-fix-capitalization "hello world"))) + (should (string-match-p "^Hello" result)))) + +(ert-deftest test-capitalize-after-period () + "Should capitalize after period." + (let ((result (cj/markov-fix-capitalization "hello. world"))) + (should (string-match-p "Hello\\. World" result)))) + +(ert-deftest test-capitalize-after-exclamation () + "Should capitalize after exclamation." + (let ((result (cj/markov-fix-capitalization "hello! world"))) + (should (string-match-p "Hello! World" result)))) + +(ert-deftest test-capitalize-after-question () + "Should capitalize after question mark." + (let ((result (cj/markov-fix-capitalization "hello? world"))) + (should (string-match-p "Hello\\? World" result)))) + +(ert-deftest test-capitalize-skip-non-alpha () + "Should skip non-alphabetic tokens." + (let ((result (cj/markov-fix-capitalization "hello. 123 world"))) + (should (string-match-p "123" result)))) + +(ert-deftest test-capitalize-multiple-sentences () + "Should capitalize all sentences." + (let ((result (cj/markov-fix-capitalization "first. second. third"))) + (should (string-match-p "First\\. Second\\. Third" result)))) + +;;; Generation Tests (deterministic with fixed chain) + +(ert-deftest test-generate-produces-output () + "Should generate non-empty output." + (let ((chain (test-learn "Lorem ipsum dolor sit amet consectetur adipiscing elit"))) + (let ((result (cj/markov-generate chain 5))) + (should (stringp result)) + (should (> (length result) 0))))) + +(ert-deftest test-generate-empty-chain () + "Should handle empty chain gracefully." + (let ((chain (test-chain))) + (let ((result (cj/markov-generate chain 5))) + (should (or (null result) (string-empty-p result)))))) + +(ert-deftest test-generate-respects-start () + "Should use provided start state if available." + (let ((chain (test-learn "Lorem ipsum dolor sit amet"))) + (let ((result (cj/markov-generate chain 3 '("Lorem" "ipsum")))) + (should (stringp result)) + ;; Should start with Lorem or similar + (should (> (length result) 0))))) + +;;; Integration Tests + +(ert-deftest test-full-workflow () + "Should complete full learn-generate workflow." + (let ((chain (test-chain))) + (cj/markov-learn chain "The quick brown fox jumps over the lazy dog") + (let ((result (cj/markov-generate chain 8))) + (should (stringp result)) + (should (> (length result) 0)) + (should (string-match-p "^[A-Z]" result)) + (should (string-match-p "[.!?]$" result))))) + +(ert-deftest test-latin-like-output () + "Should generate Latin-like text from Latin input." + (let ((chain (test-chain))) + (cj/markov-learn chain "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.") + (let ((result (cj/markov-generate chain 10))) + (should (stringp result)) + (should (> (length result) 10))))) + +;;; Edge Cases + +(ert-deftest test-learn-short-text () + "Should handle text shorter than trigram." + (let ((chain (test-learn "one two"))) + (should (cj/markov-chain-p chain)))) + +(ert-deftest test-learn-single-word () + "Should handle single word." + (let ((chain (test-learn "word"))) + (should (cj/markov-chain-p chain)))) + +(ert-deftest test-generate-requested-count-small () + "Should handle small generation count." + (let ((chain (test-learn "one two three four five"))) + (let ((result (cj/markov-generate chain 2))) + (should (stringp result))))) + +(provide 'test-lorem-optimum) +;;; test-lorem-optimum.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 |
