summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2025-10-26 21:21:38 -0500
committerCraig Jennings <c@cjennings.net>2025-10-26 21:21:38 -0500
commit03719a065c21bf683a9b90c83ab26ee0dc069cc7 (patch)
treed0ee9238323ca6dcf55817c10138e9637f35ab42
parent220efbbecd366bc3337c73ad4d902716a9733dae (diff)
test+fix:custom-misc: add tests and fix fraction glyph bug
Add test coverage for 4 functions in custom-misc.el: - cj/replace-fraction-glyphs (24 tests) - cj/format-region-or-buffer (17 tests) - cj/count-words-buffer-or-region (20 tests) - cj/jump-to-matching-paren (18 tests) Refactored functions using internal/interactive split pattern: - Internal functions (cj/--function-name) contain business logic with explicit parameters and validation - Interactive wrappers handle UI concerns (region detection, messages) - Tests call internal functions directly (no mocking required) Bug Fix: cj/--replace-fraction-glyphs Fixed "Invalid search bound" error when converting glyphs to text. Original code used fixed end position which became invalid when replacements changed buffer size. Fixed by using copy-marker for dynamic end position tracking.
-rw-r--r--modules/custom-misc.el90
-rw-r--r--tests/test-custom-misc-count-words.el148
-rw-r--r--tests/test-custom-misc-format-region.el161
-rw-r--r--tests/test-custom-misc-jump-to-matching-paren.el197
-rw-r--r--tests/test-custom-misc-replace-fraction-glyphs.el185
5 files changed, 750 insertions, 31 deletions
diff --git a/modules/custom-misc.el b/modules/custom-misc.el
index 0c6d7ac8..2af9c244 100644
--- a/modules/custom-misc.el
+++ b/modules/custom-misc.el
@@ -46,19 +46,27 @@ If not on a delimiter, show a message. Respects the current syntax table."
(message "Point is not on a delimiter.")))))
+(defun cj/--format-region (start end)
+ "Internal implementation: Reformat text between START and END.
+START and END define the region to operate on.
+Replaces tabs with spaces, reindents, and deletes trailing whitespace."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (save-excursion
+ (save-restriction
+ (narrow-to-region start end)
+ (untabify (point-min) (point-max))
+ (indent-region (point-min) (point-max))
+ (delete-trailing-whitespace (point-min) (point-max)))))
+
(defun cj/format-region-or-buffer ()
"Reformat the region or the entire buffer.
Replaces tabs with spaces, deletes trailing whitespace, and reindents."
(interactive)
(let ((start-pos (if (use-region-p) (region-beginning) (point-min)))
- (end-pos (if (use-region-p) (region-end) (point-max))))
- (save-excursion
- (save-restriction
- (narrow-to-region start-pos end-pos)
- (untabify (point-min) (point-max))
- (indent-region (point-min) (point-max))
- (delete-trailing-whitespace (point-min) (point-max))))
- (message "Formatted %s" (if (use-region-p) "region" "buffer"))))
+ (end-pos (if (use-region-p) (region-end) (point-max))))
+ (cj/--format-region start-pos end-pos)
+ (message "Formatted %s" (if (use-region-p) "region" "buffer"))))
(defun cj/switch-to-previous-buffer ()
"Switch to previously open buffer.
@@ -66,6 +74,14 @@ Repeated invocations toggle between the two most recently open buffers."
(interactive)
(switch-to-buffer (other-buffer (current-buffer) 1)))
+(defun cj/--count-words (start end)
+ "Internal implementation: Count words between START and END.
+START and END define the region to count.
+Returns the word count as an integer."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (count-words start end))
+
(defun cj/count-words-buffer-or-region ()
"Count the number of words in the buffer or region.
Display the result in the minibuffer."
@@ -73,9 +89,38 @@ Display the result in the minibuffer."
(let* ((use-region (use-region-p))
(begin (if use-region (region-beginning) (point-min)))
(end (if use-region (region-end) (point-max)))
- (area-type (if use-region "the region" "the buffer")))
- (message "There are %d words in %s." (count-words begin end) area-type)))
+ (area-type (if use-region "the region" "the buffer"))
+ (word-count (cj/--count-words begin end)))
+ (message "There are %d words in %s." word-count area-type)))
+
+(defun cj/--replace-fraction-glyphs (start end to-glyphs)
+ "Internal implementation: Replace fraction glyphs or text between START and END.
+START and END define the region to operate on.
+TO-GLYPHS when non-nil converts text (1/4) to glyphs (¼),
+otherwise converts glyphs to text."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (let ((replacements (if to-glyphs
+ '(("1/4" . "¼")
+ ("1/2" . "½")
+ ("3/4" . "¾")
+ ("1/3" . "⅓")
+ ("2/3" . "⅔"))
+ '(("¼" . "1/4")
+ ("½" . "1/2")
+ ("¾" . "3/4")
+ ("⅓" . "1/3")
+ ("⅔" . "2/3"))))
+ (count 0)
+ (end-marker (copy-marker end)))
+ (save-excursion
+ (dolist (r replacements)
+ (goto-char start)
+ (while (search-forward (car r) end-marker t)
+ (replace-match (cdr r))
+ (setq count (1+ count)))))
+ count))
(defun cj/replace-fraction-glyphs (start end)
"Replace common fraction glyphs between START and END.
@@ -83,27 +128,10 @@ Operate on the buffer or region designated by START and END.
Replace the text representations with glyphs when called with a
\\[universal-argument] prefix."
(interactive (if (use-region-p)
- (list (region-beginning) (region-end))
- (list (point-min) (point-max))))
- (let ((replacements (if current-prefix-arg
- '(("1/4" . "¼")
- ("1/2" . "½")
- ("3/4" . "¾")
- ("1/3" . "⅓")
- ("2/3" . "⅔"))
- '(("¼" . "1/4")
- ("½" . "1/2")
- ("¾" . "3/4")
- ("⅓" . "1/3")
- ("⅔" . "2/3"))))
- (count 0))
- (save-excursion
- (dolist (r replacements)
- (goto-char start)
- (while (search-forward (car r) end t)
- (replace-match (cdr r))
- (setq count (1+ count)))))
- (message "Replaced %d fraction%s" count (if (= count 1) "" "s"))))
+ (list (region-beginning) (region-end))
+ (list (point-min) (point-max))))
+ (let ((count (cj/--replace-fraction-glyphs start end current-prefix-arg)))
+ (message "Replaced %d fraction%s" count (if (= count 1) "" "s"))))
(defun cj/align-regexp-with-spaces (orig-fun &rest args)
"Call ORIG-FUN with ARGS while temporarily disabling tabs for alignment.
diff --git a/tests/test-custom-misc-count-words.el b/tests/test-custom-misc-count-words.el
new file mode 100644
index 00000000..f2bf793f
--- /dev/null
+++ b/tests/test-custom-misc-count-words.el
@@ -0,0 +1,148 @@
+;;; test-custom-misc-count-words.el --- Tests for cj/--count-words -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--count-words function from custom-misc.el
+;;
+;; This function counts words in a region using Emacs's built-in count-words.
+;; A word is defined by Emacs's word boundaries, which generally means
+;; sequences of word-constituent characters separated by whitespace or punctuation.
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--count-words) to avoid
+;; mocking region selection. This follows our testing best practice of
+;; separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-misc)
+
+;;; Test Helpers
+
+(defun test-count-words (input-text)
+ "Test cj/--count-words on INPUT-TEXT.
+Returns the word count."
+ (with-temp-buffer
+ (insert input-text)
+ (cj/--count-words (point-min) (point-max))))
+
+;;; Normal Cases
+
+(ert-deftest test-count-words-multiple-words ()
+ "Should count multiple words."
+ (should (= 5 (test-count-words "The quick brown fox jumps"))))
+
+(ert-deftest test-count-words-single-word ()
+ "Should count single word."
+ (should (= 1 (test-count-words "hello"))))
+
+(ert-deftest test-count-words-with-punctuation ()
+ "Should count words with punctuation."
+ (should (= 5 (test-count-words "Hello, world! How are you?"))))
+
+(ert-deftest test-count-words-multiple-spaces ()
+ "Should count words separated by multiple spaces."
+ (should (= 3 (test-count-words "hello world test"))))
+
+(ert-deftest test-count-words-with-newlines ()
+ "Should count words across newlines."
+ (should (= 6 (test-count-words "line one\nline two\nline three"))))
+
+(ert-deftest test-count-words-with-tabs ()
+ "Should count words separated by tabs."
+ (should (= 3 (test-count-words "hello\tworld\ttest"))))
+
+(ert-deftest test-count-words-mixed-whitespace ()
+ "Should count words with mixed whitespace."
+ (should (= 4 (test-count-words "hello \t world\n\ntest end"))))
+
+(ert-deftest test-count-words-hyphenated ()
+ "Should count hyphenated words."
+ ;; Emacs treats hyphens as word separators in count-words
+ (should (= 7 (test-count-words "This is state-of-the-art technology"))))
+
+(ert-deftest test-count-words-contractions ()
+ "Should count contractions."
+ ;; Emacs treats apostrophes as word separators in count-words
+ (should (= 6 (test-count-words "don't can't won't"))))
+
+(ert-deftest test-count-words-numbers ()
+ "Should count numbers as words."
+ (should (= 6 (test-count-words "The year 2024 has 365 days"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-count-words-empty-string ()
+ "Should return 0 for empty string."
+ (should (= 0 (test-count-words ""))))
+
+(ert-deftest test-count-words-only-whitespace ()
+ "Should return 0 for whitespace-only text."
+ (should (= 0 (test-count-words " \t\n\n "))))
+
+(ert-deftest test-count-words-only-punctuation ()
+ "Should count punctuation-only text."
+ ;; Emacs may count consecutive punctuation as a word
+ (should (= 1 (test-count-words "!@#$%^&*()"))))
+
+(ert-deftest test-count-words-leading-trailing-spaces ()
+ "Should count words ignoring leading/trailing spaces."
+ (should (= 3 (test-count-words " hello world test "))))
+
+(ert-deftest test-count-words-unicode ()
+ "Should count Unicode words."
+ (should (= 3 (test-count-words "café résumé naïve"))))
+
+(ert-deftest test-count-words-very-long-text ()
+ "Should handle very long text."
+ (let ((long-text (mapconcat (lambda (_) "word") (make-list 1000 nil) " ")))
+ (should (= 1000 (test-count-words long-text)))))
+
+(ert-deftest test-count-words-multiline-paragraph ()
+ "Should count words in multi-line paragraph."
+ (let ((text "This is a paragraph
+that spans multiple
+lines with various
+words in it."))
+ (should (= 13 (test-count-words text)))))
+
+;;; Error Cases
+
+(ert-deftest test-count-words-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "hello world")
+ (cj/--count-words (point-max) (point-min)))
+ :type 'error))
+
+(ert-deftest test-count-words-empty-region ()
+ "Should return 0 for empty region (start == end)."
+ (with-temp-buffer
+ (insert "hello world")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (should (= 0 (cj/--count-words pos pos))))))
+
+(ert-deftest test-count-words-partial-region ()
+ "Should count words only in specified region."
+ (with-temp-buffer
+ (insert "one two three four five")
+ ;; Count only "two three four" (positions roughly in middle)
+ (goto-char (point-min))
+ (search-forward "two")
+ (let ((start (match-beginning 0)))
+ (search-forward "four")
+ (let ((end (match-end 0)))
+ (should (= 3 (cj/--count-words start end)))))))
+
+(provide 'test-custom-misc-count-words)
+;;; test-custom-misc-count-words.el ends here
diff --git a/tests/test-custom-misc-format-region.el b/tests/test-custom-misc-format-region.el
new file mode 100644
index 00000000..c40a8898
--- /dev/null
+++ b/tests/test-custom-misc-format-region.el
@@ -0,0 +1,161 @@
+;;; test-custom-misc-format-region.el --- Tests for cj/--format-region -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--format-region function from custom-misc.el
+;;
+;; This function reformats text by applying three operations:
+;; 1. untabify - converts tabs to spaces
+;; 2. indent-region - reindents according to major mode
+;; 3. delete-trailing-whitespace - removes trailing whitespace
+;;
+;; Note: indent-region behavior is major-mode dependent. We test in
+;; emacs-lisp-mode and fundamental-mode for predictable results.
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--format-region)
+;; to avoid mocking region selection. This follows our testing best practice
+;; of separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-misc)
+
+;;; Test Helpers
+
+(defun test-format-region (input-text &optional mode)
+ "Test cj/--format-region on INPUT-TEXT.
+MODE is the major mode to use (defaults to fundamental-mode).
+Returns the buffer string after operation."
+ (with-temp-buffer
+ (funcall (or mode #'fundamental-mode))
+ (insert input-text)
+ (cj/--format-region (point-min) (point-max))
+ (buffer-string)))
+
+;;; Normal Cases - Tab Conversion
+
+(ert-deftest test-format-region-converts-tabs ()
+ "Should convert tabs to spaces."
+ (let ((result (test-format-region "hello\tworld")))
+ (should-not (string-match-p "\t" result))
+ (should (string-match-p " " result))))
+
+(ert-deftest test-format-region-multiple-tabs ()
+ "Should convert multiple tabs."
+ (let ((result (test-format-region "\t\thello\t\tworld\t\t")))
+ (should-not (string-match-p "\t" result))))
+
+;;; Normal Cases - Trailing Whitespace
+
+(ert-deftest test-format-region-removes-trailing-spaces ()
+ "Should remove trailing spaces."
+ (let ((result (test-format-region "hello world ")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-format-region-removes-trailing-tabs ()
+ "Should remove trailing tabs."
+ (let ((result (test-format-region "hello world\t\t")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-format-region-removes-trailing-mixed ()
+ "Should remove trailing mixed whitespace."
+ (let ((result (test-format-region "hello world \t \t ")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-format-region-multiline-trailing ()
+ "Should remove trailing whitespace from multiple lines."
+ (let ((result (test-format-region "line1 \nline2\t\t\nline3 \t ")))
+ (should (string= result "line1\nline2\nline3"))))
+
+;;; Normal Cases - Combined Operations
+
+(ert-deftest test-format-region-tabs-and-trailing ()
+ "Should handle both tabs and trailing whitespace."
+ (let ((result (test-format-region "\thello\tworld\t\t")))
+ (should-not (string-match-p "\t" result))
+ ;; Should not end with whitespace
+ (should-not (string-match-p "[ \t]+$" result))))
+
+(ert-deftest test-format-region-preserves-interior-spaces ()
+ "Should preserve interior spaces while fixing edges."
+ (let ((result (test-format-region "\thello world\t")))
+ (should (string-match-p "hello world" result))
+ (should-not (string-match-p "\t" result))))
+
+;;; Normal Cases - Indentation (Mode-Specific)
+
+(ert-deftest test-format-region-elisp-indentation ()
+ "Should reindent Elisp code."
+ (let* ((input "(defun foo ()\n(+ 1 2))")
+ (result (test-format-region input #'emacs-lisp-mode))
+ (lines (split-string result "\n")))
+ ;; The inner form should be indented - second line should start with 2 spaces
+ (should (= 2 (length lines)))
+ (should (string-prefix-p "(defun foo ()" (car lines)))
+ (should (string-prefix-p " " (cadr lines)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-format-region-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-format-region "")))
+ (should (string= result ""))))
+
+(ert-deftest test-format-region-no-issues ()
+ "Should handle text with no formatting issues (no-op)."
+ (let ((result (test-format-region "hello world")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-format-region-only-whitespace ()
+ "Should handle text with only whitespace."
+ (let ((result (test-format-region "\t \t ")))
+ ;; Should become empty or just spaces, no tabs
+ (should-not (string-match-p "\t" result))))
+
+(ert-deftest test-format-region-single-line ()
+ "Should handle single line."
+ (let ((result (test-format-region "\thello\t")))
+ (should-not (string-match-p "\t" result))))
+
+(ert-deftest test-format-region-very-long-text ()
+ "Should handle very long text."
+ (let* ((long-text (mapconcat (lambda (_) "\thello\t") (make-list 100 nil) "\n"))
+ (result (test-format-region long-text)))
+ (should-not (string-match-p "\t" result))))
+
+(ert-deftest test-format-region-newlines-preserved ()
+ "Should preserve newlines while fixing formatting."
+ (let ((result (test-format-region "line1\t \nline2\t \nline3\t ")))
+ (should (= 2 (cl-count ?\n result)))))
+
+;;; Error Cases
+
+(ert-deftest test-format-region-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "hello world")
+ (cj/--format-region (point-max) (point-min)))
+ :type 'error))
+
+(ert-deftest test-format-region-empty-region ()
+ "Should handle empty region (start == end) without error."
+ (with-temp-buffer
+ (insert "hello world")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (cj/--format-region pos pos)
+ ;; Should complete without error
+ (should (string= (buffer-string) "hello world")))))
+
+(provide 'test-custom-misc-format-region)
+;;; test-custom-misc-format-region.el ends here
diff --git a/tests/test-custom-misc-jump-to-matching-paren.el b/tests/test-custom-misc-jump-to-matching-paren.el
new file mode 100644
index 00000000..973b6dfa
--- /dev/null
+++ b/tests/test-custom-misc-jump-to-matching-paren.el
@@ -0,0 +1,197 @@
+;;; test-custom-misc-jump-to-matching-paren.el --- Tests for cj/jump-to-matching-paren -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/jump-to-matching-paren function from custom-misc.el
+;;
+;; This function jumps to matching delimiters using Emacs's sexp navigation.
+;; It works with any delimiter that has matching syntax according to the
+;; current syntax table (parentheses, brackets, braces, etc.).
+;;
+;; Unlike other functions in this test suite, this is an INTERACTIVE function
+;; that moves point and displays messages. We test it as an integration test
+;; by setting up buffers, positioning point, calling the function, and
+;; verifying where point ends up.
+;;
+;; Key behaviors:
+;; - When on opening delimiter: jump forward to matching closing delimiter
+;; - When on closing delimiter: jump backward to matching opening delimiter
+;; - When just after closing delimiter: jump backward to matching opening
+;; - When not on delimiter: display message, don't move
+;; - When no matching delimiter: display error message, don't move
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-misc)
+
+;;; Test Helpers
+
+(defun test-jump-to-matching-paren (text point-position)
+ "Test cj/jump-to-matching-paren with TEXT and point at POINT-POSITION.
+Returns the new point position after calling the function.
+POINT-POSITION is 1-indexed (1 = first character)."
+ (with-temp-buffer
+ (emacs-lisp-mode) ; Use elisp mode for proper syntax table
+ (insert text)
+ (goto-char point-position)
+ (cj/jump-to-matching-paren)
+ (point)))
+
+;;; Normal Cases - Forward Jump (Opening to Closing)
+
+(ert-deftest test-jump-paren-forward-simple ()
+ "Should jump forward from opening paren to closing paren."
+ ;; Text: "(hello)"
+ ;; Start at position 1 (on opening paren)
+ ;; Should end at position 8 (after closing paren)
+ (should (= 8 (test-jump-to-matching-paren "(hello)" 1))))
+
+(ert-deftest test-jump-paren-forward-nested ()
+ "Should jump forward over nested parens."
+ ;; Text: "(foo (bar))"
+ ;; Start at position 1 (on outer opening paren)
+ ;; Should end at position 12 (after outer closing paren)
+ (should (= 12 (test-jump-to-matching-paren "(foo (bar))" 1))))
+
+(ert-deftest test-jump-paren-forward-inner-nested ()
+ "Should jump forward from inner opening paren."
+ ;; Text: "(foo (bar))"
+ ;; Start at position 6 (on inner opening paren)
+ ;; Should end at position 11 (after inner closing paren)
+ (should (= 11 (test-jump-to-matching-paren "(foo (bar))" 6))))
+
+(ert-deftest test-jump-bracket-forward ()
+ "Should jump forward from opening bracket."
+ ;; Text: "[1 2 3]"
+ ;; Start at position 1
+ ;; Should end at position 8
+ (should (= 8 (test-jump-to-matching-paren "[1 2 3]" 1))))
+
+;; Note: Braces are not treated as matching delimiters in emacs-lisp-mode
+;; so we don't test them here
+
+;;; Normal Cases - Backward Jump (Closing to Opening)
+
+(ert-deftest test-jump-paren-backward-simple ()
+ "Should jump backward from closing paren to opening paren."
+ ;; Text: "(hello)"
+ ;; Start at position 7 (on closing paren)
+ ;; Should end at position 2 (after opening paren)
+ (should (= 2 (test-jump-to-matching-paren "(hello)" 7))))
+
+(ert-deftest test-jump-paren-backward-nested ()
+ "Should jump backward over nested parens from after outer closing."
+ ;; Text: "(foo (bar))"
+ ;; Start at position 12 (after outer closing paren)
+ ;; backward-sexp goes back to before opening paren
+ (should (= 1 (test-jump-to-matching-paren "(foo (bar))" 12))))
+
+(ert-deftest test-jump-paren-backward-inner-nested ()
+ "Should jump backward from inner closing paren."
+ ;; Text: "(foo (bar))"
+ ;; Start at position 10 (on inner closing paren)
+ ;; Should end at position 7 (after inner opening paren)
+ (should (= 7 (test-jump-to-matching-paren "(foo (bar))" 10))))
+
+(ert-deftest test-jump-bracket-backward ()
+ "Should jump backward from after closing bracket."
+ ;; Text: "[1 2 3]"
+ ;; Start at position 8 (after ])
+ ;; backward-sexp goes back one sexp
+ (should (= 1 (test-jump-to-matching-paren "[1 2 3]" 8))))
+
+;;; Normal Cases - Jump from After Closing Delimiter
+
+(ert-deftest test-jump-paren-after-closing ()
+ "Should jump backward when just after closing paren."
+ ;; Text: "(hello)"
+ ;; Start at position 8 (after closing paren)
+ ;; backward-sexp goes back one sexp, ending before the opening paren
+ (should (= 1 (test-jump-to-matching-paren "(hello)" 8))))
+
+;;; Boundary Cases - No Movement
+
+(ert-deftest test-jump-paren-not-on-delimiter ()
+ "Should not move when not on delimiter."
+ ;; Text: "(hello world)"
+ ;; Start at position 3 (on 'e' in hello)
+ ;; Should stay at position 3
+ (should (= 3 (test-jump-to-matching-paren "(hello world)" 3))))
+
+(ert-deftest test-jump-paren-on-whitespace ()
+ "Should not move when on whitespace."
+ ;; Text: "(hello world)"
+ ;; Start at position 7 (on space)
+ ;; Should stay at position 7
+ (should (= 7 (test-jump-to-matching-paren "(hello world)" 7))))
+
+;;; Boundary Cases - Unmatched Delimiters
+
+(ert-deftest test-jump-paren-unmatched-opening ()
+ "Should not move from unmatched opening paren."
+ ;; Text: "(hello"
+ ;; Start at position 1 (on opening paren with no closing)
+ ;; Should stay at position 1
+ (should (= 1 (test-jump-to-matching-paren "(hello" 1))))
+
+(ert-deftest test-jump-paren-unmatched-closing ()
+ "Should move to beginning from unmatched closing paren."
+ ;; Text: "hello)"
+ ;; Start at position 6 (on closing paren with no opening)
+ ;; backward-sexp with unmatched closing paren goes to beginning
+ (should (= 1 (test-jump-to-matching-paren "hello)" 6))))
+
+;;; Boundary Cases - Empty Delimiters
+
+(ert-deftest test-jump-paren-empty ()
+ "Should jump over empty parens."
+ ;; Text: "()"
+ ;; Start at position 1
+ ;; Should end at position 3
+ (should (= 3 (test-jump-to-matching-paren "()" 1))))
+
+(ert-deftest test-jump-paren-empty-backward ()
+ "Should stay put when on closing paren of empty parens."
+ ;; Text: "()"
+ ;; Start at position 2 (on closing paren)
+ ;; backward-sexp from closing of empty parens gives an error, so stays at 2
+ (should (= 2 (test-jump-to-matching-paren "()" 2))))
+
+;;; Boundary Cases - Multiple Delimiter Types
+
+(ert-deftest test-jump-paren-mixed-delimiters ()
+ "Should jump over mixed delimiter types."
+ ;; Text: "(foo [bar {baz}])"
+ ;; Start at position 1 (on opening paren)
+ ;; Should end at position 18 (after closing paren)
+ (should (= 18 (test-jump-to-matching-paren "(foo [bar {baz}])" 1))))
+
+(ert-deftest test-jump-bracket-in-parens ()
+ "Should jump from bracket inside parens."
+ ;; Text: "(foo [bar])"
+ ;; Start at position 6 (on opening bracket)
+ ;; Should end at position 11 (after closing bracket)
+ (should (= 11 (test-jump-to-matching-paren "(foo [bar])" 6))))
+
+;;; Complex Cases - Strings and Comments
+
+(ert-deftest test-jump-paren-over-string ()
+ "Should jump over parens containing strings."
+ ;; Text: "(\"hello (world)\")"
+ ;; Start at position 1 (on opening paren)
+ ;; Should end at position 18 (after closing paren)
+ ;; The parens in the string should be ignored
+ (should (= 18 (test-jump-to-matching-paren "(\"hello (world)\")" 1))))
+
+(provide 'test-custom-misc-jump-to-matching-paren)
+;;; test-custom-misc-jump-to-matching-paren.el ends here
diff --git a/tests/test-custom-misc-replace-fraction-glyphs.el b/tests/test-custom-misc-replace-fraction-glyphs.el
new file mode 100644
index 00000000..81d1546e
--- /dev/null
+++ b/tests/test-custom-misc-replace-fraction-glyphs.el
@@ -0,0 +1,185 @@
+;;; test-custom-misc-replace-fraction-glyphs.el --- Tests for cj/--replace-fraction-glyphs -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--replace-fraction-glyphs function from custom-misc.el
+;;
+;; This function bidirectionally converts between text fractions (1/4) and
+;; Unicode fraction glyphs (¼). It supports 5 common fractions:
+;; - 1/4 ↔ ¼
+;; - 1/2 ↔ ½
+;; - 3/4 ↔ ¾
+;; - 1/3 ↔ ⅓
+;; - 2/3 ↔ ⅔
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--replace-fraction-glyphs)
+;; to avoid mocking prefix arguments. This follows our testing best practice
+;; of separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-misc)
+
+;;; Test Helpers
+
+(defun test-replace-fraction-glyphs (input-text to-glyphs)
+ "Test cj/--replace-fraction-glyphs on INPUT-TEXT.
+TO-GLYPHS determines conversion direction.
+Returns the buffer string after operation."
+ (with-temp-buffer
+ (insert input-text)
+ (cj/--replace-fraction-glyphs (point-min) (point-max) to-glyphs)
+ (buffer-string)))
+
+;;; Normal Cases - Text to Glyphs
+
+(ert-deftest test-replace-fraction-glyphs-text-to-glyph-quarter ()
+ "Should convert 1/4 to ¼."
+ (let ((result (test-replace-fraction-glyphs "1/4" t)))
+ (should (string= result "¼"))))
+
+(ert-deftest test-replace-fraction-glyphs-text-to-glyph-half ()
+ "Should convert 1/2 to ½."
+ (let ((result (test-replace-fraction-glyphs "1/2" t)))
+ (should (string= result "½"))))
+
+(ert-deftest test-replace-fraction-glyphs-text-to-glyph-three-quarters ()
+ "Should convert 3/4 to ¾."
+ (let ((result (test-replace-fraction-glyphs "3/4" t)))
+ (should (string= result "¾"))))
+
+(ert-deftest test-replace-fraction-glyphs-text-to-glyph-third ()
+ "Should convert 1/3 to ⅓."
+ (let ((result (test-replace-fraction-glyphs "1/3" t)))
+ (should (string= result "⅓"))))
+
+(ert-deftest test-replace-fraction-glyphs-text-to-glyph-two-thirds ()
+ "Should convert 2/3 to ⅔."
+ (let ((result (test-replace-fraction-glyphs "2/3" t)))
+ (should (string= result "⅔"))))
+
+(ert-deftest test-replace-fraction-glyphs-text-to-glyph-multiple ()
+ "Should convert multiple fractions in text."
+ (let ((result (test-replace-fraction-glyphs "Use 1/4 cup and 1/2 teaspoon" t)))
+ (should (string= result "Use ¼ cup and ½ teaspoon"))))
+
+(ert-deftest test-replace-fraction-glyphs-text-to-glyph-all-types ()
+ "Should convert all fraction types."
+ (let ((result (test-replace-fraction-glyphs "1/4 1/2 3/4 1/3 2/3" t)))
+ (should (string= result "¼ ½ ¾ ⅓ ⅔"))))
+
+;;; Normal Cases - Glyphs to Text
+
+(ert-deftest test-replace-fraction-glyphs-glyph-to-text-quarter ()
+ "Should convert ¼ to 1/4."
+ (let ((result (test-replace-fraction-glyphs "¼" nil)))
+ (should (string= result "1/4"))))
+
+(ert-deftest test-replace-fraction-glyphs-glyph-to-text-half ()
+ "Should convert ½ to 1/2."
+ (let ((result (test-replace-fraction-glyphs "½" nil)))
+ (should (string= result "1/2"))))
+
+(ert-deftest test-replace-fraction-glyphs-glyph-to-text-three-quarters ()
+ "Should convert ¾ to 3/4."
+ (let ((result (test-replace-fraction-glyphs "¾" nil)))
+ (should (string= result "3/4"))))
+
+(ert-deftest test-replace-fraction-glyphs-glyph-to-text-third ()
+ "Should convert ⅓ to 1/3."
+ (let ((result (test-replace-fraction-glyphs "⅓" nil)))
+ (should (string= result "1/3"))))
+
+(ert-deftest test-replace-fraction-glyphs-glyph-to-text-two-thirds ()
+ "Should convert ⅔ to 2/3."
+ (let ((result (test-replace-fraction-glyphs "⅔" nil)))
+ (should (string= result "2/3"))))
+
+(ert-deftest test-replace-fraction-glyphs-glyph-to-text-multiple ()
+ "Should convert multiple glyphs in text."
+ (let ((result (test-replace-fraction-glyphs "Use ¼ cup and ½ teaspoon" nil)))
+ (should (string= result "Use 1/4 cup and 1/2 teaspoon"))))
+
+(ert-deftest test-replace-fraction-glyphs-glyph-to-text-all-types ()
+ "Should convert all glyph types."
+ (let ((result (test-replace-fraction-glyphs "¼ ½ ¾ ⅓ ⅔" nil)))
+ (should (string= result "1/4 1/2 3/4 1/3 2/3"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-replace-fraction-glyphs-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-replace-fraction-glyphs "" t)))
+ (should (string= result ""))))
+
+(ert-deftest test-replace-fraction-glyphs-no-fractions-to-glyphs ()
+ "Should handle text with no fractions (no-op) when converting to glyphs."
+ (let ((result (test-replace-fraction-glyphs "hello world" t)))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-replace-fraction-glyphs-no-fractions-to-text ()
+ "Should handle text with no glyphs (no-op) when converting to text."
+ (let ((result (test-replace-fraction-glyphs "hello world" nil)))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-replace-fraction-glyphs-at-start ()
+ "Should handle fraction at start of text."
+ (let ((result (test-replace-fraction-glyphs "1/2 of the total" t)))
+ (should (string= result "½ of the total"))))
+
+(ert-deftest test-replace-fraction-glyphs-at-end ()
+ "Should handle fraction at end of text."
+ (let ((result (test-replace-fraction-glyphs "Reduce by 1/4" t)))
+ (should (string= result "Reduce by ¼"))))
+
+(ert-deftest test-replace-fraction-glyphs-repeated ()
+ "Should handle repeated fractions."
+ (let ((result (test-replace-fraction-glyphs "1/4 and 1/4 and 1/4" t)))
+ (should (string= result "¼ and ¼ and ¼"))))
+
+(ert-deftest test-replace-fraction-glyphs-very-long-text ()
+ "Should handle very long text with many fractions."
+ (let* ((long-text (mapconcat (lambda (_) "1/4") (make-list 50 nil) " "))
+ (result (test-replace-fraction-glyphs long-text t)))
+ (should (string-match-p "¼" result))
+ (should-not (string-match-p "1/4" result))))
+
+(ert-deftest test-replace-fraction-glyphs-bidirectional ()
+ "Should correctly convert back and forth."
+ (let* ((original "Use 1/4 cup")
+ (to-glyph (test-replace-fraction-glyphs original t))
+ (back-to-text (test-replace-fraction-glyphs to-glyph nil)))
+ (should (string= to-glyph "Use ¼ cup"))
+ (should (string= back-to-text original))))
+
+;;; Error Cases
+
+(ert-deftest test-replace-fraction-glyphs-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "1/4")
+ (cj/--replace-fraction-glyphs (point-max) (point-min) t))
+ :type 'error))
+
+(ert-deftest test-replace-fraction-glyphs-empty-region ()
+ "Should handle empty region (start == end) without error."
+ (with-temp-buffer
+ (insert "1/4")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (cj/--replace-fraction-glyphs pos pos t)
+ ;; Should complete without error
+ (should (string= (buffer-string) "1/4")))))
+
+(provide 'test-custom-misc-replace-fraction-glyphs)
+;;; test-custom-misc-replace-fraction-glyphs.el ends here