diff options
Diffstat (limited to 'tests')
148 files changed, 25031 insertions, 2048 deletions
diff --git a/tests/fixtures/audio/microphone-input-test.wav b/tests/fixtures/audio/microphone-input-test.wav Binary files differnew file mode 100644 index 00000000..9a59c6a4 --- /dev/null +++ b/tests/fixtures/audio/microphone-input-test.wav diff --git a/tests/fixtures/audio/speaker-output-test.wav b/tests/fixtures/audio/speaker-output-test.wav Binary files differnew file mode 100644 index 00000000..008e9710 --- /dev/null +++ b/tests/fixtures/audio/speaker-output-test.wav diff --git a/tests/fixtures/grammar-correct.txt b/tests/fixtures/grammar-correct.txt new file mode 100644 index 00000000..bea335e8 --- /dev/null +++ b/tests/fixtures/grammar-correct.txt @@ -0,0 +1,5 @@ +This is a well-written sentence with no grammar errors. + +The quick brown fox jumps over the lazy dog. + +Everything here follows standard English grammar rules. diff --git a/tests/fixtures/grammar-errors-basic.txt b/tests/fixtures/grammar-errors-basic.txt new file mode 100644 index 00000000..c2f72c12 --- /dev/null +++ b/tests/fixtures/grammar-errors-basic.txt @@ -0,0 +1,7 @@ +This are a test of basic grammar errors. + +I could of done better with this sentence. + +Their going to the store to buy there groceries. + +The dog wagged it's tail happily. diff --git a/tests/fixtures/grammar-errors-punctuation.txt b/tests/fixtures/grammar-errors-punctuation.txt new file mode 100644 index 00000000..37de646a --- /dev/null +++ b/tests/fixtures/grammar-errors-punctuation.txt @@ -0,0 +1,5 @@ +This sentence is missing punctuation at the end + +Multiple spaces between words should be detected. + +A sentence with,incorrect comma,placement and usage. diff --git a/tests/fixtures/pactl-output-empty.txt b/tests/fixtures/pactl-output-empty.txt new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/tests/fixtures/pactl-output-empty.txt diff --git a/tests/fixtures/pactl-output-inputs-only.txt b/tests/fixtures/pactl-output-inputs-only.txt new file mode 100644 index 00000000..1840b37c --- /dev/null +++ b/tests/fixtures/pactl-output-inputs-only.txt @@ -0,0 +1,3 @@ +50 alsa_input.pci-0000_00_1f.3.analog-stereo PipeWire s32le 2ch 48000Hz SUSPENDED +79 bluez_input.00:1B:66:C0:91:6D PipeWire float32le 1ch 48000Hz SUSPENDED +100 alsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback PipeWire s16le 1ch 16000Hz SUSPENDED diff --git a/tests/fixtures/pactl-output-malformed.txt b/tests/fixtures/pactl-output-malformed.txt new file mode 100644 index 00000000..a37b8dd6 --- /dev/null +++ b/tests/fixtures/pactl-output-malformed.txt @@ -0,0 +1,4 @@ +This is not valid pactl output +Some random text +50 incomplete-line-missing-fields +Another bad line with only two tabs diff --git a/tests/fixtures/pactl-output-monitors-only.txt b/tests/fixtures/pactl-output-monitors-only.txt new file mode 100644 index 00000000..be29ebe8 --- /dev/null +++ b/tests/fixtures/pactl-output-monitors-only.txt @@ -0,0 +1,3 @@ +49 alsa_output.pci-0000_00_1f.3.analog-stereo.monitor PipeWire s32le 2ch 48000Hz SUSPENDED +81 bluez_output.00_1B_66_C0_91_6D.1.monitor PipeWire s24le 2ch 48000Hz RUNNING +99 alsa_output.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo.monitor PipeWire s16le 2ch 48000Hz SUSPENDED diff --git a/tests/fixtures/pactl-output-normal.txt b/tests/fixtures/pactl-output-normal.txt new file mode 100644 index 00000000..6d8d955b --- /dev/null +++ b/tests/fixtures/pactl-output-normal.txt @@ -0,0 +1,6 @@ +49 alsa_output.pci-0000_00_1f.3.analog-stereo.monitor PipeWire s32le 2ch 48000Hz SUSPENDED +50 alsa_input.pci-0000_00_1f.3.analog-stereo PipeWire s32le 2ch 48000Hz SUSPENDED +79 bluez_input.00:1B:66:C0:91:6D PipeWire float32le 1ch 48000Hz SUSPENDED +81 bluez_output.00_1B_66_C0_91_6D.1.monitor PipeWire s24le 2ch 48000Hz SUSPENDED +99 alsa_output.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo.monitor PipeWire s16le 2ch 48000Hz SUSPENDED +100 alsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback PipeWire s16le 1ch 16000Hz SUSPENDED diff --git a/tests/fixtures/pactl-output-single.txt b/tests/fixtures/pactl-output-single.txt new file mode 100644 index 00000000..d1d1c254 --- /dev/null +++ b/tests/fixtures/pactl-output-single.txt @@ -0,0 +1 @@ +50 alsa_input.pci-0000_00_1f.3.analog-stereo PipeWire s32le 2ch 48000Hz SUSPENDED 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-calendar-sync--expand-weekly.el b/tests/test-calendar-sync--expand-weekly.el new file mode 100644 index 00000000..fe333c98 --- /dev/null +++ b/tests/test-calendar-sync--expand-weekly.el @@ -0,0 +1,274 @@ +;;; test-calendar-sync--expand-weekly.el --- Tests for calendar-sync--expand-weekly -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for calendar-sync--expand-weekly function. +;; Tests expansion of weekly recurring events into individual occurrences. +;; Uses dynamic timestamps to avoid hardcoded dates. + +;;; Code: + +(require 'ert) +(require 'calendar-sync) +(require 'testutil-calendar-sync) + +;;; Setup and Teardown + +(defun test-calendar-sync--expand-weekly-setup () + "Setup for calendar-sync--expand-weekly tests." + nil) + +(defun test-calendar-sync--expand-weekly-teardown () + "Teardown for calendar-sync--expand-weekly tests." + nil) + +;;; Normal Cases + +(ert-deftest test-calendar-sync--expand-weekly-normal-saturday-returns-occurrences () + "Test expanding weekly event on Saturday (GTFO use case)." + (test-calendar-sync--expand-weekly-setup) + (unwind-protect + (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 30)) + (end-date (test-calendar-sync-time-days-from-now 1 11 0)) + (base-event (list :summary "GTFO" + :start start-date + :end end-date)) + (rrule (list :freq 'weekly + :byday '("SA") + :interval 1)) + ;; Date range: 90 days past to 365 days future + (range (list (time-subtract (current-time) (* 90 24 3600)) + (time-add (current-time) (* 365 24 3600)))) + (occurrences (calendar-sync--expand-weekly base-event rrule range))) + ;; Should generate ~52 Saturday occurrences in a year + (should (> (length occurrences) 40)) + (should (< (length occurrences) 60)) + ;; Each occurrence should be a Saturday + (dolist (occurrence occurrences) + (let* ((start (plist-get occurrence :start)) + (weekday (calendar-sync--date-weekday (list (nth 0 start) (nth 1 start) (nth 2 start))))) + (should (= weekday 6))))) ; Saturday = 6 + (test-calendar-sync--expand-weekly-teardown))) + +(ert-deftest test-calendar-sync--expand-weekly-normal-multiple-days-returns-occurrences () + "Test expanding weekly event on multiple weekdays." + (test-calendar-sync--expand-weekly-setup) + (unwind-protect + (let* ((start-date (test-calendar-sync-time-days-from-now 1 9 0)) + (end-date (test-calendar-sync-time-days-from-now 1 10 0)) + (base-event (list :summary "Standup" + :start start-date + :end end-date)) + (rrule (list :freq 'weekly + :byday '("MO" "WE" "FR") + :interval 1)) + (range (list (time-subtract (current-time) (* 30 24 3600)) + (time-add (current-time) (* 90 24 3600)))) + (occurrences (calendar-sync--expand-weekly base-event rrule range))) + ;; Should generate 3 occurrences per week for ~4 months + (should (> (length occurrences) 30)) + (should (< (length occurrences) 60)) + ;; Each occurrence should be Mon, Wed, or Fri + (dolist (occurrence occurrences) + (let* ((start (plist-get occurrence :start)) + (weekday (calendar-sync--date-weekday (list (nth 0 start) (nth 1 start) (nth 2 start))))) + (should (member weekday '(1 3 5)))))) ; Mon=1, Wed=3, Fri=5 + (test-calendar-sync--expand-weekly-teardown))) + +(ert-deftest test-calendar-sync--expand-weekly-normal-interval-two-returns-occurrences () + "Test expanding bi-weekly event." + (test-calendar-sync--expand-weekly-setup) + (unwind-protect + (let* ((start-date (test-calendar-sync-time-days-from-now 1 14 0)) + (end-date (test-calendar-sync-time-days-from-now 1 15 0)) + (base-event (list :summary "Bi-weekly Meeting" + :start start-date + :end end-date)) + (rrule (list :freq 'weekly + :byday '("TU") + :interval 2)) + (range (list (time-subtract (current-time) (* 30 24 3600)) + (time-add (current-time) (* 180 24 3600)))) + (occurrences (calendar-sync--expand-weekly base-event rrule range))) + ;; Should generate ~13 occurrences (26 weeks = 13 bi-weekly) + (should (> (length occurrences) 10)) + (should (< (length occurrences) 20))) + (test-calendar-sync--expand-weekly-teardown))) + +;;; Boundary Cases + +(ert-deftest test-calendar-sync--expand-weekly-boundary-with-count-returns-limited-occurrences () + "Test expanding weekly event with count limit." + (test-calendar-sync--expand-weekly-setup) + (unwind-protect + (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0)) + (end-date (test-calendar-sync-time-days-from-now 1 11 0)) + (base-event (list :summary "Limited Event" + :start start-date + :end end-date)) + (rrule (list :freq 'weekly + :byday '("MO") + :interval 1 + :count 5)) + (range (list (time-subtract (current-time) (* 30 24 3600)) + (time-add (current-time) (* 365 24 3600)))) + (occurrences (calendar-sync--expand-weekly base-event rrule range))) + ;; Should generate exactly 5 occurrences + (should (= (length occurrences) 5))) + (test-calendar-sync--expand-weekly-teardown))) + +(ert-deftest test-calendar-sync--expand-weekly-boundary-with-until-returns-limited-occurrences () + "Test expanding weekly event with end date." + (test-calendar-sync--expand-weekly-setup) + (unwind-protect + (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0)) + (end-date (test-calendar-sync-time-days-from-now 1 11 0)) + ;; UNTIL must be date-only (3 elements) for calendar-sync--before-date-p + (until-date (test-calendar-sync-time-date-only 60)) + (base-event (list :summary "Time-Limited Event" + :start start-date + :end end-date)) + (rrule (list :freq 'weekly + :byday '("WE") + :interval 1 + :until until-date)) + (range (list (time-subtract (current-time) (* 30 24 3600)) + (time-add (current-time) (* 365 24 3600)))) + (occurrences (calendar-sync--expand-weekly base-event rrule range))) + ;; Should generate ~8 Wednesday occurrences in 60 days + (should (> (length occurrences) 6)) + (should (< (length occurrences) 12))) + (test-calendar-sync--expand-weekly-teardown))) + +(ert-deftest test-calendar-sync--expand-weekly-boundary-no-byday-uses-start-day () + "Test expanding weekly event without BYDAY uses start date weekday." + (test-calendar-sync--expand-weekly-setup) + (unwind-protect + (let* ((start-date (test-calendar-sync-time-days-from-now 7 10 0)) + (end-date (test-calendar-sync-time-days-from-now 7 11 0)) + (start-weekday (calendar-sync--date-weekday (list (nth 0 start-date) (nth 1 start-date) (nth 2 start-date)))) + (base-event (list :summary "No BYDAY Event" + :start start-date + :end end-date)) + (rrule (list :freq 'weekly + :interval 1)) + (range (list (time-subtract (current-time) (* 30 24 3600)) + (time-add (current-time) (* 90 24 3600)))) + (occurrences (calendar-sync--expand-weekly base-event rrule range))) + ;; Should generate occurrences + (should (> (length occurrences) 8)) + ;; All occurrences should be on the same weekday as start + (dolist (occurrence occurrences) + (let* ((start (plist-get occurrence :start)) + (weekday (calendar-sync--date-weekday (list (nth 0 start) (nth 1 start) (nth 2 start))))) + (should (= weekday start-weekday))))) + (test-calendar-sync--expand-weekly-teardown))) + +(ert-deftest test-calendar-sync--expand-weekly-boundary-max-iterations-prevents-infinite-loop () + "Test that max iterations safety check prevents infinite loops." + (test-calendar-sync--expand-weekly-setup) + (unwind-protect + (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0)) + (end-date (test-calendar-sync-time-days-from-now 1 11 0)) + (base-event (list :summary "Event" + :start start-date + :end end-date)) + (rrule (list :freq 'weekly + :byday '("MO") + :interval 1)) + ;; Very large date range that would generate >1000 occurrences + (range (list (time-subtract (current-time) (* 365 24 3600)) + (time-add (current-time) (* 3650 24 3600)))) + (occurrences (calendar-sync--expand-weekly base-event rrule range))) + ;; Should stop at max iterations (1000) + (should (<= (length occurrences) 1000))) + (test-calendar-sync--expand-weekly-teardown))) + +(ert-deftest test-calendar-sync--expand-weekly-boundary-respects-date-range () + "Test that expansion respects date range boundaries." + (test-calendar-sync--expand-weekly-setup) + (unwind-protect + (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0)) + (end-date (test-calendar-sync-time-days-from-now 1 11 0)) + (base-event (list :summary "Event" + :start start-date + :end end-date)) + (rrule (list :freq 'weekly + :byday '("TH") + :interval 1)) + ;; Narrow date range: only 30 days + (range (list (current-time) + (time-add (current-time) (* 30 24 3600)))) + (occurrences (calendar-sync--expand-weekly base-event rrule range)) + (range-start (nth 0 range)) + (range-end (nth 1 range))) + ;; Should only generate ~4 Thursday occurrences in 30 days + (should (>= (length occurrences) 3)) + (should (<= (length occurrences) 5)) + ;; All occurrences should be within range + (dolist (occurrence occurrences) + (let* ((start (plist-get occurrence :start)) + (occ-time (apply #'encode-time 0 0 0 (reverse (list (nth 0 start) (nth 1 start) (nth 2 start)))))) + (should (time-less-p range-start occ-time)) + (should (time-less-p occ-time range-end))))) + (test-calendar-sync--expand-weekly-teardown))) + +;;; Error Cases + +(ert-deftest test-calendar-sync--expand-weekly-error-empty-base-event-returns-empty () + "Test expanding with minimal base event." + (test-calendar-sync--expand-weekly-setup) + (unwind-protect + (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0)) + (base-event (list :start start-date)) + (rrule (list :freq 'weekly + :interval 1)) + (range (list (current-time) + (time-add (current-time) (* 30 24 3600)))) + (occurrences (calendar-sync--expand-weekly base-event rrule range))) + ;; Should still generate occurrences even without end time + (should (> (length occurrences) 0))) + (test-calendar-sync--expand-weekly-teardown))) + +(ert-deftest test-calendar-sync--expand-weekly-error-zero-interval-returns-empty () + "Test that zero interval doesn't cause infinite loop." + (test-calendar-sync--expand-weekly-setup) + (unwind-protect + (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0)) + (end-date (test-calendar-sync-time-days-from-now 1 11 0)) + (base-event (list :summary "Event" + :start start-date + :end end-date)) + (rrule (list :freq 'weekly + :byday '("MO") + :interval 0)) ; Invalid! + (range (list (current-time) + (time-add (current-time) (* 30 24 3600))))) + ;; Should either return empty or handle gracefully + ;; Zero interval would cause infinite loop if not handled + (should-error (calendar-sync--expand-weekly base-event rrule range))) + (test-calendar-sync--expand-weekly-teardown))) + +(ert-deftest test-calendar-sync--expand-weekly-error-past-until-returns-empty () + "Test expanding event with UNTIL date in the past." + (test-calendar-sync--expand-weekly-setup) + (unwind-protect + (let* ((start-date (test-calendar-sync-time-days-ago 100 10 0)) + (end-date (test-calendar-sync-time-days-ago 100 11 0)) + ;; UNTIL must be date-only (3 elements) for calendar-sync--before-date-p + (until-date (test-calendar-sync-time-date-only-ago 50)) + (base-event (list :summary "Past Event" + :start start-date + :end end-date)) + (rrule (list :freq 'weekly + :byday '("MO") + :interval 1 + :until until-date)) + (range (list (time-subtract (current-time) (* 30 24 3600)) + (time-add (current-time) (* 365 24 3600)))) + (occurrences (calendar-sync--expand-weekly base-event rrule range))) + ;; Should return empty list (all occurrences before range) + (should (= (length occurrences) 0))) + (test-calendar-sync--expand-weekly-teardown))) + +(provide 'test-calendar-sync--expand-weekly) +;;; test-calendar-sync--expand-weekly.el ends here diff --git a/tests/test-calendar-sync--get-property.el b/tests/test-calendar-sync--get-property.el new file mode 100644 index 00000000..79fefc8f --- /dev/null +++ b/tests/test-calendar-sync--get-property.el @@ -0,0 +1,180 @@ +;;; test-calendar-sync--get-property.el --- Tests for calendar-sync--get-property -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for calendar-sync--get-property function. +;; Tests property extraction from iCalendar event strings, +;; especially with property parameters like TZID. +;; +;; Critical: This function had a bug where it couldn't parse +;; properties with parameters (e.g., DTSTART;TZID=America/Chicago:...) +;; These tests prevent regression of that bug. + +;;; Code: + +(require 'ert) +(require 'calendar-sync) + +;;; Setup and Teardown + +(defun test-calendar-sync--get-property-setup () + "Setup for calendar-sync--get-property tests." + nil) + +(defun test-calendar-sync--get-property-teardown () + "Teardown for calendar-sync--get-property tests." + nil) + +;;; Normal Cases + +(ert-deftest test-calendar-sync--get-property-normal-simple-property-returns-value () + "Test extracting simple property without parameters." + (test-calendar-sync--get-property-setup) + (unwind-protect + (let ((event "BEGIN:VEVENT\nSUMMARY:Test Event\nEND:VEVENT")) + (should (equal (calendar-sync--get-property event "SUMMARY") "Test Event"))) + (test-calendar-sync--get-property-teardown))) + +(ert-deftest test-calendar-sync--get-property-normal-dtstart-without-tzid-returns-value () + "Test extracting DTSTART without timezone parameter." + (test-calendar-sync--get-property-setup) + (unwind-protect + (let ((event "BEGIN:VEVENT\nDTSTART:20251118T140000Z\nEND:VEVENT")) + (should (equal (calendar-sync--get-property event "DTSTART") "20251118T140000Z"))) + (test-calendar-sync--get-property-teardown))) + +(ert-deftest test-calendar-sync--get-property-normal-dtstart-with-tzid-returns-value () + "Test extracting DTSTART with TZID parameter (the bug we fixed)." + (test-calendar-sync--get-property-setup) + (unwind-protect + (let ((event "BEGIN:VEVENT\nDTSTART;TZID=America/Chicago:20251118T140000\nEND:VEVENT")) + (should (equal (calendar-sync--get-property event "DTSTART") "20251118T140000"))) + (test-calendar-sync--get-property-teardown))) + +(ert-deftest test-calendar-sync--get-property-normal-location-returns-value () + "Test extracting LOCATION property." + (test-calendar-sync--get-property-setup) + (unwind-protect + (let ((event "BEGIN:VEVENT\nLOCATION:Conference Room A\nEND:VEVENT")) + (should (equal (calendar-sync--get-property event "LOCATION") "Conference Room A"))) + (test-calendar-sync--get-property-teardown))) + +(ert-deftest test-calendar-sync--get-property-normal-description-returns-value () + "Test extracting DESCRIPTION property." + (test-calendar-sync--get-property-setup) + (unwind-protect + (let ((event "BEGIN:VEVENT\nDESCRIPTION:This is a test event\nEND:VEVENT")) + (should (equal (calendar-sync--get-property event "DESCRIPTION") "This is a test event"))) + (test-calendar-sync--get-property-teardown))) + +(ert-deftest test-calendar-sync--get-property-normal-rrule-returns-value () + "Test extracting RRULE property." + (test-calendar-sync--get-property-setup) + (unwind-protect + (let ((event "BEGIN:VEVENT\nRRULE:FREQ=WEEKLY;BYDAY=SA\nEND:VEVENT")) + (should (equal (calendar-sync--get-property event "RRULE") "FREQ=WEEKLY;BYDAY=SA"))) + (test-calendar-sync--get-property-teardown))) + +;;; Boundary Cases + +(ert-deftest test-calendar-sync--get-property-boundary-value-param-with-multiple-params-returns-value () + "Test extracting property with multiple parameters." + (test-calendar-sync--get-property-setup) + (unwind-protect + (let ((event "BEGIN:VEVENT\nDTSTART;TZID=America/Chicago;VALUE=DATE-TIME:20251118T140000\nEND:VEVENT")) + (should (equal (calendar-sync--get-property event "DTSTART") "20251118T140000"))) + (test-calendar-sync--get-property-teardown))) + +(ert-deftest test-calendar-sync--get-property-boundary-complex-tzid-returns-value () + "Test extracting property with complex timezone ID." + (test-calendar-sync--get-property-setup) + (unwind-protect + (let ((event "BEGIN:VEVENT\nDTSTART;TZID=America/Argentina/Buenos_Aires:20251118T140000\nEND:VEVENT")) + (should (equal (calendar-sync--get-property event "DTSTART") "20251118T140000"))) + (test-calendar-sync--get-property-teardown))) + +(ert-deftest test-calendar-sync--get-property-boundary-empty-value-returns-empty-string () + "Test extracting property with empty value." + (test-calendar-sync--get-property-setup) + (unwind-protect + (let ((event "BEGIN:VEVENT\nDESCRIPTION:\nEND:VEVENT")) + (should (equal (calendar-sync--get-property event "DESCRIPTION") ""))) + (test-calendar-sync--get-property-teardown))) + +(ert-deftest test-calendar-sync--get-property-boundary-property-at-start-returns-value () + "Test extracting property when it's the first line." + (test-calendar-sync--get-property-setup) + (unwind-protect + (let ((event "SUMMARY:First Property\nDTSTART:20251118T140000Z")) + (should (equal (calendar-sync--get-property event "SUMMARY") "First Property"))) + (test-calendar-sync--get-property-teardown))) + +(ert-deftest test-calendar-sync--get-property-boundary-property-at-end-returns-value () + "Test extracting property when it's the last line." + (test-calendar-sync--get-property-setup) + (unwind-protect + (let ((event "DTSTART:20251118T140000Z\nSUMMARY:Last Property")) + (should (equal (calendar-sync--get-property event "SUMMARY") "Last Property"))) + (test-calendar-sync--get-property-teardown))) + +(ert-deftest test-calendar-sync--get-property-boundary-value-with-special-chars-returns-value () + "Test extracting property value with special characters." + (test-calendar-sync--get-property-setup) + (unwind-protect + (let ((event "BEGIN:VEVENT\nLOCATION:Room 123, Building A (Main Campus)\nEND:VEVENT")) + (should (equal (calendar-sync--get-property event "LOCATION") "Room 123, Building A (Main Campus)"))) + (test-calendar-sync--get-property-teardown))) + +(ert-deftest test-calendar-sync--get-property-boundary-value-with-semicolons-returns-value () + "Test extracting property value containing semicolons." + (test-calendar-sync--get-property-setup) + (unwind-protect + (let ((event "BEGIN:VEVENT\nDESCRIPTION:Tasks: setup; review; deploy\nEND:VEVENT")) + (should (equal (calendar-sync--get-property event "DESCRIPTION") "Tasks: setup; review; deploy"))) + (test-calendar-sync--get-property-teardown))) + +;;; Error Cases + +(ert-deftest test-calendar-sync--get-property-error-missing-property-returns-nil () + "Test extracting non-existent property returns nil." + (test-calendar-sync--get-property-setup) + (unwind-protect + (let ((event "BEGIN:VEVENT\nSUMMARY:Test Event\nEND:VEVENT")) + (should (null (calendar-sync--get-property event "LOCATION")))) + (test-calendar-sync--get-property-teardown))) + +(ert-deftest test-calendar-sync--get-property-error-empty-event-returns-nil () + "Test extracting property from empty event string." + (test-calendar-sync--get-property-setup) + (unwind-protect + (should (null (calendar-sync--get-property "" "SUMMARY"))) + (test-calendar-sync--get-property-teardown))) + +(ert-deftest test-calendar-sync--get-property-error-malformed-property-returns-nil () + "Test extracting property with missing colon. +Malformed properties without colons should not match." + (test-calendar-sync--get-property-setup) + (unwind-protect + (let ((event "BEGIN:VEVENT\nSUMMARY Test Event\nEND:VEVENT")) + ;; Space instead of colon - should not match + (should (null (calendar-sync--get-property event "SUMMARY")))) + (test-calendar-sync--get-property-teardown))) + +(ert-deftest test-calendar-sync--get-property-boundary-case-insensitive-returns-value () + "Test that property matching is case-insensitive per RFC 5545. +iCalendar spec requires property names to be case-insensitive." + (test-calendar-sync--get-property-setup) + (unwind-protect + (let ((event "BEGIN:VEVENT\nsummary:Test Event\nEND:VEVENT")) + (should (equal (calendar-sync--get-property event "SUMMARY") "Test Event"))) + (test-calendar-sync--get-property-teardown))) + +(ert-deftest test-calendar-sync--get-property-error-property-in-value-not-matched () + "Test that property name in another property's value is not matched." + (test-calendar-sync--get-property-setup) + (unwind-protect + (let ((event "BEGIN:VEVENT\nDESCRIPTION:SUMMARY: Not a real summary\nEND:VEVENT")) + (should (null (calendar-sync--get-property event "SUMMARY")))) + (test-calendar-sync--get-property-teardown))) + +(provide 'test-calendar-sync--get-property) +;;; test-calendar-sync--get-property.el ends here diff --git a/tests/test-calendar-sync--helpers.el b/tests/test-calendar-sync--helpers.el new file mode 100644 index 00000000..eb868952 --- /dev/null +++ b/tests/test-calendar-sync--helpers.el @@ -0,0 +1,157 @@ +;;; test-calendar-sync--helpers.el --- Tests for calendar-sync helper functions -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for refactored helper functions. +;; Tests the helper functions that simplify RRULE expansion logic. + +;;; Code: + +(require 'ert) +(require 'calendar-sync) + +;;; Setup and Teardown + +(defun test-calendar-sync--helpers-setup () + "Setup for helper function tests." + nil) + +(defun test-calendar-sync--helpers-teardown () + "Teardown for helper function tests." + nil) + +;;; calendar-sync--date-to-time Tests + +(ert-deftest test-calendar-sync--date-to-time-converts-date-to-time () + "Test converting date to time value." + (test-calendar-sync--helpers-setup) + (unwind-protect + (let* ((date '(2025 11 18)) ; Nov 18, 2025 + (time-val (calendar-sync--date-to-time date))) + ;; Should return a valid time value + (should (numberp (time-convert time-val 'integer)))) + (test-calendar-sync--helpers-teardown))) + +(ert-deftest test-calendar-sync--date-to-time-handles-different-dates () + "Test date-to-time with various dates." + (test-calendar-sync--helpers-setup) + (unwind-protect + (let ((date1 '(2025 1 1)) + (date2 '(2025 12 31))) + ;; Different dates should produce different time values + (should (not (equal (calendar-sync--date-to-time date1) + (calendar-sync--date-to-time date2))))) + (test-calendar-sync--helpers-teardown))) + +;;; calendar-sync--before-date-p Tests + +(ert-deftest test-calendar-sync--before-date-p-returns-true-for-earlier-date () + "Test that earlier dates return true." + (test-calendar-sync--helpers-setup) + (unwind-protect + (let ((earlier '(2025 11 17)) + (later '(2025 11 18))) + (should (calendar-sync--before-date-p earlier later))) + (test-calendar-sync--helpers-teardown))) + +(ert-deftest test-calendar-sync--before-date-p-returns-false-for-later-date () + "Test that later dates return false." + (test-calendar-sync--helpers-setup) + (unwind-protect + (let ((earlier '(2025 11 17)) + (later '(2025 11 18))) + (should-not (calendar-sync--before-date-p later earlier))) + (test-calendar-sync--helpers-teardown))) + +(ert-deftest test-calendar-sync--before-date-p-returns-false-for-same-date () + "Test that same dates return false." + (test-calendar-sync--helpers-setup) + (unwind-protect + (let ((date '(2025 11 18))) + (should-not (calendar-sync--before-date-p date date))) + (test-calendar-sync--helpers-teardown))) + +(ert-deftest test-calendar-sync--before-date-p-handles-month-boundaries () + "Test date comparison across month boundaries." + (test-calendar-sync--helpers-setup) + (unwind-protect + (let ((november '(2025 11 30)) + (december '(2025 12 1))) + (should (calendar-sync--before-date-p november december)) + (should-not (calendar-sync--before-date-p december november))) + (test-calendar-sync--helpers-teardown))) + +(ert-deftest test-calendar-sync--before-date-p-handles-year-boundaries () + "Test date comparison across year boundaries." + (test-calendar-sync--helpers-setup) + (unwind-protect + (let ((dec-2025 '(2025 12 31)) + (jan-2026 '(2026 1 1))) + (should (calendar-sync--before-date-p dec-2025 jan-2026)) + (should-not (calendar-sync--before-date-p jan-2026 dec-2025))) + (test-calendar-sync--helpers-teardown))) + +;;; calendar-sync--create-occurrence Tests + +(ert-deftest test-calendar-sync--create-occurrence-creates-new-event () + "Test creating occurrence from base event." + (test-calendar-sync--helpers-setup) + (unwind-protect + (let* ((base-event '(:summary "Test Event" + :start (2025 11 1 10 0 0) + :end (2025 11 1 11 0 0))) + (new-date '(2025 11 15 10 0 0)) + (occurrence (calendar-sync--create-occurrence base-event new-date))) + ;; Should have same summary + (should (equal (plist-get occurrence :summary) "Test Event")) + ;; Should have new start date + (should (equal (plist-get occurrence :start) new-date)) + ;; Should have end date with same day as start + (let ((end (plist-get occurrence :end))) + (should (= (nth 0 end) 2025)) + (should (= (nth 1 end) 11)) + (should (= (nth 2 end) 15)))) + (test-calendar-sync--helpers-teardown))) + +(ert-deftest test-calendar-sync--create-occurrence-preserves-time () + "Test that occurrence preserves time from base event." + (test-calendar-sync--helpers-setup) + (unwind-protect + (let* ((base-event '(:summary "Morning Meeting" + :start (2025 11 1 9 30 0) + :end (2025 11 1 10 30 0))) + (new-date '(2025 11 15 9 30 0)) + (occurrence (calendar-sync--create-occurrence base-event new-date))) + ;; End time should preserve hours/minutes from base event + (let ((end (plist-get occurrence :end))) + (should (= (nth 3 end) 10)) ; hour + (should (= (nth 4 end) 30)))) ; minute + (test-calendar-sync--helpers-teardown))) + +(ert-deftest test-calendar-sync--create-occurrence-handles-no-end-time () + "Test creating occurrence when base event has no end time." + (test-calendar-sync--helpers-setup) + (unwind-protect + (let* ((base-event '(:summary "All Day Event" + :start (2025 11 1 0 0 0))) + (new-date '(2025 11 15 0 0 0)) + (occurrence (calendar-sync--create-occurrence base-event new-date))) + ;; Should have start but no end + (should (equal (plist-get occurrence :start) new-date)) + (should (null (plist-get occurrence :end)))) + (test-calendar-sync--helpers-teardown))) + +(ert-deftest test-calendar-sync--create-occurrence-does-not-modify-original () + "Test that creating occurrence doesn't modify base event." + (test-calendar-sync--helpers-setup) + (unwind-protect + (let* ((original-start '(2025 11 1 10 0 0)) + (base-event (list :summary "Test" + :start original-start)) + (new-date '(2025 11 15 10 0 0))) + (calendar-sync--create-occurrence base-event new-date) + ;; Original should be unchanged + (should (equal (plist-get base-event :start) original-start))) + (test-calendar-sync--helpers-teardown))) + +(provide 'test-calendar-sync--helpers) +;;; test-calendar-sync--helpers.el ends here diff --git a/tests/test-calendar-sync--normalize-line-endings.el b/tests/test-calendar-sync--normalize-line-endings.el new file mode 100644 index 00000000..7f0830cc --- /dev/null +++ b/tests/test-calendar-sync--normalize-line-endings.el @@ -0,0 +1,186 @@ +;;; test-calendar-sync--normalize-line-endings.el --- Tests for calendar-sync--normalize-line-endings -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for calendar-sync--normalize-line-endings function. +;; Tests conversion of various line ending formats to Unix LF-only format. +;; Covers Normal, Boundary, and Error cases. +;; +;; The iCalendar format (RFC 5545) uses CRLF line endings (\r\n), +;; but Emacs and org-mode expect LF only (\n). This function ensures +;; consistent line endings throughout the parsing pipeline. + +;;; Code: + +(require 'ert) +(require 'calendar-sync) + +;;; Normal Cases + +(ert-deftest test-calendar-sync--normalize-line-endings-normal-crlf-to-lf () + "Test that CRLF line endings are converted to LF only. + +Input: String with \\r\\n (Windows/DOS line endings) +Expected: String with \\n only (Unix line endings)" + (let* ((input "line1\r\nline2\r\nline3\r\n") + (expected "line1\nline2\nline3\n") + (result (calendar-sync--normalize-line-endings input))) + (should (string= expected result)) + (should-not (string-match-p "\r" result)))) + +(ert-deftest test-calendar-sync--normalize-line-endings-normal-lf-unchanged () + "Test that LF-only content is returned unchanged. + +Input: String with \\n only (already Unix format) +Expected: Same string (no modification needed)" + (let* ((input "line1\nline2\nline3\n") + (result (calendar-sync--normalize-line-endings input))) + (should (string= input result)) + (should-not (string-match-p "\r" result)))) + +(ert-deftest test-calendar-sync--normalize-line-endings-normal-mixed-endings () + "Test that mixed line endings are normalized to LF only. + +Input: String with both \\r\\n (CRLF) and \\n (LF) +Expected: String with \\n only everywhere" + (let* ((input "line1\r\nline2\nline3\r\nline4\n") + (expected "line1\nline2\nline3\nline4\n") + (result (calendar-sync--normalize-line-endings input))) + (should (string= expected result)) + (should-not (string-match-p "\r" result)))) + +(ert-deftest test-calendar-sync--normalize-line-endings-normal-ics-vevent-block () + "Test normalization of realistic iCalendar VEVENT block with CRLF. + +Input: VEVENT block with CRLF line endings (as per RFC 5545) +Expected: Same structure with LF only" + (let* ((input "BEGIN:VEVENT\r\nSUMMARY:Test Event\r\nDTSTART:20251116T140000Z\r\nEND:VEVENT\r\n") + (expected "BEGIN:VEVENT\nSUMMARY:Test Event\nDTSTART:20251116T140000Z\nEND:VEVENT\n") + (result (calendar-sync--normalize-line-endings input))) + (should (string= expected result)) + (should-not (string-match-p "\r" result)))) + +;;; Boundary Cases + +(ert-deftest test-calendar-sync--normalize-line-endings-boundary-empty-string () + "Test that empty string is handled correctly. + +Input: Empty string +Expected: Empty string (no crash)" + (let ((result (calendar-sync--normalize-line-endings ""))) + (should (string= "" result)))) + +(ert-deftest test-calendar-sync--normalize-line-endings-boundary-no-line-endings () + "Test that string with no line endings is unchanged. + +Input: Plain text with no \\r or \\n +Expected: Same string unchanged" + (let* ((input "no line endings here") + (result (calendar-sync--normalize-line-endings input))) + (should (string= input result)))) + +(ert-deftest test-calendar-sync--normalize-line-endings-boundary-only-cr () + "Test that bare CR characters (old Mac format) are removed. + +Input: String with \\r only (classic Mac OS line endings) +Expected: String with \\r removed (results in run-together text)" + (let* ((input "line1\rline2\rline3\r") + (expected "line1line2line3") + (result (calendar-sync--normalize-line-endings input))) + (should (string= expected result)) + (should-not (string-match-p "\r" result)))) + +(ert-deftest test-calendar-sync--normalize-line-endings-boundary-cr-in-middle () + "Test that CR characters in middle of content are removed. + +Input: String with \\r not followed by \\n (unusual but possible) +Expected: All \\r removed regardless of position" + (let* ((input "line1\r\ntext\rwith\rmiddle\r\nline2") + (expected "line1\ntextwithmiddle\nline2") + (result (calendar-sync--normalize-line-endings input))) + (should (string= expected result)) + (should-not (string-match-p "\r" result)))) + +(ert-deftest test-calendar-sync--normalize-line-endings-boundary-multiple-cr () + "Test that multiple consecutive CR characters are all removed. + +Input: String with \\r\\r or \\r\\r\\n sequences +Expected: All \\r characters removed" + (let* ((input "line1\r\r\nline2\r\r\r\nline3") + (expected "line1\nline2\nline3") + (result (calendar-sync--normalize-line-endings input))) + (should (string= expected result)) + (should-not (string-match-p "\r" result)))) + +(ert-deftest test-calendar-sync--normalize-line-endings-boundary-single-line () + "Test normalization of single line with trailing CRLF. + +Input: Single line of text ending with \\r\\n +Expected: Single line ending with \\n" + (let* ((input "single line\r\n") + (expected "single line\n") + (result (calendar-sync--normalize-line-endings input))) + (should (string= expected result)) + (should-not (string-match-p "\r" result)))) + +(ert-deftest test-calendar-sync--normalize-line-endings-boundary-only-line-endings () + "Test string containing only line ending characters. + +Input: String of only \\r\\n sequences +Expected: String of only \\n (CR stripped)" + (let* ((input "\r\n\r\n\r\n") + (expected "\n\n\n") + (result (calendar-sync--normalize-line-endings input))) + (should (string= expected result)) + (should-not (string-match-p "\r" result)))) + +(ert-deftest test-calendar-sync--normalize-line-endings-boundary-unicode-content () + "Test normalization preserves Unicode characters. + +Input: String with Unicode and CRLF line endings +Expected: Unicode preserved, only CR removed" + (let* ((input "emoji 🎉\r\nchinese 中文\r\narabic العربية\r\n") + (expected "emoji 🎉\nchinese 中文\narabic العربية\n") + (result (calendar-sync--normalize-line-endings input))) + (should (string= expected result)) + (should-not (string-match-p "\r" result)))) + +(ert-deftest test-calendar-sync--normalize-line-endings-boundary-very-long-string () + "Test normalization of large string with many line endings. + +Input: String with 1000 lines with CRLF +Expected: Same content with LF only, performance acceptable" + (let* ((line "This is line content with some text\r\n") + (input (apply #'concat (make-list 1000 line))) + (result (calendar-sync--normalize-line-endings input))) + (should (= (length input) (+ (length result) 1000))) ; 1000 \r removed + (should-not (string-match-p "\r" result)) + (should (string-match-p "^This is line content" result)))) + +;;; Error Cases + +(ert-deftest test-calendar-sync--normalize-line-endings-error-nil-input () + "Test that nil input is handled gracefully. + +Input: nil +Expected: nil (defensive programming, no crash)" + (let ((result (calendar-sync--normalize-line-endings nil))) + (should (null result)))) + +(ert-deftest test-calendar-sync--normalize-line-endings-error-non-string-input () + "Test that non-string input is returned unchanged. + +Input: Integer (wrong type) +Expected: Same value returned (defensive, don't crash)" + (let ((result (calendar-sync--normalize-line-endings 42))) + (should (= 42 result)))) + +(ert-deftest test-calendar-sync--normalize-line-endings-error-symbol-input () + "Test that symbol input is handled gracefully. + +Input: Symbol (wrong type) +Expected: Symbol returned unchanged" + (let ((result (calendar-sync--normalize-line-endings 'some-symbol))) + (should (eq 'some-symbol result)))) + +(provide 'test-calendar-sync--normalize-line-endings) +;;; test-calendar-sync--normalize-line-endings.el ends here diff --git a/tests/test-calendar-sync--parse-rrule.el b/tests/test-calendar-sync--parse-rrule.el new file mode 100644 index 00000000..123caa5c --- /dev/null +++ b/tests/test-calendar-sync--parse-rrule.el @@ -0,0 +1,209 @@ +;;; test-calendar-sync--parse-rrule.el --- Tests for calendar-sync--parse-rrule -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for calendar-sync--parse-rrule function. +;; Tests parsing of iCalendar RRULE strings into plist format. + +;;; Code: + +(require 'ert) +(require 'calendar-sync) + +;;; Setup and Teardown + +(defun test-calendar-sync--parse-rrule-setup () + "Setup for calendar-sync--parse-rrule tests." + ;; No setup required for pure parsing tests + nil) + +(defun test-calendar-sync--parse-rrule-teardown () + "Teardown for calendar-sync--parse-rrule tests." + ;; No teardown required + nil) + +;;; Normal Cases + +(ert-deftest test-calendar-sync--parse-rrule-normal-weekly-returns-plist () + "Test parsing simple weekly recurrence rule." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let ((result (calendar-sync--parse-rrule "FREQ=WEEKLY"))) + (should (eq (plist-get result :freq) 'weekly)) + (should (= (plist-get result :interval) 1))) + (test-calendar-sync--parse-rrule-teardown))) + +(ert-deftest test-calendar-sync--parse-rrule-normal-weekly-with-byday-returns-plist () + "Test parsing weekly recurrence with specific weekdays." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let ((result (calendar-sync--parse-rrule "FREQ=WEEKLY;BYDAY=SA"))) + (should (eq (plist-get result :freq) 'weekly)) + (should (equal (plist-get result :byday) '("SA"))) + (should (= (plist-get result :interval) 1))) + (test-calendar-sync--parse-rrule-teardown))) + +(ert-deftest test-calendar-sync--parse-rrule-normal-daily-returns-plist () + "Test parsing daily recurrence rule." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let ((result (calendar-sync--parse-rrule "FREQ=DAILY"))) + (should (eq (plist-get result :freq) 'daily)) + (should (= (plist-get result :interval) 1))) + (test-calendar-sync--parse-rrule-teardown))) + +(ert-deftest test-calendar-sync--parse-rrule-normal-monthly-returns-plist () + "Test parsing monthly recurrence rule." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let ((result (calendar-sync--parse-rrule "FREQ=MONTHLY"))) + (should (eq (plist-get result :freq) 'monthly)) + (should (= (plist-get result :interval) 1))) + (test-calendar-sync--parse-rrule-teardown))) + +(ert-deftest test-calendar-sync--parse-rrule-normal-yearly-returns-plist () + "Test parsing yearly recurrence rule." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let ((result (calendar-sync--parse-rrule "FREQ=YEARLY"))) + (should (eq (plist-get result :freq) 'yearly)) + (should (= (plist-get result :interval) 1))) + (test-calendar-sync--parse-rrule-teardown))) + +(ert-deftest test-calendar-sync--parse-rrule-normal-with-interval-returns-plist () + "Test parsing recurrence rule with custom interval." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let ((result (calendar-sync--parse-rrule "FREQ=WEEKLY;INTERVAL=2"))) + (should (eq (plist-get result :freq) 'weekly)) + (should (= (plist-get result :interval) 2))) + (test-calendar-sync--parse-rrule-teardown))) + +(ert-deftest test-calendar-sync--parse-rrule-normal-with-count-returns-plist () + "Test parsing recurrence rule with count limit." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let ((result (calendar-sync--parse-rrule "FREQ=DAILY;COUNT=10"))) + (should (eq (plist-get result :freq) 'daily)) + (should (= (plist-get result :count) 10))) + (test-calendar-sync--parse-rrule-teardown))) + +(ert-deftest test-calendar-sync--parse-rrule-normal-with-until-returns-plist () + "Test parsing recurrence rule with end date." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let* ((result (calendar-sync--parse-rrule "FREQ=WEEKLY;UNTIL=20261118T120000Z")) + (until (plist-get result :until))) + (should (eq (plist-get result :freq) 'weekly)) + (should (listp until)) + (should (= (nth 0 until) 2026)) ; year + (should (= (nth 1 until) 11)) ; month + ;; Day might be 17 or 18 depending on timezone conversion + (should (member (nth 2 until) '(17 18)))) + (test-calendar-sync--parse-rrule-teardown))) + +;;; Boundary Cases + +(ert-deftest test-calendar-sync--parse-rrule-boundary-multiple-byday-returns-list () + "Test parsing BYDAY with multiple weekdays." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let ((result (calendar-sync--parse-rrule "FREQ=WEEKLY;BYDAY=MO,WE,FR"))) + (should (eq (plist-get result :freq) 'weekly)) + (should (equal (plist-get result :byday) '("MO" "WE" "FR")))) + (test-calendar-sync--parse-rrule-teardown))) + +(ert-deftest test-calendar-sync--parse-rrule-boundary-all-parameters-returns-plist () + "Test parsing RRULE with all supported parameters." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let ((result (calendar-sync--parse-rrule "FREQ=WEEKLY;INTERVAL=2;BYDAY=SA;UNTIL=20261118T000000Z;COUNT=52"))) + (should (eq (plist-get result :freq) 'weekly)) + (should (= (plist-get result :interval) 2)) + (should (equal (plist-get result :byday) '("SA"))) + (should (plist-get result :until)) + (should (= (plist-get result :count) 52))) + (test-calendar-sync--parse-rrule-teardown))) + +(ert-deftest test-calendar-sync--parse-rrule-boundary-interval-one-returns-default () + "Test that default interval is 1 when not specified." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let ((result (calendar-sync--parse-rrule "FREQ=DAILY"))) + (should (= (plist-get result :interval) 1))) + (test-calendar-sync--parse-rrule-teardown))) + +(ert-deftest test-calendar-sync--parse-rrule-boundary-large-interval-returns-number () + "Test parsing RRULE with large interval value." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let ((result (calendar-sync--parse-rrule "FREQ=MONTHLY;INTERVAL=12"))) + (should (= (plist-get result :interval) 12))) + (test-calendar-sync--parse-rrule-teardown))) + +(ert-deftest test-calendar-sync--parse-rrule-boundary-large-count-returns-number () + "Test parsing RRULE with large count value." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let ((result (calendar-sync--parse-rrule "FREQ=DAILY;COUNT=365"))) + (should (= (plist-get result :count) 365))) + (test-calendar-sync--parse-rrule-teardown))) + +;;; Error Cases + +(ert-deftest test-calendar-sync--parse-rrule-error-empty-string-returns-plist () + "Test parsing empty RRULE string returns plist with defaults." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let ((result (calendar-sync--parse-rrule ""))) + (should (listp result)) + (should (= (plist-get result :interval) 1))) + (test-calendar-sync--parse-rrule-teardown))) + +(ert-deftest test-calendar-sync--parse-rrule-error-unsupported-freq-returns-symbol () + "Test parsing RRULE with unsupported frequency." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let ((result (calendar-sync--parse-rrule "FREQ=HOURLY"))) + (should (eq (plist-get result :freq) 'hourly))) + (test-calendar-sync--parse-rrule-teardown))) + +(ert-deftest test-calendar-sync--parse-rrule-error-invalid-until-returns-nil () + "Test parsing RRULE with malformed UNTIL date." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let ((result (calendar-sync--parse-rrule "FREQ=DAILY;UNTIL=invalid"))) + (should (eq (plist-get result :freq) 'daily)) + (should (null (plist-get result :until)))) + (test-calendar-sync--parse-rrule-teardown))) + +(ert-deftest test-calendar-sync--parse-rrule-error-invalid-count-returns-zero () + "Test parsing RRULE with non-numeric COUNT." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let ((result (calendar-sync--parse-rrule "FREQ=DAILY;COUNT=abc"))) + (should (eq (plist-get result :freq) 'daily)) + (should (= (plist-get result :count) 0))) + (test-calendar-sync--parse-rrule-teardown))) + +(ert-deftest test-calendar-sync--parse-rrule-error-invalid-interval-returns-zero () + "Test parsing RRULE with non-numeric INTERVAL." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let ((result (calendar-sync--parse-rrule "FREQ=WEEKLY;INTERVAL=xyz"))) + (should (eq (plist-get result :freq) 'weekly)) + (should (= (plist-get result :interval) 0))) + (test-calendar-sync--parse-rrule-teardown))) + +(ert-deftest test-calendar-sync--parse-rrule-error-missing-freq-returns-plist () + "Test parsing RRULE without FREQ parameter." + (test-calendar-sync--parse-rrule-setup) + (unwind-protect + (let ((result (calendar-sync--parse-rrule "INTERVAL=2;COUNT=10"))) + (should (listp result)) + (should (null (plist-get result :freq))) + (should (= (plist-get result :interval) 2)) + (should (= (plist-get result :count) 10))) + (test-calendar-sync--parse-rrule-teardown))) + +(provide 'test-calendar-sync--parse-rrule) +;;; test-calendar-sync--parse-rrule.el ends here diff --git a/tests/test-calendar-sync-properties.el b/tests/test-calendar-sync-properties.el new file mode 100644 index 00000000..6054fc5e --- /dev/null +++ b/tests/test-calendar-sync-properties.el @@ -0,0 +1,239 @@ +;;; test-calendar-sync-properties.el --- Property-based tests for calendar-sync -*- lexical-binding: t; -*- + +;;; Commentary: +;; Property-based tests for RRULE expansion functions. +;; These tests verify invariants hold across randomly generated inputs, +;; complementing the example-based tests in other test files. +;; +;; Each test runs multiple trials with random parameters to explore +;; the input space and find edge cases that example-based tests miss. +;; +;; Properties tested: +;; 1. COUNT always limits total occurrences +;; 2. UNTIL date bounds all occurrences +;; 3. BYDAY constrains weekly occurrences to specified weekdays +;; 4. INTERVAL creates correct spacing between occurrences +;; 5. All occurrences fall within the date range +;; 6. Expansion is deterministic (same inputs → same outputs) + +;;; Code: + +(require 'ert) +(require 'calendar-sync) +(require 'testutil-calendar-sync) + +(defconst test-calendar-sync-property-trials 30 + "Number of random trials to run for each property test. +Higher values give more confidence but slower tests.") + +;;; Property 1: COUNT Ceiling + +(ert-deftest test-calendar-sync-property-count-limits-daily () + "Property: COUNT parameter limits daily occurrences. +For any COUNT value N, expansion never produces more than N occurrences." + (dotimes (_ test-calendar-sync-property-trials) + (let* ((count (1+ (random 20))) + (start-date (test-calendar-sync-random-future-date)) + (base-event (list :summary "Daily Test" :start start-date)) + (rrule (list :freq 'daily :interval 1 :count count)) + (range (test-calendar-sync-wide-range)) + (occurrences (calendar-sync--expand-daily base-event rrule range))) + (should (<= (length occurrences) count))))) + +(ert-deftest test-calendar-sync-property-count-limits-weekly () + "Property: COUNT parameter limits weekly occurrences. +For any COUNT value N, expansion never produces more than N occurrences." + (dotimes (_ test-calendar-sync-property-trials) + (let* ((count (1+ (random 20))) + (weekdays (test-calendar-sync-random-weekday-subset)) + (start-date (test-calendar-sync-random-future-date)) + (base-event (list :summary "Weekly Test" :start start-date)) + (rrule (list :freq 'weekly :byday weekdays :interval 1 :count count)) + (range (test-calendar-sync-wide-range)) + (occurrences (calendar-sync--expand-weekly base-event rrule range))) + (should (<= (length occurrences) count))))) + +(ert-deftest test-calendar-sync-property-count-limits-monthly () + "Property: COUNT parameter limits monthly occurrences." + (dotimes (_ test-calendar-sync-property-trials) + (let* ((count (1+ (random 15))) + (start-date (test-calendar-sync-random-future-date)) + (base-event (list :summary "Monthly Test" :start start-date)) + (rrule (list :freq 'monthly :interval 1 :count count)) + (range (test-calendar-sync-wide-range)) + (occurrences (calendar-sync--expand-monthly base-event rrule range))) + (should (<= (length occurrences) count))))) + +(ert-deftest test-calendar-sync-property-count-limits-yearly () + "Property: COUNT parameter limits yearly occurrences." + (dotimes (_ test-calendar-sync-property-trials) + (let* ((count (1+ (random 5))) + (start-date (test-calendar-sync-random-future-date)) + (base-event (list :summary "Yearly Test" :start start-date)) + (rrule (list :freq 'yearly :interval 1 :count count)) + (range (test-calendar-sync-wide-range)) + (occurrences (calendar-sync--expand-yearly base-event rrule range))) + (should (<= (length occurrences) count))))) + +;;; Property 2: UNTIL Boundary + +(ert-deftest test-calendar-sync-property-until-bounds-daily () + "Property: No daily occurrence starts on or after UNTIL date." + (dotimes (_ test-calendar-sync-property-trials) + (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0)) + (until-days (+ 10 (random 60))) + ;; UNTIL must be date-only (3 elements) for calendar-sync--before-date-p + (until-date (test-calendar-sync-time-date-only until-days)) + (base-event (list :summary "Until Test" :start start-date)) + (rrule (list :freq 'daily :interval 1 :until until-date)) + (range (test-calendar-sync-wide-range)) + (occurrences (calendar-sync--expand-daily base-event rrule range))) + (dolist (occ occurrences) + (let ((occ-start (plist-get occ :start))) + (should (calendar-sync--before-date-p + (list (nth 0 occ-start) (nth 1 occ-start) (nth 2 occ-start)) + until-date))))))) + +(ert-deftest test-calendar-sync-property-until-bounds-weekly () + "Property: No weekly occurrence starts on or after UNTIL date." + (dotimes (_ test-calendar-sync-property-trials) + (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0)) + (until-days (+ 14 (random 60))) + ;; UNTIL must be date-only (3 elements) for calendar-sync--before-date-p + (until-date (test-calendar-sync-time-date-only until-days)) + (weekdays (test-calendar-sync-random-weekday-subset)) + (base-event (list :summary "Until Test" :start start-date)) + (rrule (list :freq 'weekly :byday weekdays :interval 1 :until until-date)) + (range (test-calendar-sync-wide-range)) + (occurrences (calendar-sync--expand-weekly base-event rrule range))) + (dolist (occ occurrences) + (let ((occ-start (plist-get occ :start))) + (should (calendar-sync--before-date-p + (list (nth 0 occ-start) (nth 1 occ-start) (nth 2 occ-start)) + until-date))))))) + +;;; Property 3: BYDAY Constraint + +(ert-deftest test-calendar-sync-property-byday-constrains-weekdays () + "Property: Weekly occurrences only fall on BYDAY weekdays. +Every generated occurrence must be on one of the specified weekdays." + (dotimes (_ test-calendar-sync-property-trials) + (let* ((weekdays (test-calendar-sync-random-weekday-subset)) + (weekday-nums (mapcar #'calendar-sync--weekday-to-number weekdays)) + (start-date (test-calendar-sync-random-future-date)) + (base-event (list :summary "BYDAY Test" :start start-date)) + (rrule (list :freq 'weekly :byday weekdays :interval 1)) + (range (test-calendar-sync-narrow-range)) + (occurrences (calendar-sync--expand-weekly base-event rrule range))) + (dolist (occ occurrences) + (let* ((occ-start (plist-get occ :start)) + (occ-weekday (calendar-sync--date-weekday + (list (nth 0 occ-start) (nth 1 occ-start) (nth 2 occ-start))))) + (should (member occ-weekday weekday-nums))))))) + +;;; Property 4: INTERVAL Spacing + +(ert-deftest test-calendar-sync-property-interval-spacing-daily () + "Property: Daily occurrences are spaced INTERVAL days apart. +Consecutive occurrences should be exactly INTERVAL days apart." + (dotimes (_ test-calendar-sync-property-trials) + (let* ((interval (1+ (random 5))) + (start-date (test-calendar-sync-time-days-from-now 1 10 0)) + (base-event (list :summary "Interval Test" :start start-date)) + (rrule (list :freq 'daily :interval interval :count 10)) + (range (test-calendar-sync-wide-range)) + (occurrences (calendar-sync--expand-daily base-event rrule range))) + (when (> (length occurrences) 1) + (let ((dates (mapcar (lambda (o) (plist-get o :start)) occurrences))) + (cl-loop for i from 0 below (1- (length dates)) + for d1 = (nth i dates) + for d2 = (nth (1+ i) dates) + do (let ((gap (round (test-calendar-sync-days-between d1 d2)))) + (should (= interval gap))))))))) + +(ert-deftest test-calendar-sync-property-interval-spacing-weekly-single-day () + "Property: Weekly single-day occurrences are spaced INTERVAL weeks apart." + (dotimes (_ test-calendar-sync-property-trials) + (let* ((interval (1+ (random 3))) + (weekday (nth (random 7) '("MO" "TU" "WE" "TH" "FR" "SA" "SU"))) + (start-date (test-calendar-sync-time-days-from-now 1 10 0)) + (base-event (list :summary "Weekly Interval Test" :start start-date)) + (rrule (list :freq 'weekly :byday (list weekday) :interval interval :count 8)) + (range (test-calendar-sync-wide-range)) + (occurrences (calendar-sync--expand-weekly base-event rrule range))) + (when (> (length occurrences) 1) + (let ((dates (mapcar (lambda (o) (plist-get o :start)) occurrences))) + (cl-loop for i from 0 below (1- (length dates)) + for d1 = (nth i dates) + for d2 = (nth (1+ i) dates) + do (let ((gap (round (test-calendar-sync-days-between d1 d2)))) + (should (= (* 7 interval) gap))))))))) + +;;; Property 5: Range Containment + +(ert-deftest test-calendar-sync-property-occurrences-within-range () + "Property: All occurrences fall within the date range. +No occurrence should be before range start or after range end." + (dotimes (_ test-calendar-sync-property-trials) + (let* ((range-start-days (random 30)) + (range-end-days (+ range-start-days 30 (random 60))) + (range (list (time-add (current-time) (* range-start-days 86400)) + (time-add (current-time) (* range-end-days 86400)))) + (start-date (test-calendar-sync-time-days-from-now (1+ range-start-days) 10 0)) + (base-event (list :summary "Range Test" :start start-date)) + (rrule (list :freq 'daily :interval 1)) + (occurrences (calendar-sync--expand-daily base-event rrule range))) + (dolist (occ occurrences) + (let ((occ-start (plist-get occ :start))) + (should (calendar-sync--date-in-range-p occ-start range))))))) + +(ert-deftest test-calendar-sync-property-weekly-occurrences-within-range () + "Property: All weekly occurrences fall within the date range." + (dotimes (_ test-calendar-sync-property-trials) + (let* ((range (test-calendar-sync-narrow-range)) + (start-date (test-calendar-sync-time-days-from-now 1 10 0)) + (weekdays (test-calendar-sync-random-weekday-subset)) + (base-event (list :summary "Range Test" :start start-date)) + (rrule (list :freq 'weekly :byday weekdays :interval 1)) + (occurrences (calendar-sync--expand-weekly base-event rrule range))) + (dolist (occ occurrences) + (let ((occ-start (plist-get occ :start))) + (should (calendar-sync--date-in-range-p occ-start range))))))) + +;;; Property 6: Determinism + +(ert-deftest test-calendar-sync-property-expansion-deterministic-daily () + "Property: Same inputs produce identical outputs for daily expansion." + (dotimes (_ test-calendar-sync-property-trials) + (let* ((interval (1+ (random 3))) + (count (+ 5 (random 10))) + (start-date (test-calendar-sync-time-days-from-now 1 10 0)) + (base-event (list :summary "Determinism Test" :start start-date)) + (rrule (list :freq 'daily :interval interval :count count)) + (range (test-calendar-sync-wide-range)) + (result1 (calendar-sync--expand-daily base-event rrule range)) + (result2 (calendar-sync--expand-daily base-event rrule range))) + (should (= (length result1) (length result2))) + (cl-loop for o1 in result1 + for o2 in result2 + do (should (equal (plist-get o1 :start) (plist-get o2 :start))))))) + +(ert-deftest test-calendar-sync-property-expansion-deterministic-weekly () + "Property: Same inputs produce identical outputs for weekly expansion." + (dotimes (_ test-calendar-sync-property-trials) + (let* ((interval (1+ (random 2))) + (weekdays (test-calendar-sync-random-weekday-subset)) + (count (+ 5 (random 10))) + (start-date (test-calendar-sync-time-days-from-now 1 10 0)) + (base-event (list :summary "Determinism Test" :start start-date)) + (rrule (list :freq 'weekly :byday weekdays :interval interval :count count)) + (range (test-calendar-sync-wide-range)) + (result1 (calendar-sync--expand-weekly base-event rrule range)) + (result2 (calendar-sync--expand-weekly base-event rrule range))) + (should (= (length result1) (length result2))) + (cl-loop for o1 in result1 + for o2 in result2 + do (should (equal (plist-get o1 :start) (plist-get o2 :start))))))) + +(provide 'test-calendar-sync-properties) +;;; test-calendar-sync-properties.el ends here diff --git a/tests/test-calendar-sync.el b/tests/test-calendar-sync.el new file mode 100644 index 00000000..7cda5e73 --- /dev/null +++ b/tests/test-calendar-sync.el @@ -0,0 +1,703 @@ +;;; test-calendar-sync.el --- Tests for calendar-sync -*- lexical-binding: t; -*- + +;;; Commentary: +;; Comprehensive tests for calendar-sync module. +;; Covers Normal, Boundary, and Error cases for all parsing functions. +;; Uses dynamic timestamps (no hardcoded dates). + +;;; Code: + +(require 'ert) +(require 'calendar-sync) +(require 'testutil-calendar-sync) + +;;; Test Data + +(defconst test-calendar-sync-sample-ics + "BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Google Inc//Google Calendar 70.9054//EN +BEGIN:VEVENT +SUMMARY:Test Meeting +DTSTART:20251116T140000Z +DTEND:20251116T150000Z +DESCRIPTION:Discuss project status +LOCATION:Conference Room A +END:VEVENT +END:VCALENDAR" + "Sample .ics content for testing.") + +;;; Helper Functions + +(defmacro with-test-time (time &rest body) + "Execute BODY with `current-time` mocked to TIME." + `(cl-letf (((symbol-function 'current-time) + (lambda () ,time))) + ,@body)) + +;;; Tests: calendar-sync--split-events + +(ert-deftest test-calendar-sync--split-events-normal-single-event-returns-one () + "Test that single event is extracted correctly." + (let* ((ics test-calendar-sync-sample-ics) + (events (calendar-sync--split-events ics))) + (should (= 1 (length events))) + (should (string-match-p "BEGIN:VEVENT" (car events))) + (should (string-match-p "END:VEVENT" (car events))))) + +(ert-deftest test-calendar-sync--split-events-normal-multiple-events-returns-all () + "Test that multiple events are all extracted." + (let* ((event1 (test-calendar-sync-make-vevent + "Event 1" + (test-calendar-sync-time-today-at 14 0) + (test-calendar-sync-time-today-at 15 0))) + (event2 (test-calendar-sync-make-vevent + "Event 2" + (test-calendar-sync-time-tomorrow-at 10 0) + (test-calendar-sync-time-tomorrow-at 11 0))) + (ics (test-calendar-sync-make-ics event1 event2)) + (events (calendar-sync--split-events ics))) + (should (= 2 (length events))) + (should (string-match-p "Event 1" (nth 0 events))) + (should (string-match-p "Event 2" (nth 1 events))))) + +(ert-deftest test-calendar-sync--split-events-boundary-empty-string-returns-nil () + "Test that empty string returns empty list." + (should (null (calendar-sync--split-events "")))) + +(ert-deftest test-calendar-sync--split-events-boundary-no-events-returns-nil () + "Test that .ics with no VEVENT returns empty list." + (let ((ics "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR")) + (should (null (calendar-sync--split-events ics))))) + +;;; Tests: calendar-sync--get-property + +(ert-deftest test-calendar-sync--get-property-normal-summary-returns-value () + "Test extracting SUMMARY property." + (let ((event "BEGIN:VEVENT\nSUMMARY:Test Event\nEND:VEVENT")) + (should (string= "Test Event" (calendar-sync--get-property event "SUMMARY"))))) + +(ert-deftest test-calendar-sync--get-property-normal-description-with-spaces () + "Test extracting DESCRIPTION with spaces." + (let ((event "DESCRIPTION:Multi word description")) + (should (string= "Multi word description" + (calendar-sync--get-property event "DESCRIPTION"))))) + +(ert-deftest test-calendar-sync--get-property-boundary-missing-property-returns-nil () + "Test that missing property returns nil." + (let ((event "BEGIN:VEVENT\nSUMMARY:Test\nEND:VEVENT")) + (should (null (calendar-sync--get-property event "LOCATION"))))) + +(ert-deftest test-calendar-sync--get-property-error-empty-string-returns-nil () + "Test that empty event string returns nil." + (should (null (calendar-sync--get-property "" "SUMMARY")))) + +;;; Tests: calendar-sync--parse-timestamp + +(ert-deftest test-calendar-sync--parse-timestamp-normal-datetime-returns-full-time () + "Test parsing full datetime with time component. +UTC timestamp (with Z suffix) is converted to local time." + (let* ((parsed (calendar-sync--parse-timestamp "20251116T140000Z")) + ;; Compute expected local time from UTC + (utc-time (encode-time 0 0 14 16 11 2025 0)) + (local-time (decode-time utc-time)) + (expected-hour (nth 2 local-time)) + (expected-minute (nth 1 local-time))) + (should (= 5 (length parsed))) + (should (= 2025 (nth 0 parsed))) + (should (= 11 (nth 1 parsed))) + (should (= 16 (nth 2 parsed))) + (should (= expected-hour (nth 3 parsed))) + (should (= expected-minute (nth 4 parsed))))) + +(ert-deftest test-calendar-sync--parse-timestamp-normal-datetime-without-z () + "Test parsing datetime without Z suffix." + (let* ((parsed (calendar-sync--parse-timestamp "20251116T140000"))) + (should (= 5 (length parsed))) + (should (= 14 (nth 3 parsed))))) + +(ert-deftest test-calendar-sync--parse-timestamp-boundary-date-only-returns-three-parts () + "Test parsing date-only timestamp (all-day event)." + (let* ((parsed (calendar-sync--parse-timestamp "20251116"))) + (should (= 3 (length parsed))) + (should (= 2025 (nth 0 parsed))) + (should (= 11 (nth 1 parsed))) + (should (= 16 (nth 2 parsed))))) + +(ert-deftest test-calendar-sync--parse-timestamp-error-invalid-format-returns-nil () + "Test that invalid timestamp returns nil." + (should (null (calendar-sync--parse-timestamp "invalid"))) + (should (null (calendar-sync--parse-timestamp "2025-11-16"))) + (should (null (calendar-sync--parse-timestamp "")))) + +(ert-deftest test-calendar-sync--parse-timestamp-boundary-leap-year-feb-29 () + "Test parsing Feb 29 on leap year." + (let* ((parsed (calendar-sync--parse-timestamp "20240229T120000Z"))) + (should parsed) + (should (= 2024 (nth 0 parsed))) + (should (= 2 (nth 1 parsed))) + (should (= 29 (nth 2 parsed))))) + +;;; Tests: Timezone Conversion + +(ert-deftest test-calendar-sync--parse-timestamp-utc-conversion-actually-converts () + "Test that UTC timestamp (with Z) is actually converted to local time. +This test verifies the conversion happened by checking that the result +differs from the original UTC time (unless we happen to be in UTC timezone)." + (let* ((utc-timestamp "20251116T140000Z") ; 14:00 UTC + (parsed (calendar-sync--parse-timestamp utc-timestamp)) + ;; Get what the UTC time would be without conversion + (utc-hour 14) + (parsed-hour (nth 3 parsed))) + ;; The parsed hour should match what decode-time gives us for this UTC time + (let* ((utc-time (encode-time 0 0 14 16 11 2025 0)) + (local-time (decode-time utc-time)) + (expected-local-hour (nth 2 local-time))) + (should (= expected-local-hour parsed-hour))))) + +(ert-deftest test-calendar-sync--parse-timestamp-local-time-not-converted () + "Test that timestamp without Z suffix is NOT converted. +Local times should pass through unchanged." + (let* ((local-timestamp "20251116T140000") ; 14:00 local (no Z) + (parsed (calendar-sync--parse-timestamp local-timestamp))) + ;; Should return exactly 14:00, not converted + (should (= 14 (nth 3 parsed))) + (should (= 0 (nth 4 parsed))))) + +(ert-deftest test-calendar-sync--parse-timestamp-utc-midnight-converts-correctly () + "Test UTC midnight conversion handles day boundaries correctly." + (let* ((parsed (calendar-sync--parse-timestamp "20251116T000000Z")) + ;; Compute expected local time + (utc-time (encode-time 0 0 0 16 11 2025 0)) + (local-time (decode-time utc-time)) + (expected-year (nth 5 local-time)) + (expected-month (nth 4 local-time)) + (expected-day (nth 3 local-time)) + (expected-hour (nth 2 local-time))) + (should (= expected-year (nth 0 parsed))) + (should (= expected-month (nth 1 parsed))) + (should (= expected-day (nth 2 parsed))) + (should (= expected-hour (nth 3 parsed))))) + +;;; Tests: UTC to Local Conversion + +(ert-deftest test-calendar-sync--convert-utc-to-local-basic-conversion () + "Test basic UTC to local time conversion." + (let* ((result (calendar-sync--convert-utc-to-local 2025 11 16 14 30 0)) + ;; Compute expected local time + (utc-time (encode-time 0 30 14 16 11 2025 0)) + (local-time (decode-time utc-time)) + (expected-year (nth 5 local-time)) + (expected-month (nth 4 local-time)) + (expected-day (nth 3 local-time)) + (expected-hour (nth 2 local-time)) + (expected-minute (nth 1 local-time))) + ;; Verify conversion happened correctly + (should (= expected-year (nth 0 result))) + (should (= expected-month (nth 1 result))) + (should (= expected-day (nth 2 result))) + (should (= expected-hour (nth 3 result))) + (should (= expected-minute (nth 4 result))))) + +(ert-deftest test-calendar-sync--convert-utc-to-local-midnight-boundary () + "Test UTC midnight conversion handles day boundary correctly." + (let* ((result (calendar-sync--convert-utc-to-local 2025 11 16 0 0 0)) + ;; Compute expected local time + (utc-time (encode-time 0 0 0 16 11 2025 0)) + (local-time (decode-time utc-time)) + (expected-year (nth 5 local-time)) + (expected-month (nth 4 local-time)) + (expected-day (nth 3 local-time)) + (expected-hour (nth 2 local-time))) + ;; In timezones west of UTC, midnight UTC becomes previous day + (should (= expected-year (nth 0 result))) + (should (= expected-month (nth 1 result))) + (should (= expected-day (nth 2 result))) + (should (= expected-hour (nth 3 result))))) + +(ert-deftest test-calendar-sync--convert-utc-to-local-preserves-minutes () + "Test that minute component is preserved during conversion." + (let* ((result (calendar-sync--convert-utc-to-local 2025 11 16 20 45 0)) + (expected-minute 45)) + ;; Minutes should always be preserved (timezone offsets are in hours) + (should (= expected-minute (nth 4 result))))) + +(ert-deftest test-calendar-sync--convert-utc-to-local-returns-five-elements () + "Test that conversion returns exactly 5 elements (year month day hour minute)." + (let ((result (calendar-sync--convert-utc-to-local 2025 11 16 14 30 0))) + (should (= 5 (length result))) + ;; All elements should be numbers + (should (numberp (nth 0 result))) ; year + (should (numberp (nth 1 result))) ; month + (should (numberp (nth 2 result))) ; day + (should (numberp (nth 3 result))) ; hour + (should (numberp (nth 4 result))))) ; minute + +(ert-deftest test-calendar-sync--convert-utc-to-local-end-of-day () + "Test conversion near end of day (23:59 UTC)." + (let* ((result (calendar-sync--convert-utc-to-local 2025 11 16 23 59 0)) + ;; Compute expected local time + (utc-time (encode-time 0 59 23 16 11 2025 0)) + (local-time (decode-time utc-time)) + (expected-year (nth 5 local-time)) + (expected-month (nth 4 local-time)) + (expected-day (nth 3 local-time)) + (expected-hour (nth 2 local-time)) + (expected-minute (nth 1 local-time))) + ;; In timezones east of UTC, this might roll to next day + (should (= expected-year (nth 0 result))) + (should (= expected-month (nth 1 result))) + (should (= expected-day (nth 2 result))) + (should (= expected-hour (nth 3 result))) + (should (= expected-minute (nth 4 result))))) + +;;; Tests: Chronological Sorting + +(ert-deftest test-calendar-sync--event-start-time-extracts-comparable-time () + "Test that event start time can be extracted for comparison." + (let* ((event (list :start (list 2025 11 16 14 30))) + (time-value (calendar-sync--event-start-time event)) + (event-earlier (list :start (list 2025 11 16 10 0))) + (time-earlier (calendar-sync--event-start-time event-earlier))) + ;; Should return a valid time value (cons cell for Emacs time) + (should (consp time-value)) + ;; Should be comparable - later time should not be less than earlier + (should (time-less-p time-earlier time-value)))) + +(ert-deftest test-calendar-sync--event-start-time-handles-all-day-events () + "Test that all-day events (no time component) work for comparison." + (let* ((event (list :start (list 2025 11 16))) ; No hour/minute + (time-value (calendar-sync--event-start-time event)) + (event-next-day (list :start (list 2025 11 17))) + (time-next-day (calendar-sync--event-start-time event-next-day))) + ;; Should return a valid time value (cons cell) + (should (consp time-value)) + ;; Next day should be later than current day + (should (time-less-p time-value time-next-day)))) + +(ert-deftest test-calendar-sync--parse-ics-sorts-chronologically () + "Test that parsed events are returned in chronological order. +Earlier events should appear first in the output." + (let* ((event-future (test-calendar-sync-make-vevent + "Future Event" + (test-calendar-sync-time-days-from-now 7 10 0) + (test-calendar-sync-time-days-from-now 7 11 0))) + (event-past (test-calendar-sync-make-vevent + "Past Event" + (test-calendar-sync-time-days-ago 1 14 0) + (test-calendar-sync-time-days-ago 1 15 0))) + (event-today (test-calendar-sync-make-vevent + "Today Event" + (test-calendar-sync-time-today-at 9 0) + (test-calendar-sync-time-today-at 10 0))) + ;; Create .ics with events in wrong order (future, past, today) + (ics (test-calendar-sync-make-ics event-future event-past event-today)) + (org-content (calendar-sync--parse-ics ics)) + ;; Find positions of each event in output + (past-pos (string-match "Past Event" org-content)) + (today-pos (string-match "Today Event" org-content)) + (future-pos (string-match "Future Event" org-content))) + ;; All events should be found + (should past-pos) + (should today-pos) + (should future-pos) + ;; Order should be: past < today < future + (should (< past-pos today-pos)) + (should (< today-pos future-pos)))) + +(ert-deftest test-calendar-sync--parse-ics-sorts-same-day-events-by-time () + "Test that events on the same day are sorted by time." + (let* ((event-morning (test-calendar-sync-make-vevent + "Morning Event" + (test-calendar-sync-time-today-at 9 0) + (test-calendar-sync-time-today-at 10 0))) + (event-evening (test-calendar-sync-make-vevent + "Evening Event" + (test-calendar-sync-time-today-at 18 0) + (test-calendar-sync-time-today-at 19 0))) + (event-afternoon (test-calendar-sync-make-vevent + "Afternoon Event" + (test-calendar-sync-time-today-at 14 0) + (test-calendar-sync-time-today-at 15 0))) + ;; Create .ics with events in wrong order + (ics (test-calendar-sync-make-ics event-evening event-morning event-afternoon)) + (org-content (calendar-sync--parse-ics ics)) + (morning-pos (string-match "Morning Event" org-content)) + (afternoon-pos (string-match "Afternoon Event" org-content)) + (evening-pos (string-match "Evening Event" org-content))) + (should (< morning-pos afternoon-pos)) + (should (< afternoon-pos evening-pos)))) + +;;; Tests: calendar-sync--format-timestamp + +(ert-deftest test-calendar-sync--format-timestamp-normal-timed-event-includes-times () + "Test formatting timed event with start and end times." + (let* ((start (list 2025 11 16 14 0)) + (end (list 2025 11 16 15 30)) + (formatted (calendar-sync--format-timestamp start end))) + (should (string-match-p "<2025-11-16 \\w\\{3\\} 14:00-15:30>" formatted)))) + +(ert-deftest test-calendar-sync--format-timestamp-boundary-all-day-event-no-times () + "Test formatting all-day event (date only, no times)." + (let* ((start (list 2025 11 16)) + (formatted (calendar-sync--format-timestamp start nil))) + (should (string-match-p "<2025-11-16 \\w\\{3\\}>" formatted)) + (should-not (string-match-p "[0-9]:[0-9]" formatted)))) + +(ert-deftest test-calendar-sync--format-timestamp-normal-includes-day-of-week () + "Test that formatted timestamp includes day of week." + (let* ((start (list 2025 11 16 14 0)) + (end (list 2025 11 16 15 0)) + (formatted (calendar-sync--format-timestamp start end))) + (should (string-match-p "Sun" formatted)))) + +;;; Tests: calendar-sync--parse-event + +(ert-deftest test-calendar-sync--parse-event-normal-complete-event-returns-plist () + "Test parsing complete event with all fields." + (let* ((event (test-calendar-sync-make-vevent + "Meeting" + (test-calendar-sync-time-today-at 14 0) + (test-calendar-sync-time-today-at 15 0) + "Discussion" + "Room A")) + (parsed (calendar-sync--parse-event event))) + (should parsed) + (should (string= "Meeting" (plist-get parsed :summary))) + (should (string= "Discussion" (plist-get parsed :description))) + (should (string= "Room A" (plist-get parsed :location))) + (should (plist-get parsed :start)) + (should (plist-get parsed :end)))) + +(ert-deftest test-calendar-sync--parse-event-boundary-minimal-event-no-optional-fields () + "Test parsing event with only required fields (SUMMARY, DTSTART)." + (let* ((event (test-calendar-sync-make-vevent + "Simple Event" + (test-calendar-sync-time-today-at 10 0) + nil)) + (parsed (calendar-sync--parse-event event))) + (should parsed) + (should (string= "Simple Event" (plist-get parsed :summary))) + (should (null (plist-get parsed :description))) + (should (null (plist-get parsed :location))) + (should (plist-get parsed :start)))) + +(ert-deftest test-calendar-sync--parse-event-boundary-emoji-in-summary-preserved () + "Test that emoji in summary are preserved." + (let* ((event (test-calendar-sync-make-vevent + "Meeting 🎉" + (test-calendar-sync-time-today-at 14 0) + (test-calendar-sync-time-today-at 15 0))) + (parsed (calendar-sync--parse-event event))) + (should (string-match-p "🎉" (plist-get parsed :summary))))) + +(ert-deftest test-calendar-sync--parse-event-error-missing-summary-returns-nil () + "Test that event without SUMMARY returns nil." + (let ((event "BEGIN:VEVENT\nDTSTART:20251116T140000Z\nEND:VEVENT")) + (should (null (calendar-sync--parse-event event))))) + +(ert-deftest test-calendar-sync--parse-event-error-missing-dtstart-returns-nil () + "Test that event without DTSTART returns nil." + (let ((event "BEGIN:VEVENT\nSUMMARY:Test\nEND:VEVENT")) + (should (null (calendar-sync--parse-event event))))) + +(ert-deftest test-calendar-sync--parse-event-error-invalid-dtstart-returns-nil () + "Test that event with invalid DTSTART returns nil." + (let ((event "BEGIN:VEVENT\nSUMMARY:Test\nDTSTART:invalid\nEND:VEVENT")) + (should (null (calendar-sync--parse-event event))))) + +;;; Tests: calendar-sync--event-to-org + +(ert-deftest test-calendar-sync--event-to-org-normal-complete-event-formats-correctly () + "Test converting complete event to org format." + (let* ((event (list :summary "Meeting" + :description "Discuss project" + :location "Room A" + :start (list 2025 11 16 14 0) + :end (list 2025 11 16 15 30))) + (org-str (calendar-sync--event-to-org event))) + (should (string-match-p "^\\* Meeting$" org-str)) + (should (string-match-p "<2025-11-16 \\w\\{3\\} 14:00-15:30>" org-str)) + (should (string-match-p "Discuss project" org-str)) + (should (string-match-p "Location: Room A" org-str)))) + +(ert-deftest test-calendar-sync--event-to-org-boundary-minimal-event-no-description () + "Test converting minimal event without optional fields." + (let* ((event (list :summary "Simple Event" + :start (list 2025 11 16 10 0) + :end (list 2025 11 16 11 0))) + (org-str (calendar-sync--event-to-org event))) + (should (string-match-p "^\\* Simple Event$" org-str)) + (should-not (string-match-p "Location:" org-str)) + ;; Check timestamp is present + (should (string-match-p "<2025-11-16" org-str)))) + +(ert-deftest test-calendar-sync--event-to-org-boundary-all-day-event-no-times () + "Test converting all-day event." + (let* ((event (list :summary "All Day Event" + :start (list 2025 11 16))) + (org-str (calendar-sync--event-to-org event))) + (should (string-match-p "^\\* All Day Event$" org-str)) + (should (string-match-p "<2025-11-16" org-str)) + (should-not (string-match-p "[0-9][0-9]:[0-9][0-9]" org-str)))) + +;;; Tests: calendar-sync--parse-ics + +(ert-deftest test-calendar-sync--parse-ics-normal-single-event-returns-org () + "Test parsing .ics with single event returns org format." + (let* ((event (test-calendar-sync-make-vevent + "Test Event" + (test-calendar-sync-time-today-at 14 0) + (test-calendar-sync-time-today-at 15 0))) + (ics (test-calendar-sync-make-ics event)) + (org-content (calendar-sync--parse-ics ics))) + (should org-content) + (should (string-match-p "^# Calendar Events" org-content)) + (should (string-match-p "\\* Test Event" org-content)))) + +(ert-deftest test-calendar-sync--parse-ics-normal-multiple-events-all-included () + "Test parsing .ics with multiple events." + (let* ((event1 (test-calendar-sync-make-vevent + "Event 1" + (test-calendar-sync-time-today-at 9 0) + (test-calendar-sync-time-today-at 10 0))) + (event2 (test-calendar-sync-make-vevent + "Event 2" + (test-calendar-sync-time-today-at 14 0) + (test-calendar-sync-time-today-at 15 0))) + (ics (test-calendar-sync-make-ics event1 event2)) + (org-content (calendar-sync--parse-ics ics))) + (should org-content) + (should (string-match-p "\\* Event 1" org-content)) + (should (string-match-p "\\* Event 2" org-content)))) + +(ert-deftest test-calendar-sync--parse-ics-boundary-empty-calendar-returns-nil () + "Test parsing empty calendar (no events)." + (let* ((ics "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR") + (org-content (calendar-sync--parse-ics ics))) + (should (null org-content)))) + +(ert-deftest test-calendar-sync--parse-ics-error-malformed-ics-returns-nil () + "Test that malformed .ics returns nil and sets error." + (setq calendar-sync--last-error nil) + (let ((result (calendar-sync--parse-ics "malformed content"))) + ;; Function should handle error gracefully + (should (or (null result) (stringp result))))) + +(ert-deftest test-calendar-sync--parse-ics-boundary-mixed-valid-invalid-events () + "Test parsing .ics with mix of valid and invalid events. +Valid events should be parsed, invalid ones skipped." + (let* ((valid-event (test-calendar-sync-make-vevent + "Valid Event" + (test-calendar-sync-time-today-at 14 0) + (test-calendar-sync-time-today-at 15 0))) + (invalid-event "BEGIN:VEVENT\nDTSTART:20251116T140000Z\nEND:VEVENT") ;; No SUMMARY + (ics (test-calendar-sync-make-ics valid-event invalid-event)) + (org-content (calendar-sync--parse-ics ics))) + (should org-content) + (should (string-match-p "\\* Valid Event" org-content)))) + +;;; Tests: Timezone Detection + +(ert-deftest test-calendar-sync--current-timezone-offset-returns-number () + "Test that current timezone offset returns a number in seconds." + (let ((offset (calendar-sync--current-timezone-offset))) + ;; Should be a number + (should (numberp offset)) + ;; Should be reasonable (between -12 and +14 hours in seconds) + (should (>= offset (* -12 3600))) + (should (<= offset (* 14 3600))))) + +(ert-deftest test-calendar-sync--timezone-name-returns-string () + "Test that timezone name returns a string." + (let ((name (calendar-sync--timezone-name))) + ;; Should be a string + (should (stringp name)) + ;; Should not be empty + (should (> (length name) 0)))) + +(ert-deftest test-calendar-sync--format-timezone-offset-handles-negative () + "Test formatting negative timezone offsets (west of UTC)." + ;; CST: UTC-6 = -21600 seconds + (should (string= "UTC-6" (calendar-sync--format-timezone-offset -21600))) + ;; PST: UTC-8 = -28800 seconds + (should (string= "UTC-8" (calendar-sync--format-timezone-offset -28800))) + ;; EST: UTC-5 = -18000 seconds + (should (string= "UTC-5" (calendar-sync--format-timezone-offset -18000)))) + +(ert-deftest test-calendar-sync--format-timezone-offset-handles-positive () + "Test formatting positive timezone offsets (east of UTC)." + ;; CET: UTC+1 = 3600 seconds + (should (string= "UTC+1" (calendar-sync--format-timezone-offset 3600))) + ;; JST: UTC+9 = 32400 seconds + (should (string= "UTC+9" (calendar-sync--format-timezone-offset 32400))) + ;; AEST: UTC+10 = 36000 seconds + (should (string= "UTC+10" (calendar-sync--format-timezone-offset 36000)))) + +(ert-deftest test-calendar-sync--format-timezone-offset-handles-utc () + "Test formatting UTC (zero offset)." + (should (string= "UTC+0" (calendar-sync--format-timezone-offset 0)))) + +(ert-deftest test-calendar-sync--format-timezone-offset-handles-fractional () + "Test formatting timezone offsets with fractional hours." + ;; IST: UTC+5:30 = 19800 seconds + (should (string= "UTC+5:30" (calendar-sync--format-timezone-offset 19800))) + ;; ACST: UTC+9:30 = 34200 seconds + (should (string= "UTC+9:30" (calendar-sync--format-timezone-offset 34200))) + ;; NFT: UTC+11:30 = 41400 seconds + (should (string= "UTC+11:30" (calendar-sync--format-timezone-offset 41400)))) + +(ert-deftest test-calendar-sync--format-timezone-offset-handles-nil () + "Test formatting nil timezone offset." + (should (string= "unknown" (calendar-sync--format-timezone-offset nil)))) + +(ert-deftest test-calendar-sync--timezone-changed-p-detects-no-change () + "Test that timezone-changed-p returns nil when timezone hasn't changed." + (let ((calendar-sync--last-timezone-offset (calendar-sync--current-timezone-offset))) + (should-not (calendar-sync--timezone-changed-p)))) + +(ert-deftest test-calendar-sync--timezone-changed-p-detects-change () + "Test that timezone-changed-p detects timezone changes." + (let* ((current (calendar-sync--current-timezone-offset)) + ;; Simulate different timezone (shift by 3 hours) + (calendar-sync--last-timezone-offset (+ current (* 3 3600)))) + (should (calendar-sync--timezone-changed-p)))) + +(ert-deftest test-calendar-sync--timezone-changed-p-handles-nil-last () + "Test that timezone-changed-p returns nil when no previous timezone." + (let ((calendar-sync--last-timezone-offset nil)) + (should-not (calendar-sync--timezone-changed-p)))) + +;;; Tests: State Persistence + +(ert-deftest test-calendar-sync--save-and-load-state-roundtrip () + "Test that state can be saved and loaded correctly." + (let* ((test-state-file (make-temp-file "calendar-sync-test-state")) + (calendar-sync--state-file test-state-file) + (original-offset -21600) ; CST + (original-time (current-time)) + (calendar-sync--last-timezone-offset original-offset) + (calendar-sync--calendar-states (make-hash-table :test 'equal))) + ;; Set up per-calendar state + (puthash "test-calendar" + (list :status 'ok :last-sync original-time :last-error nil) + calendar-sync--calendar-states) + (unwind-protect + (progn + ;; Save state + (calendar-sync--save-state) + (should (file-exists-p test-state-file)) + + ;; Clear variables + (setq calendar-sync--last-timezone-offset nil) + (clrhash calendar-sync--calendar-states) + + ;; Load state + (calendar-sync--load-state) + + ;; Verify loaded correctly + (should (= original-offset calendar-sync--last-timezone-offset)) + (let ((loaded-state (gethash "test-calendar" calendar-sync--calendar-states))) + (should loaded-state) + (should (eq 'ok (plist-get loaded-state :status))) + (should (equal original-time (plist-get loaded-state :last-sync))))) + ;; Cleanup + (when (file-exists-p test-state-file) + (delete-file test-state-file))))) + +(ert-deftest test-calendar-sync--save-state-creates-directory () + "Test that save-state creates parent directory if needed." + (let* ((test-dir (make-temp-file "calendar-sync-test-dir" t)) + (test-state-file (expand-file-name "subdir/state.el" test-dir)) + (calendar-sync--state-file test-state-file) + (calendar-sync--last-timezone-offset -21600) + (calendar-sync--calendar-states (make-hash-table :test 'equal))) + (unwind-protect + (progn + (calendar-sync--save-state) + (should (file-exists-p test-state-file)) + (should (file-directory-p (file-name-directory test-state-file)))) + ;; Cleanup + (when (file-exists-p test-dir) + (delete-directory test-dir t))))) + +(ert-deftest test-calendar-sync--load-state-handles-missing-file () + "Test that load-state handles missing file gracefully." + (let ((calendar-sync--state-file "/nonexistent/path/state.el") + (calendar-sync--last-timezone-offset nil) + (calendar-sync--calendar-states (make-hash-table :test 'equal))) + ;; Should not error + (should-not (calendar-sync--load-state)) + ;; Variables should remain nil/empty + (should-not calendar-sync--last-timezone-offset) + (should (= 0 (hash-table-count calendar-sync--calendar-states))))) + +(ert-deftest test-calendar-sync--load-state-handles-corrupted-file () + "Test that load-state handles corrupted state file gracefully." + (let* ((test-state-file (make-temp-file "calendar-sync-test-state")) + (calendar-sync--state-file test-state-file) + (calendar-sync--last-timezone-offset nil) + (calendar-sync--calendar-states (make-hash-table :test 'equal))) + (unwind-protect + (progn + ;; Write corrupted data + (with-temp-file test-state-file + (insert "this is not valid elisp {[}")) + + ;; Should handle error gracefully (catches error, logs message, returns nil) + ;; Function logs to *Messages* but returns nil (doesn't crash) + (should-not (calendar-sync--load-state)) + + ;; Variables should remain nil/empty (not loaded from corrupted file) + (should-not calendar-sync--last-timezone-offset) + (should (= 0 (hash-table-count calendar-sync--calendar-states)))) + ;; Cleanup + (when (file-exists-p test-state-file) + (delete-file test-state-file))))) + +;;; Tests: Multi-Calendar Configuration + +(ert-deftest test-calendar-sync--calendar-names-returns-names () + "Test that calendar-names returns list of calendar names." + (let ((calendar-sync-calendars + '((:name "cal1" :url "http://example.com/1" :file "/tmp/cal1.org") + (:name "cal2" :url "http://example.com/2" :file "/tmp/cal2.org")))) + (should (equal '("cal1" "cal2") (calendar-sync--calendar-names))))) + +(ert-deftest test-calendar-sync--calendar-names-empty-when-no-calendars () + "Test that calendar-names returns empty list when no calendars configured." + (let ((calendar-sync-calendars nil)) + (should (null (calendar-sync--calendar-names))))) + +(ert-deftest test-calendar-sync--get-calendar-by-name-finds-calendar () + "Test that get-calendar-by-name finds correct calendar." + (let ((calendar-sync-calendars + '((:name "google" :url "http://google.com" :file "/tmp/gcal.org") + (:name "proton" :url "http://proton.me" :file "/tmp/pcal.org")))) + (let ((found (calendar-sync--get-calendar-by-name "proton"))) + (should found) + (should (string= "proton" (plist-get found :name))) + (should (string= "http://proton.me" (plist-get found :url)))))) + +(ert-deftest test-calendar-sync--get-calendar-by-name-returns-nil-for-unknown () + "Test that get-calendar-by-name returns nil for unknown calendar." + (let ((calendar-sync-calendars + '((:name "google" :url "http://google.com" :file "/tmp/gcal.org")))) + (should (null (calendar-sync--get-calendar-by-name "nonexistent"))))) + +(ert-deftest test-calendar-sync--get-calendar-state-returns-nil-for-new () + "Test that get-calendar-state returns nil for calendar without state." + (let ((calendar-sync--calendar-states (make-hash-table :test 'equal))) + (should (null (calendar-sync--get-calendar-state "new-calendar"))))) + +(ert-deftest test-calendar-sync--set-and-get-calendar-state () + "Test setting and getting calendar state." + (let ((calendar-sync--calendar-states (make-hash-table :test 'equal)) + (test-state '(:status ok :last-sync (0 0 0) :last-error nil))) + (calendar-sync--set-calendar-state "test-cal" test-state) + (let ((retrieved (calendar-sync--get-calendar-state "test-cal"))) + (should retrieved) + (should (eq 'ok (plist-get retrieved :status)))))) + +(provide 'test-calendar-sync) +;;; test-calendar-sync.el ends here diff --git a/tests/test-clear-blank-lines.el.disabled b/tests/test-clear-blank-lines.el.disabled deleted file mode 100644 index 2190aba0..00000000 --- a/tests/test-clear-blank-lines.el.disabled +++ /dev/null @@ -1,47 +0,0 @@ -;;; test-clear-blank-lines.el --- -*- lexical-binding: t; -*- - -;;; Commentary: -;; - -;;; Code: - -(require 'ert) -(add-to-list 'load-path (concat user-emacs-directory "modules")) -(require 'custom-functions) - -(ert-deftest test-cj/clear-blank-lines-region () - (let ((testdata "Some\n\n\n\nText") - (expected "Some\nText") - (actual)) - (with-temp-buffer - (insert testdata) - (cj/clear-blank-lines (point-min) (point-max)) - (setq actual (buffer-string)) - (message "buffer is:\n'%s'" actual) - (should (string= actual expected))))) - -(ert-deftest test-cj/clear-blank-lines-region-multiple-lines () - (let ((testdata "Some\n\n\n\nText") - (expected "Some\n\n\n\nText") - (midpoint) - (actual)) - (with-temp-buffer - (insert testdata) - (insert "\n") - (setq midpoint (point)) - (insert testdata) - (cj/clear-blank-lines (point-min) midpoint) - (setq actual (buffer-substring (- (point-max) - (length testdata)) (point-max))) - (message "buffer is:\n'%s'" (buffer-string)) - (should (string= actual expected))))) - -(ert-deftest test-cj/clear-blank-lines-negative () - (with-temp-buffer - (insert "Some\nText") - (cj/clear-blank-lines (point-min) (point-max)) - (should (equal (buffer-string) "Some\nText")))) - - -(provide 'test-clear-blank-lines) -;;; test-clear-blank-lines.el ends here. diff --git a/tests/test-custom-buffer-file-clear-to-bottom-of-buffer.el b/tests/test-custom-buffer-file-clear-to-bottom-of-buffer.el new file mode 100644 index 00000000..bd309880 --- /dev/null +++ b/tests/test-custom-buffer-file-clear-to-bottom-of-buffer.el @@ -0,0 +1,163 @@ +;;; test-custom-buffer-file-clear-to-bottom-of-buffer.el --- Tests for cj/clear-to-bottom-of-buffer -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/clear-to-bottom-of-buffer function from custom-buffer-file.el +;; +;; This function deletes all text from point to the end of the current buffer. +;; It does not save the deleted text in the kill ring. + +;;; 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.") + +;; Stub ps-print package +(provide 'ps-print) + +;; Now load the actual production module +(require 'custom-buffer-file) + +;;; Setup and Teardown + +(defun test-clear-to-bottom-setup () + "Set up test environment." + (setq kill-ring nil)) + +(defun test-clear-to-bottom-teardown () + "Clean up test environment." + (setq kill-ring nil)) + +;;; Normal Cases + +(ert-deftest test-clear-to-bottom-point-in-middle () + "Should delete from point to end when point in middle." + (test-clear-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-min)) + (forward-line 1) ; Point at start of "Line 2" + (cj/clear-to-bottom-of-buffer) + (should (equal (buffer-string) "Line 1\n"))) + (test-clear-to-bottom-teardown))) + +(ert-deftest test-clear-to-bottom-empty-buffer () + "Should do nothing in empty buffer." + (test-clear-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (cj/clear-to-bottom-of-buffer) + (should (equal (buffer-string) ""))) + (test-clear-to-bottom-teardown))) + +;;; Boundary Cases + +(ert-deftest test-clear-to-bottom-point-at-beginning () + "Should delete entire buffer when point at beginning." + (test-clear-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-min)) + (cj/clear-to-bottom-of-buffer) + (should (equal (buffer-string) ""))) + (test-clear-to-bottom-teardown))) + +(ert-deftest test-clear-to-bottom-point-at-end () + "Should delete nothing when point at end." + (test-clear-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-max)) + (cj/clear-to-bottom-of-buffer) + (should (equal (buffer-string) "Line 1\nLine 2\nLine 3"))) + (test-clear-to-bottom-teardown))) + +(ert-deftest test-clear-to-bottom-point-second-to-last-char () + "Should delete last character when point at second-to-last." + (test-clear-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello") + (goto-char (1- (point-max))) ; Before 'o' + (cj/clear-to-bottom-of-buffer) + (should (equal (buffer-string) "Hell"))) + (test-clear-to-bottom-teardown))) + +(ert-deftest test-clear-to-bottom-unicode-content () + "Should handle unicode content." + (test-clear-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello 👋\nمرحبا\nWorld") + (goto-char (point-min)) + (forward-line 1) + (cj/clear-to-bottom-of-buffer) + (should (equal (buffer-string) "Hello 👋\n"))) + (test-clear-to-bottom-teardown))) + +(ert-deftest test-clear-to-bottom-narrowed-buffer () + "Should respect narrowing." + (test-clear-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3\nLine 4") + (goto-char (point-min)) + (forward-line 1) + (let ((start (point))) + (forward-line 2) + (narrow-to-region start (point)) + (goto-char (point-min)) + (forward-line 1) ; Point at "Line 3" + (cj/clear-to-bottom-of-buffer) + (should (equal (buffer-string) "Line 2\n")))) + (test-clear-to-bottom-teardown))) + +(ert-deftest test-clear-to-bottom-multiple-windows () + "Should update all windows showing buffer." + (test-clear-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-min)) + (forward-line 1) + (cj/clear-to-bottom-of-buffer) + ;; Just verify content changed + (should (equal (buffer-string) "Line 1\n"))) + (test-clear-to-bottom-teardown))) + +(ert-deftest test-clear-to-bottom-does-not-affect-kill-ring () + "Should not add deleted text to kill ring." + (test-clear-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-min)) + (setq kill-ring nil) + (cj/clear-to-bottom-of-buffer) + (should (null kill-ring))) + (test-clear-to-bottom-teardown))) + +;;; Error Cases + +(ert-deftest test-clear-to-bottom-read-only-buffer () + "Should signal error in read-only buffer." + (test-clear-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Read-only content") + (read-only-mode 1) + (goto-char (point-min)) + (should-error (cj/clear-to-bottom-of-buffer))) + (test-clear-to-bottom-teardown))) + +(provide 'test-custom-buffer-file-clear-to-bottom-of-buffer) +;;; test-custom-buffer-file-clear-to-bottom-of-buffer.el ends here diff --git a/tests/test-custom-buffer-file-clear-to-top-of-buffer.el b/tests/test-custom-buffer-file-clear-to-top-of-buffer.el new file mode 100644 index 00000000..2bf79b27 --- /dev/null +++ b/tests/test-custom-buffer-file-clear-to-top-of-buffer.el @@ -0,0 +1,162 @@ +;;; test-custom-buffer-file-clear-to-top-of-buffer.el --- Tests for cj/clear-to-top-of-buffer -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/clear-to-top-of-buffer function from custom-buffer-file.el +;; +;; This function deletes all text from point to the beginning of the current buffer. +;; It does not save the deleted text in the kill ring. + +;;; 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.") + +;; Stub ps-print package +(provide 'ps-print) + +;; Now load the actual production module +(require 'custom-buffer-file) + +;;; Setup and Teardown + +(defun test-clear-to-top-setup () + "Set up test environment." + (setq kill-ring nil)) + +(defun test-clear-to-top-teardown () + "Clean up test environment." + (setq kill-ring nil)) + +;;; Normal Cases + +(ert-deftest test-clear-to-top-point-in-middle () + "Should delete from beginning to point when point in middle." + (test-clear-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-min)) + (forward-line 2) ; Point at start of "Line 3" + (cj/clear-to-top-of-buffer) + (should (equal (buffer-string) "Line 3"))) + (test-clear-to-top-teardown))) + +(ert-deftest test-clear-to-top-empty-buffer () + "Should do nothing in empty buffer." + (test-clear-to-top-setup) + (unwind-protect + (with-temp-buffer + (cj/clear-to-top-of-buffer) + (should (equal (buffer-string) ""))) + (test-clear-to-top-teardown))) + +;;; Boundary Cases + +(ert-deftest test-clear-to-top-point-at-beginning () + "Should delete nothing when point at beginning." + (test-clear-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-min)) + (cj/clear-to-top-of-buffer) + (should (equal (buffer-string) "Line 1\nLine 2\nLine 3"))) + (test-clear-to-top-teardown))) + +(ert-deftest test-clear-to-top-point-at-end () + "Should delete entire buffer when point at end." + (test-clear-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-max)) + (cj/clear-to-top-of-buffer) + (should (equal (buffer-string) ""))) + (test-clear-to-top-teardown))) + +(ert-deftest test-clear-to-top-point-at-second-char () + "Should delete first character when point at second." + (test-clear-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello") + (goto-char (1+ (point-min))) ; After 'H' + (cj/clear-to-top-of-buffer) + (should (equal (buffer-string) "ello"))) + (test-clear-to-top-teardown))) + +(ert-deftest test-clear-to-top-unicode-content () + "Should handle unicode content." + (test-clear-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello 👋\nمرحبا\nWorld") + (goto-char (point-min)) + (forward-line 2) + (cj/clear-to-top-of-buffer) + (should (equal (buffer-string) "World"))) + (test-clear-to-top-teardown))) + +(ert-deftest test-clear-to-top-narrowed-buffer () + "Should respect narrowing." + (test-clear-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3\nLine 4") + (goto-char (point-min)) + (forward-line 1) + (let ((start (point))) + (forward-line 2) + (narrow-to-region start (point)) + (goto-char (point-min)) + (forward-line 1) ; Point at "Line 3" + (cj/clear-to-top-of-buffer) + (should (equal (buffer-string) "Line 3\n")))) + (test-clear-to-top-teardown))) + +(ert-deftest test-clear-to-top-multiple-windows () + "Should update all windows showing buffer." + (test-clear-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-max)) + (cj/clear-to-top-of-buffer) + ;; Just verify content changed + (should (equal (buffer-string) ""))) + (test-clear-to-top-teardown))) + +(ert-deftest test-clear-to-top-does-not-affect-kill-ring () + "Should not add deleted text to kill ring." + (test-clear-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-max)) + (setq kill-ring nil) + (cj/clear-to-top-of-buffer) + (should (null kill-ring))) + (test-clear-to-top-teardown))) + +;;; Error Cases + +(ert-deftest test-clear-to-top-read-only-buffer () + "Should signal error in read-only buffer." + (test-clear-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Read-only content") + (read-only-mode 1) + (goto-char (point-max)) + (should-error (cj/clear-to-top-of-buffer))) + (test-clear-to-top-teardown))) + +(provide 'test-custom-buffer-file-clear-to-top-of-buffer) +;;; test-custom-buffer-file-clear-to-top-of-buffer.el ends here diff --git a/tests/test-custom-buffer-file-copy-link-to-buffer-file.el b/tests/test-custom-buffer-file-copy-link-to-buffer-file.el new file mode 100644 index 00000000..262968d6 --- /dev/null +++ b/tests/test-custom-buffer-file-copy-link-to-buffer-file.el @@ -0,0 +1,209 @@ +;;; test-custom-buffer-file-copy-link-to-buffer-file.el --- Tests for cj/copy-link-to-buffer-file -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/copy-link-to-buffer-file function from custom-buffer-file.el +;; +;; This function copies the full file:// path of the current buffer's file to +;; the kill ring. For non-file buffers, it does nothing (no error). + +;;; 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.") + +;; Stub ps-print package +(provide 'ps-print) + +;; Now load the actual production module +(require 'custom-buffer-file) + +;;; Setup and Teardown + +(defun test-copy-link-setup () + "Set up test environment." + (setq kill-ring nil)) + +(defun test-copy-link-teardown () + "Clean up test environment." + ;; Kill all buffers visiting files in the test directory + (dolist (buf (buffer-list)) + (when (buffer-file-name buf) + (when (string-prefix-p cj/test-base-dir (buffer-file-name buf)) + (with-current-buffer buf + (set-buffer-modified-p nil) + (kill-buffer buf))))) + (cj/delete-test-base-dir) + (setq kill-ring nil)) + +;;; Normal Cases + +(ert-deftest test-copy-link-simple-file () + "Should copy file:// link for simple file buffer." + (test-copy-link-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (with-current-buffer (find-file test-file) + (cj/copy-link-to-buffer-file) + (should (equal (car kill-ring) (concat "file://" test-file))))) + (test-copy-link-teardown))) + +(ert-deftest test-copy-link-non-file-buffer () + "Should do nothing for non-file buffer without error." + (test-copy-link-setup) + (unwind-protect + (with-temp-buffer + (setq kill-ring nil) + (cj/copy-link-to-buffer-file) + (should (null kill-ring))) + (test-copy-link-teardown))) + +;;; Boundary Cases + +(ert-deftest test-copy-link-unicode-filename () + "Should handle unicode in filename." + (test-copy-link-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "café.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (with-current-buffer (find-file test-file) + (cj/copy-link-to-buffer-file) + (should (equal (car kill-ring) (concat "file://" test-file))))) + (test-copy-link-teardown))) + +(ert-deftest test-copy-link-spaces-in-filename () + "Should handle spaces in filename." + (test-copy-link-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "my file.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (with-current-buffer (find-file test-file) + (cj/copy-link-to-buffer-file) + (should (equal (car kill-ring) (concat "file://" test-file))))) + (test-copy-link-teardown))) + +(ert-deftest test-copy-link-special-chars-filename () + "Should handle special characters in filename." + (test-copy-link-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "[test]-(1).txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (with-current-buffer (find-file test-file) + (cj/copy-link-to-buffer-file) + (should (equal (car kill-ring) (concat "file://" test-file))))) + (test-copy-link-teardown))) + +(ert-deftest test-copy-link-very-long-path () + "Should handle very long path." + (test-copy-link-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (long-name (make-string 200 ?x)) + (test-file (expand-file-name (concat long-name ".txt") test-dir))) + (with-temp-file test-file + (insert "content")) + (with-current-buffer (find-file test-file) + (cj/copy-link-to-buffer-file) + (should (equal (car kill-ring) (concat "file://" test-file))))) + (test-copy-link-teardown))) + +(ert-deftest test-copy-link-hidden-file () + "Should handle hidden file." + (test-copy-link-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name ".hidden" test-dir))) + (with-temp-file test-file + (insert "content")) + (with-current-buffer (find-file test-file) + (cj/copy-link-to-buffer-file) + (should (equal (car kill-ring) (concat "file://" test-file))))) + (test-copy-link-teardown))) + +(ert-deftest test-copy-link-no-extension () + "Should handle file with no extension." + (test-copy-link-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "README" test-dir))) + (with-temp-file test-file + (insert "content")) + (with-current-buffer (find-file test-file) + (cj/copy-link-to-buffer-file) + (should (equal (car kill-ring) (concat "file://" test-file))))) + (test-copy-link-teardown))) + +(ert-deftest test-copy-link-symlink-file () + "Should use buffer's filename for symlink." + (test-copy-link-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (target-file (expand-file-name "target.txt" test-dir)) + (link-file (expand-file-name "link.txt" test-dir))) + (with-temp-file target-file + (insert "content")) + (make-symbolic-link target-file link-file) + (with-current-buffer (find-file link-file) + (cj/copy-link-to-buffer-file) + ;; Should use the link name (what buffer-file-name returns) + (should (equal (car kill-ring) (concat "file://" (buffer-file-name)))))) + (test-copy-link-teardown))) + +(ert-deftest test-copy-link-kill-ring-has-content () + "Should add to kill ring when it already has content." + (test-copy-link-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (kill-new "existing content") + (with-current-buffer (find-file test-file) + (cj/copy-link-to-buffer-file) + (should (equal (car kill-ring) (concat "file://" test-file))) + (should (equal (cadr kill-ring) "existing content")))) + (test-copy-link-teardown))) + +(ert-deftest test-copy-link-empty-kill-ring () + "Should populate empty kill ring." + (test-copy-link-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (setq kill-ring nil) + (with-current-buffer (find-file test-file) + (cj/copy-link-to-buffer-file) + (should (equal (car kill-ring) (concat "file://" test-file))) + (should (= (length kill-ring) 1)))) + (test-copy-link-teardown))) + +(ert-deftest test-copy-link-scratch-buffer () + "Should do nothing for *scratch* buffer." + (test-copy-link-setup) + (unwind-protect + (progn + (setq kill-ring nil) + (with-current-buffer "*scratch*" + (cj/copy-link-to-buffer-file) + (should (null kill-ring)))) + (test-copy-link-teardown))) + +(provide 'test-custom-buffer-file-copy-link-to-buffer-file) +;;; test-custom-buffer-file-copy-link-to-buffer-file.el ends here diff --git a/tests/test-custom-buffer-file-copy-path-to-buffer-file-as-kill.el b/tests/test-custom-buffer-file-copy-path-to-buffer-file-as-kill.el new file mode 100644 index 00000000..08959a85 --- /dev/null +++ b/tests/test-custom-buffer-file-copy-path-to-buffer-file-as-kill.el @@ -0,0 +1,205 @@ +;;; test-custom-buffer-file-copy-path-to-buffer-file-as-kill.el --- Tests for cj/copy-path-to-buffer-file-as-kill -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/copy-path-to-buffer-file-as-kill function from custom-buffer-file.el +;; +;; This function copies the full path of the current buffer's file to the kill ring +;; and returns the path. It signals an error if the buffer is not visiting a file. + +;;; 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.") + +;; Stub ps-print package +(provide 'ps-print) + +;; Now load the actual production module +(require 'custom-buffer-file) + +;;; Setup and Teardown + +(defun test-copy-path-setup () + "Set up test environment." + (setq kill-ring nil)) + +(defun test-copy-path-teardown () + "Clean up test environment." + ;; Kill all buffers visiting files in the test directory + (dolist (buf (buffer-list)) + (when (buffer-file-name buf) + (when (string-prefix-p cj/test-base-dir (buffer-file-name buf)) + (with-current-buffer buf + (set-buffer-modified-p nil) + (kill-buffer buf))))) + (cj/delete-test-base-dir) + (setq kill-ring nil)) + +;;; Normal Cases + +(ert-deftest test-copy-path-simple-file () + "Should copy absolute path for simple file buffer." + (test-copy-path-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (with-current-buffer (find-file test-file) + (let ((result (cj/copy-path-to-buffer-file-as-kill))) + (should (equal result test-file)) + (should (equal (car kill-ring) test-file))))) + (test-copy-path-teardown))) + +(ert-deftest test-copy-path-returns-path () + "Should return the path value." + (test-copy-path-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (with-current-buffer (find-file test-file) + (let ((result (cj/copy-path-to-buffer-file-as-kill))) + (should (stringp result)) + (should (equal result test-file))))) + (test-copy-path-teardown))) + +;;; Boundary Cases + +(ert-deftest test-copy-path-unicode-filename () + "Should handle unicode in filename." + (test-copy-path-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "café.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (with-current-buffer (find-file test-file) + (cj/copy-path-to-buffer-file-as-kill) + (should (equal (car kill-ring) test-file)))) + (test-copy-path-teardown))) + +(ert-deftest test-copy-path-spaces-in-filename () + "Should handle spaces in filename." + (test-copy-path-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "my file.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (with-current-buffer (find-file test-file) + (cj/copy-path-to-buffer-file-as-kill) + (should (equal (car kill-ring) test-file)))) + (test-copy-path-teardown))) + +(ert-deftest test-copy-path-special-chars-filename () + "Should handle special characters in filename." + (test-copy-path-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "[test]-(1).txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (with-current-buffer (find-file test-file) + (cj/copy-path-to-buffer-file-as-kill) + (should (equal (car kill-ring) test-file)))) + (test-copy-path-teardown))) + +(ert-deftest test-copy-path-very-long-path () + "Should handle very long path." + (test-copy-path-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (long-name (make-string 200 ?x)) + (test-file (expand-file-name (concat long-name ".txt") test-dir))) + (with-temp-file test-file + (insert "content")) + (with-current-buffer (find-file test-file) + (cj/copy-path-to-buffer-file-as-kill) + (should (equal (car kill-ring) test-file)))) + (test-copy-path-teardown))) + +(ert-deftest test-copy-path-hidden-file () + "Should handle hidden file." + (test-copy-path-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name ".hidden" test-dir))) + (with-temp-file test-file + (insert "content")) + (with-current-buffer (find-file test-file) + (cj/copy-path-to-buffer-file-as-kill) + (should (equal (car kill-ring) test-file)))) + (test-copy-path-teardown))) + +(ert-deftest test-copy-path-no-extension () + "Should handle file with no extension." + (test-copy-path-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "README" test-dir))) + (with-temp-file test-file + (insert "content")) + (with-current-buffer (find-file test-file) + (cj/copy-path-to-buffer-file-as-kill) + (should (equal (car kill-ring) test-file)))) + (test-copy-path-teardown))) + +(ert-deftest test-copy-path-symlink-file () + "Should use buffer's filename for symlink." + (test-copy-path-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (target-file (expand-file-name "target.txt" test-dir)) + (link-file (expand-file-name "link.txt" test-dir))) + (with-temp-file target-file + (insert "content")) + (make-symbolic-link target-file link-file) + (with-current-buffer (find-file link-file) + (cj/copy-path-to-buffer-file-as-kill) + (should (equal (car kill-ring) (buffer-file-name))))) + (test-copy-path-teardown))) + +(ert-deftest test-copy-path-kill-ring-has-content () + "Should add to kill ring when it already has content." + (test-copy-path-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (kill-new "existing content") + (with-current-buffer (find-file test-file) + (cj/copy-path-to-buffer-file-as-kill) + (should (equal (car kill-ring) test-file)) + (should (equal (cadr kill-ring) "existing content")))) + (test-copy-path-teardown))) + +;;; Error Cases + +(ert-deftest test-copy-path-non-file-buffer () + "Should signal user-error for non-file buffer." + (test-copy-path-setup) + (unwind-protect + (with-temp-buffer + (should-error (cj/copy-path-to-buffer-file-as-kill) :type 'user-error)) + (test-copy-path-teardown))) + +(ert-deftest test-copy-path-scratch-buffer () + "Should signal user-error for *scratch* buffer." + (test-copy-path-setup) + (unwind-protect + (with-current-buffer "*scratch*" + (should-error (cj/copy-path-to-buffer-file-as-kill) :type 'user-error)) + (test-copy-path-teardown))) + +(provide 'test-custom-buffer-file-copy-path-to-buffer-file-as-kill) +;;; test-custom-buffer-file-copy-path-to-buffer-file-as-kill.el ends here diff --git a/tests/test-custom-buffer-file-copy-to-bottom-of-buffer.el b/tests/test-custom-buffer-file-copy-to-bottom-of-buffer.el new file mode 100644 index 00000000..0c41761e --- /dev/null +++ b/tests/test-custom-buffer-file-copy-to-bottom-of-buffer.el @@ -0,0 +1,187 @@ +;;; test-custom-buffer-file-copy-to-bottom-of-buffer.el --- Tests for cj/copy-to-bottom-of-buffer -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/copy-to-bottom-of-buffer function from custom-buffer-file.el +;; +;; This function copies all text from point to the end of the current buffer +;; to the kill ring without modifying the buffer. + +;;; 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.") + +;; Stub ps-print package +(provide 'ps-print) + +;; Now load the actual production module +(require 'custom-buffer-file) + +;;; Setup and Teardown + +(defun test-copy-to-bottom-setup () + "Set up test environment." + (setq kill-ring nil)) + +(defun test-copy-to-bottom-teardown () + "Clean up test environment." + (setq kill-ring nil)) + +;;; Normal Cases + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-normal-point-in-middle-copies-to-end () + "Should copy from point to end when point in middle." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-min)) + (forward-line 1) ; Point at start of "Line 2" + (let ((original-content (buffer-string))) + (cj/copy-to-bottom-of-buffer) + ;; Buffer should be unchanged + (should (equal (buffer-string) original-content)) + ;; Kill ring should contain from point to end + (should (equal (car kill-ring) "Line 2\nLine 3")))) + (test-copy-to-bottom-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-normal-single-line-copies-partial () + "Should copy partial line content from middle of line." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello World") + (goto-char (point-min)) + (forward-char 6) ; Point after "Hello " + (cj/copy-to-bottom-of-buffer) + (should (equal (buffer-string) "Hello World")) + (should (equal (car kill-ring) "World"))) + (test-copy-to-bottom-teardown))) + +;;; Boundary Cases + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-point-at-beginning-copies-all () + "Should copy entire buffer when point at beginning." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-min)) + (cj/copy-to-bottom-of-buffer) + (should (equal (buffer-string) "Line 1\nLine 2\nLine 3")) + (should (equal (car kill-ring) "Line 1\nLine 2\nLine 3"))) + (test-copy-to-bottom-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-point-at-end-copies-empty () + "Should copy empty string when point at end." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-max)) + (cj/copy-to-bottom-of-buffer) + (should (equal (buffer-string) "Line 1\nLine 2\nLine 3")) + (should (equal (car kill-ring) ""))) + (test-copy-to-bottom-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-empty-buffer-copies-empty () + "Should copy empty string in empty buffer." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (cj/copy-to-bottom-of-buffer) + (should (equal (buffer-string) "")) + (should (equal (car kill-ring) ""))) + (test-copy-to-bottom-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-point-second-to-last-char-copies-one () + "Should copy last character when point at second-to-last." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello") + (goto-char (1- (point-max))) ; Before 'o' + (cj/copy-to-bottom-of-buffer) + (should (equal (buffer-string) "Hello")) + (should (equal (car kill-ring) "o"))) + (test-copy-to-bottom-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-unicode-content-copies-correctly () + "Should handle unicode content correctly." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello 👋\nمرحبا\nWorld") + (goto-char (point-min)) + (forward-line 1) + (cj/copy-to-bottom-of-buffer) + (should (equal (buffer-string) "Hello 👋\nمرحبا\nWorld")) + (should (equal (car kill-ring) "مرحبا\nWorld"))) + (test-copy-to-bottom-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-narrowed-buffer-respects-narrowing () + "Should respect narrowing and only copy within narrowed region." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3\nLine 4") + (goto-char (point-min)) + (forward-line 1) + (let ((start (point))) + (forward-line 2) + (narrow-to-region start (point)) + (goto-char (point-min)) + (forward-line 1) ; Point at "Line 3" + (cj/copy-to-bottom-of-buffer) + (should (equal (buffer-string) "Line 2\nLine 3\n")) + (should (equal (car kill-ring) "Line 3\n")))) + (test-copy-to-bottom-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-whitespace-only-copies-whitespace () + "Should copy whitespace-only content." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert " \n\t\t\n ") + (goto-char (point-min)) + (forward-char 4) ; After first newline + (cj/copy-to-bottom-of-buffer) + (should (equal (buffer-string) " \n\t\t\n ")) + (should (equal (car kill-ring) "\t\t\n "))) + (test-copy-to-bottom-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-single-character-copies-char () + "Should copy single character buffer." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "x") + (goto-char (point-min)) + (cj/copy-to-bottom-of-buffer) + (should (equal (buffer-string) "x")) + (should (equal (car kill-ring) "x"))) + (test-copy-to-bottom-teardown))) + +;;; Error Cases + +(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-error-read-only-buffer-succeeds () + "Should work in read-only buffer since it doesn't modify content." + (test-copy-to-bottom-setup) + (unwind-protect + (with-temp-buffer + (insert "Read-only content") + (read-only-mode 1) + (goto-char (point-min)) + (cj/copy-to-bottom-of-buffer) + (should (equal (car kill-ring) "Read-only content"))) + (test-copy-to-bottom-teardown))) + +(provide 'test-custom-buffer-file-copy-to-bottom-of-buffer) +;;; test-custom-buffer-file-copy-to-bottom-of-buffer.el ends here diff --git a/tests/test-custom-buffer-file-copy-to-top-of-buffer.el b/tests/test-custom-buffer-file-copy-to-top-of-buffer.el new file mode 100644 index 00000000..0f09f26d --- /dev/null +++ b/tests/test-custom-buffer-file-copy-to-top-of-buffer.el @@ -0,0 +1,186 @@ +;;; test-custom-buffer-file-copy-to-top-of-buffer.el --- Tests for cj/copy-to-top-of-buffer -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/copy-to-top-of-buffer function from custom-buffer-file.el +;; +;; This function copies all text from the beginning of the buffer to point +;; to the kill ring without modifying the buffer. + +;;; 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.") + +;; Stub ps-print package +(provide 'ps-print) + +;; Now load the actual production module +(require 'custom-buffer-file) + +;;; Setup and Teardown + +(defun test-copy-to-top-setup () + "Set up test environment." + (setq kill-ring nil)) + +(defun test-copy-to-top-teardown () + "Clean up test environment." + (setq kill-ring nil)) + +;;; Normal Cases + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-normal-point-in-middle-copies-from-beginning () + "Should copy from beginning to point when point in middle." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-min)) + (forward-line 2) ; Point at start of "Line 3" + (let ((original-content (buffer-string))) + (cj/copy-to-top-of-buffer) + ;; Buffer should be unchanged + (should (equal (buffer-string) original-content)) + ;; Kill ring should contain from beginning to point + (should (equal (car kill-ring) "Line 1\nLine 2\n")))) + (test-copy-to-top-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-normal-single-line-copies-partial () + "Should copy partial line content from beginning to middle of line." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello World") + (goto-char (point-min)) + (forward-char 5) ; Point after "Hello" + (cj/copy-to-top-of-buffer) + (should (equal (buffer-string) "Hello World")) + (should (equal (car kill-ring) "Hello"))) + (test-copy-to-top-teardown))) + +;;; Boundary Cases + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-point-at-end-copies-all () + "Should copy entire buffer when point at end." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-max)) + (cj/copy-to-top-of-buffer) + (should (equal (buffer-string) "Line 1\nLine 2\nLine 3")) + (should (equal (car kill-ring) "Line 1\nLine 2\nLine 3"))) + (test-copy-to-top-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-point-at-beginning-copies-empty () + "Should copy empty string when point at beginning." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (goto-char (point-min)) + (cj/copy-to-top-of-buffer) + (should (equal (buffer-string) "Line 1\nLine 2\nLine 3")) + (should (equal (car kill-ring) ""))) + (test-copy-to-top-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-empty-buffer-copies-empty () + "Should copy empty string in empty buffer." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (cj/copy-to-top-of-buffer) + (should (equal (buffer-string) "")) + (should (equal (car kill-ring) ""))) + (test-copy-to-top-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-point-at-second-char-copies-one () + "Should copy first character when point at second character." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello") + (goto-char (1+ (point-min))) ; After 'H' + (cj/copy-to-top-of-buffer) + (should (equal (buffer-string) "Hello")) + (should (equal (car kill-ring) "H"))) + (test-copy-to-top-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-unicode-content-copies-correctly () + "Should handle unicode content correctly." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello 👋\nمرحبا\nWorld") + (goto-char (point-min)) + (forward-line 2) ; Point at start of "World" + (cj/copy-to-top-of-buffer) + (should (equal (buffer-string) "Hello 👋\nمرحبا\nWorld")) + (should (equal (car kill-ring) "Hello 👋\nمرحبا\n"))) + (test-copy-to-top-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-narrowed-buffer-respects-narrowing () + "Should respect narrowing and only copy within narrowed region." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3\nLine 4") + (goto-char (point-min)) + (forward-line 1) + (let ((start (point))) + (forward-line 2) + (narrow-to-region start (point)) + (goto-char (point-max)) ; Point at end of narrowed region + (cj/copy-to-top-of-buffer) + (should (equal (buffer-string) "Line 2\nLine 3\n")) + (should (equal (car kill-ring) "Line 2\nLine 3\n")))) + (test-copy-to-top-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-whitespace-only-copies-whitespace () + "Should copy whitespace-only content." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert " \n\t\t\n ") + (goto-char (point-min)) + (forward-char 7) ; After second newline + (cj/copy-to-top-of-buffer) + (should (equal (buffer-string) " \n\t\t\n ")) + (should (equal (car kill-ring) " \n\t\t\n"))) + (test-copy-to-top-teardown))) + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-single-character-copies-char () + "Should copy single character buffer." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "x") + (goto-char (point-max)) + (cj/copy-to-top-of-buffer) + (should (equal (buffer-string) "x")) + (should (equal (car kill-ring) "x"))) + (test-copy-to-top-teardown))) + +;;; Error Cases + +(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-error-read-only-buffer-succeeds () + "Should work in read-only buffer since it doesn't modify content." + (test-copy-to-top-setup) + (unwind-protect + (with-temp-buffer + (insert "Read-only content") + (goto-char (point-max)) + (read-only-mode 1) + (cj/copy-to-top-of-buffer) + (should (equal (car kill-ring) "Read-only content"))) + (test-copy-to-top-teardown))) + +(provide 'test-custom-buffer-file-copy-to-top-of-buffer) +;;; test-custom-buffer-file-copy-to-top-of-buffer.el ends here diff --git a/tests/test-custom-buffer-file-copy-whole-buffer.el b/tests/test-custom-buffer-file-copy-whole-buffer.el new file mode 100644 index 00000000..181c491a --- /dev/null +++ b/tests/test-custom-buffer-file-copy-whole-buffer.el @@ -0,0 +1,194 @@ +;;; test-custom-buffer-file-copy-whole-buffer.el --- Tests for cj/copy-whole-buffer -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/copy-whole-buffer function from custom-buffer-file.el +;; +;; This function copies the entire contents of the current buffer to the kill ring. +;; Point and mark are left exactly where they were. No transient region is created. + +;;; 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.") + +;; Stub ps-print package +(provide 'ps-print) + +;; Now load the actual production module +(require 'custom-buffer-file) + +;;; Setup and Teardown + +(defun test-copy-whole-buffer-setup () + "Set up test environment." + (setq kill-ring nil)) + +(defun test-copy-whole-buffer-teardown () + "Clean up test environment." + (setq kill-ring nil)) + +;;; Normal Cases + +(ert-deftest test-copy-whole-buffer-simple-text () + "Should copy simple text content to kill ring." + (test-copy-whole-buffer-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello, world!") + (cj/copy-whole-buffer) + (should (equal (car kill-ring) "Hello, world!"))) + (test-copy-whole-buffer-teardown))) + +(ert-deftest test-copy-whole-buffer-preserves-point () + "Should preserve point position." + (test-copy-whole-buffer-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello, world!") + (goto-char 7) ; Position in middle + (cj/copy-whole-buffer) + (should (= (point) 7))) + (test-copy-whole-buffer-teardown))) + +(ert-deftest test-copy-whole-buffer-preserves-mark () + "Should preserve mark position." + (test-copy-whole-buffer-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello, world!") + (push-mark 5) + (goto-char 10) + (cj/copy-whole-buffer) + (should (= (mark) 5)) + (should (= (point) 10))) + (test-copy-whole-buffer-teardown))) + +;;; Boundary Cases + +(ert-deftest test-copy-whole-buffer-empty () + "Should handle empty buffer." + (test-copy-whole-buffer-setup) + (unwind-protect + (with-temp-buffer + (cj/copy-whole-buffer) + (should (equal (car kill-ring) ""))) + (test-copy-whole-buffer-teardown))) + +(ert-deftest test-copy-whole-buffer-large () + "Should handle very large buffer." + (test-copy-whole-buffer-setup) + (unwind-protect + (with-temp-buffer + (let ((large-content (make-string 100000 ?x))) + (insert large-content) + (cj/copy-whole-buffer) + (should (equal (car kill-ring) large-content)))) + (test-copy-whole-buffer-teardown))) + +(ert-deftest test-copy-whole-buffer-unicode () + "Should handle unicode content (emoji, RTL text)." + (test-copy-whole-buffer-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello 👋 مرحبا") + (cj/copy-whole-buffer) + (should (equal (car kill-ring) "Hello 👋 مرحبا"))) + (test-copy-whole-buffer-teardown))) + +(ert-deftest test-copy-whole-buffer-binary () + "Should handle binary content." + (test-copy-whole-buffer-setup) + (unwind-protect + (with-temp-buffer + (insert (string 0 1 2 255)) + (cj/copy-whole-buffer) + (should (equal (car kill-ring) (string 0 1 2 255)))) + (test-copy-whole-buffer-teardown))) + +(ert-deftest test-copy-whole-buffer-only-whitespace () + "Should handle buffer with only whitespace." + (test-copy-whole-buffer-setup) + (unwind-protect + (with-temp-buffer + (insert " \t\n ") + (cj/copy-whole-buffer) + (should (equal (car kill-ring) " \t\n "))) + (test-copy-whole-buffer-teardown))) + +(ert-deftest test-copy-whole-buffer-newlines-at-boundaries () + "Should handle newlines at start/end." + (test-copy-whole-buffer-setup) + (unwind-protect + (with-temp-buffer + (insert "\n\nHello\n\n") + (cj/copy-whole-buffer) + (should (equal (car kill-ring) "\n\nHello\n\n"))) + (test-copy-whole-buffer-teardown))) + +(ert-deftest test-copy-whole-buffer-narrowed () + "Should copy only visible region in narrowed buffer." + (test-copy-whole-buffer-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3\n") + (goto-char (point-min)) + (forward-line 1) + (narrow-to-region (point) (progn (forward-line 1) (point))) + (cj/copy-whole-buffer) + ;; Should copy only the narrowed region + (should (equal (car kill-ring) "Line 2\n"))) + (test-copy-whole-buffer-teardown))) + +(ert-deftest test-copy-whole-buffer-read-only () + "Should work in read-only buffer." + (test-copy-whole-buffer-setup) + (unwind-protect + (with-temp-buffer + (insert "Read-only content") + (read-only-mode 1) + (cj/copy-whole-buffer) + (should (equal (car kill-ring) "Read-only content"))) + (test-copy-whole-buffer-teardown))) + +(ert-deftest test-copy-whole-buffer-kill-ring-has-content () + "Should add to kill ring when it already has content." + (test-copy-whole-buffer-setup) + (unwind-protect + (with-temp-buffer + (insert "New content") + (kill-new "existing content") + (cj/copy-whole-buffer) + (should (equal (car kill-ring) "New content")) + (should (equal (cadr kill-ring) "existing content"))) + (test-copy-whole-buffer-teardown))) + +(ert-deftest test-copy-whole-buffer-multiline () + "Should preserve multiline content." + (test-copy-whole-buffer-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (cj/copy-whole-buffer) + (should (equal (car kill-ring) "Line 1\nLine 2\nLine 3"))) + (test-copy-whole-buffer-teardown))) + +(ert-deftest test-copy-whole-buffer-no-properties () + "Should strip text properties." + (test-copy-whole-buffer-setup) + (unwind-protect + (with-temp-buffer + (insert (propertize "Hello" 'face 'bold)) + (cj/copy-whole-buffer) + (should (equal (car kill-ring) "Hello")) + (should (null (text-properties-at 0 (car kill-ring))))) + (test-copy-whole-buffer-teardown))) + +(provide 'test-custom-buffer-file-copy-whole-buffer) +;;; test-custom-buffer-file-copy-whole-buffer.el ends here diff --git a/tests/test-custom-buffer-file-delete-buffer-and-file.el b/tests/test-custom-buffer-file-delete-buffer-and-file.el new file mode 100644 index 00000000..4af8d2a7 --- /dev/null +++ b/tests/test-custom-buffer-file-delete-buffer-and-file.el @@ -0,0 +1,671 @@ +;;; test-custom-buffer-file-delete-buffer-and-file.el --- Tests for cj/delete-buffer-and-file -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/delete-buffer-and-file function from custom-buffer-file.el +;; +;; This function deletes both the current buffer and the file it visits. +;; It uses vc-delete-file for version-controlled files and delete-file +;; for non-version-controlled files. +;; +;; Testing Strategy: +;; - We test OUR code's behavior, not the underlying delete-file/vc-delete-file +;; implementations +;; - We verify our code correctly: +;; 1. Detects VC vs non-VC files (via vc-backend) +;; 2. Calls the appropriate deletion function (vc-delete-file or delete-file) +;; 3. Passes the trash flag (t) to delete-file +;; 4. Propagates errors from the deletion functions +;; +;; Why We Mock delete-file Errors: +;; - Tests like "already deleted file" and "no delete permission" are testing +;; system/environment behavior, not our code +;; - The trash system handles these cases in environment-specific ways: +;; - Missing files may not error (trash handles gracefully) +;; - File permissions may not matter (directory permissions for moving to trash) +;; - To make tests deterministic and portable, we mock delete-file to throw +;; specific errors, then verify our code propagates them correctly +;; - This tests our contract: "when delete-file fails, we let the error through" + +;;; 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.") + +;; Stub ps-print package +(provide 'ps-print) + +;; Now load the actual production module +(require 'custom-buffer-file) + +;;; Setup and Teardown + +(defun test-delete-buffer-and-file-setup () + "Setup for delete-buffer-and-file tests." + (cj/create-test-base-dir)) + +(defun test-delete-buffer-and-file-teardown () + "Teardown for delete-buffer-and-file tests." + ;; Kill all buffers visiting files in test directory + (dolist (buf (buffer-list)) + (when (buffer-file-name buf) + (when (string-prefix-p cj/test-base-dir (buffer-file-name buf)) + (with-current-buffer buf + (set-buffer-modified-p nil)) + (kill-buffer buf)))) + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-delete-buffer-and-file-simple-delete () + "Should delete file and kill buffer." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (let ((buf (current-buffer))) + ;; Mock vc-backend to return nil (non-VC file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (file-exists-p test-file)) + (should-not (buffer-live-p buf))))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-removes-file-from-disk () + "Should remove file from disk." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (file-exists-p test-file)))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-kills-buffer () + "Should kill the buffer." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (let ((buf (current-buffer))) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (buffer-live-p buf))))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-calls-delete-file-with-trash-flag () + "Should call delete-file with trash flag set to t." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir)) + (delete-file-args nil)) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil)) + ((symbol-function 'delete-file) + (lambda (file trash) + (setq delete-file-args (list file trash))))) + (cj/delete-buffer-and-file) + (should (equal delete-file-args (list test-file t))))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-shows-message () + "Should display message for non-VC deletes." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir)) + (message-output nil)) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil)) + ((symbol-function 'message) + (lambda (fmt &rest args) + (setq message-output (apply #'format fmt args))))) + (cj/delete-buffer-and-file) + (should (string-match-p "Deleted file.*test.txt" message-output)))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-vc-file-uses-vc-delete () + "Should call vc-delete-file for VC files." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir)) + (vc-delete-called nil)) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) 'Git)) + ((symbol-function 'vc-delete-file) + (lambda (file) + (setq vc-delete-called file) + ;; Simulate vc-delete-file killing the buffer + (when (get-file-buffer file) + (kill-buffer (get-file-buffer file))) + ;; Actually delete the file for test cleanup + (delete-file file t)))) + (cj/delete-buffer-and-file) + (should (string= vc-delete-called test-file)))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-non-vc-file-uses-delete-file () + "Should call delete-file for non-VC files." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir)) + (delete-file-called nil)) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil)) + ((symbol-function 'delete-file) + (lambda (file trash) + (setq delete-file-called file)))) + (cj/delete-buffer-and-file) + (should (string= delete-file-called test-file)))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-returns-implicitly () + "Should return result of last expression." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (let ((result (cj/delete-buffer-and-file))) + ;; kill-buffer returns t, so result should be t + (should (eq result t))))) + (test-delete-buffer-and-file-teardown))) + +;;; Boundary Cases - File Content + +(ert-deftest test-delete-buffer-and-file-empty-file () + "Should delete empty file." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "empty.txt" test-dir))) + (with-temp-file test-file) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (file-exists-p test-file)))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-large-file () + "Should delete large file." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "large.txt" test-dir)) + (large-content (make-string 100000 ?x))) + (with-temp-file test-file + (insert large-content)) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (file-exists-p test-file)))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-binary-file () + "Should delete binary file." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "binary.dat" test-dir)) + (binary-content (string 0 1 2 3 255 254 253))) + (with-temp-file test-file + (set-buffer-multibyte nil) + (insert binary-content)) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (file-exists-p test-file)))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-with-unicode-content () + "Should delete file with Unicode content." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "unicode.txt" test-dir)) + (content "Hello 世界 مرحبا Привет")) + (with-temp-file test-file + (insert content)) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (file-exists-p test-file)))) + (test-delete-buffer-and-file-teardown))) + +;;; Boundary Cases - File Naming + +(ert-deftest test-delete-buffer-and-file-unicode-filename () + "Should delete file with Unicode filename." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "café.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (file-exists-p test-file)))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-spaces-in-filename () + "Should delete file with spaces in name." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "my file.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (file-exists-p test-file)))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-special-chars-filename () + "Should delete file with special characters." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "[test]-(1).txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (file-exists-p test-file)))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-hidden-file () + "Should delete hidden file." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name ".hidden" test-dir))) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (file-exists-p test-file)))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-no-extension () + "Should delete file without extension." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "README" test-dir))) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (file-exists-p test-file)))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-very-long-filename () + "Should delete file with very long name." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (long-name (concat (make-string 200 ?x) ".txt")) + (test-file (expand-file-name long-name test-dir))) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (file-exists-p test-file)))) + (test-delete-buffer-and-file-teardown))) + +;;; Boundary Cases - Buffer State + +(ert-deftest test-delete-buffer-and-file-with-unsaved-changes () + "Should handle buffer with unsaved changes." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "original")) + (find-file test-file) + (insert " modified") + (should (buffer-modified-p)) + (let ((buf (current-buffer))) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (file-exists-p test-file)) + (should-not (buffer-live-p buf))))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-read-only-buffer () + "Should handle read-only buffer." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (read-only-mode 1) + (let ((buf (current-buffer))) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (file-exists-p test-file)) + (should-not (buffer-live-p buf))))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-multiple-windows () + "Should work when buffer displayed in multiple windows." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (delete-other-windows) + (split-window) + (other-window 1) + (switch-to-buffer (get-file-buffer test-file)) + (let ((buf (current-buffer))) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (file-exists-p test-file)) + (should-not (buffer-live-p buf)))) + (delete-other-windows)) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-buffer-not-current () + "Should only operate on current buffer." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (file1 (expand-file-name "file1.txt" test-dir)) + (file2 (expand-file-name "file2.txt" test-dir))) + (with-temp-file file1 + (insert "content1")) + (with-temp-file file2 + (insert "content2")) + (find-file file1) + (find-file file2) + ;; Current buffer is file2 + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + ;; file2 should be deleted, file1 should still exist + (should-not (file-exists-p file2)) + (should (file-exists-p file1))) + (kill-buffer (get-file-buffer file1))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-narrowed-buffer () + "Should work with narrowed buffer." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "Line 1\nLine 2\nLine 3")) + (find-file test-file) + (goto-char (point-min)) + (forward-line 1) + (narrow-to-region (point) (line-end-position)) + (let ((buf (current-buffer))) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (file-exists-p test-file)) + (should-not (buffer-live-p buf))))) + (test-delete-buffer-and-file-teardown))) + +;;; Error Cases - Buffer Issues + +(ert-deftest test-delete-buffer-and-file-non-file-buffer-does-nothing () + "Should do nothing if buffer not visiting file." + (test-delete-buffer-and-file-setup) + (unwind-protect + (with-temp-buffer + (rename-buffer "non-file-buffer" t) + (let ((buf (current-buffer))) + (cj/delete-buffer-and-file) + ;; Buffer should still be alive + (should (buffer-live-p buf)))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-scratch-buffer-does-nothing () + "Should do nothing for scratch buffer." + (test-delete-buffer-and-file-setup) + (unwind-protect + (with-current-buffer "*scratch*" + (cj/delete-buffer-and-file) + ;; Scratch buffer should still exist + (should (get-buffer "*scratch*"))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-already-killed-buffer () + "Should error when operating on killed buffer." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir)) + (buf nil)) + (with-temp-file test-file + (insert "content")) + (setq buf (find-file test-file)) + (kill-buffer buf) + (should-error + (with-current-buffer buf + (cj/delete-buffer-and-file)))) + (test-delete-buffer-and-file-teardown))) + +;;; Error Cases - File Issues + +(ert-deftest test-delete-buffer-and-file-already-deleted-file () + "Should propagate error when delete-file fails on missing file." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil)) + ((symbol-function 'delete-file) + (lambda (file &optional _trash) + (signal 'file-missing (list "Removing old name" "No such file or directory" file))))) + ;; Should propagate error from delete-file + (should-error (cj/delete-buffer-and-file) :type 'file-missing))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-no-delete-permission () + "Should propagate error when delete-file fails due to permissions." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil)) + ((symbol-function 'delete-file) + (lambda (file &optional _trash) + (signal 'file-error (list "Removing old name" "Permission denied" file))))) + ;; Should propagate error from delete-file + (should-error (cj/delete-buffer-and-file) :type 'file-error))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-no-write-permission-directory () + "Should error if directory not writable." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (set-file-modes test-dir #o555) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (should-error (cj/delete-buffer-and-file)) + (set-file-modes test-dir #o755))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-open-in-other-buffer () + "Should handle file open in another buffer." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (let ((buf1 (current-buffer))) + (find-file test-file) + (let ((buf2 (current-buffer))) + ;; Both buffers visiting same file + (should (eq buf1 buf2)) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (file-exists-p test-file)) + (should-not (buffer-live-p buf1)))))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-symlink-file () + "Should handle symlink files." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (real-file (expand-file-name "real.txt" test-dir)) + (symlink (expand-file-name "link.txt" test-dir))) + (with-temp-file real-file + (insert "content")) + (make-symbolic-link real-file symlink) + (find-file symlink) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + ;; Symlink should be deleted, real file should remain + (should-not (file-exists-p symlink)) + (should (file-exists-p real-file)))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-symlink-directory () + "Should handle files in symlinked directories." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((real-dir (cj/create-test-subdirectory "real")) + (link-dir (expand-file-name "link" cj/test-base-dir)) + (test-file (expand-file-name "test.txt" real-dir))) + (with-temp-file test-file + (insert "content")) + (make-symbolic-link real-dir link-dir) + (let ((file-via-link (expand-file-name "test.txt" link-dir))) + (find-file file-via-link) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + ;; File should be deleted + (should-not (file-exists-p test-file))))) + (test-delete-buffer-and-file-teardown))) + +;;; Edge Cases - Version Control + +(ert-deftest test-delete-buffer-and-file-git-tracked-file () + "Should use vc-delete-file for git files." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir)) + (vc-delete-called nil)) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) 'Git)) + ((symbol-function 'vc-delete-file) + (lambda (file) + (setq vc-delete-called t) + (when (get-file-buffer file) + (kill-buffer (get-file-buffer file))) + (delete-file file t)))) + (cj/delete-buffer-and-file) + (should vc-delete-called))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-untracked-in-vc-repo () + "Should use delete-file for untracked files in VC repo." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "untracked.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + ;; vc-backend returns nil for untracked files + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) nil))) + (cj/delete-buffer-and-file) + (should-not (file-exists-p test-file)))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-vc-backend-detection () + "Should correctly detect VC backend." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir)) + (backend-checked nil)) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) + (lambda (file) + (setq backend-checked file) + nil))) + (cj/delete-buffer-and-file) + (should (string= backend-checked test-file)))) + (test-delete-buffer-and-file-teardown))) + +(ert-deftest test-delete-buffer-and-file-vc-delete-fails () + "Should propagate vc-delete-file errors." + (test-delete-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (test-file (expand-file-name "test.txt" test-dir))) + (with-temp-file test-file + (insert "content")) + (find-file test-file) + (cl-letf (((symbol-function 'vc-backend) (lambda (&rest _) 'Git)) + ((symbol-function 'vc-delete-file) + (lambda (file) + (error "VC operation failed")))) + (should-error (cj/delete-buffer-and-file)))) + (test-delete-buffer-and-file-teardown))) + +(provide 'test-custom-buffer-file-delete-buffer-and-file) +;;; test-custom-buffer-file-delete-buffer-and-file.el ends here diff --git a/tests/test-custom-buffer-file-move-buffer-and-file.el b/tests/test-custom-buffer-file-move-buffer-and-file.el new file mode 100644 index 00000000..e8f4563d --- /dev/null +++ b/tests/test-custom-buffer-file-move-buffer-and-file.el @@ -0,0 +1,936 @@ +;;; test-custom-buffer-file-move-buffer-and-file.el --- Tests for cj/move-buffer-and-file -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--move-buffer-and-file function from custom-buffer-file.el +;; +;; This is the internal (non-interactive) implementation that moves both the +;; current buffer and its visited file to a new directory. It handles trailing +;; slashes, preserves file content, updates the visited-file-name, and clears +;; the modified flag. The interactive wrapper cj/move-buffer-and-file handles +;; user prompting and delegates to this implementation. + +;;; 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.") + +;; Stub ps-print package +(provide 'ps-print) + +;; Now load the actual production module +(require 'custom-buffer-file) + +;;; Setup and Teardown + +(defun test-move-buffer-and-file-setup () + "Setup for move-buffer-and-file tests." + (cj/create-test-base-dir)) + +(defun test-move-buffer-and-file-teardown () + "Teardown for move-buffer-and-file tests." + ;; Kill all buffers visiting files in test directory + (dolist (buf (buffer-list)) + (when (buffer-file-name buf) + (when (string-prefix-p cj/test-base-dir (buffer-file-name buf)) + (with-current-buffer buf + (set-buffer-modified-p nil)) + (kill-buffer buf)))) + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-move-buffer-and-file-simple-move-should-succeed () + "Should move file and buffer to new directory successfully." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir)) + (target-file (expand-file-name "test.txt" target-dir)) + (content "Test content")) + (with-temp-file source-file + (insert content)) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-updates-buffer-file-name () + "Should update buffer-file-name to new location." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir)) + (target-file (expand-file-name "test.txt" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (string= (buffer-file-name) target-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-preserves-content () + "Should preserve file content after move." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir)) + (content "Original content\nWith multiple lines\n")) + (with-temp-file source-file + (insert content)) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (string= (buffer-string) content)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-preserves-buffer-name () + "Should preserve buffer name after move." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "myfile.txt" source-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (should (string= (buffer-name) "myfile.txt")) + (cj/--move-buffer-and-file target-dir) + (should (string= (buffer-name) "myfile.txt")) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-clears-modified-flag () + "Should clear buffer modified flag after move." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (insert "modification") + (should (buffer-modified-p)) + (cj/--move-buffer-and-file target-dir) + (should-not (buffer-modified-p)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-returns-t-on-success () + "Should return t on successful move." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (should (eq t (cj/--move-buffer-and-file target-dir))) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-deletes-source-file () + "Should delete source file after move." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should-not (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-creates-target-file () + "Should create file in target directory." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir)) + (target-file (expand-file-name "test.txt" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +;;; Boundary Cases - Path Handling + +(ert-deftest test-move-buffer-and-file-trailing-slash-should-strip () + "Should handle directory with trailing slash." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir)) + (target-file (expand-file-name "test.txt" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file (concat target-dir "/")) + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-trailing-backslash-should-strip () + "Should handle directory with trailing backslash." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir)) + (target-file (expand-file-name "test.txt" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file (concat target-dir "\\")) + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-no-trailing-slash-should-work () + "Should work with directory without trailing slash." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir)) + (target-file (expand-file-name "test.txt" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-deeply-nested-target () + "Should move to deeply nested target directory." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "a/b/c/d/target")) + (source-file (expand-file-name "test.txt" source-dir)) + (target-file (expand-file-name "test.txt" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-relative-path-should-work () + "Should resolve relative paths relative to file's directory." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir)) + (target-file (expand-file-name "test.txt" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + ;; Use "../target" to go up from source/ to target/ + (cj/--move-buffer-and-file "../target") + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +;;; Boundary Cases - Character Encoding + +(ert-deftest test-move-buffer-and-file-unicode-filename () + "Should handle Unicode characters in filename." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test-café.txt" source-dir)) + (target-file (expand-file-name "test-café.txt" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-unicode-directory () + "Should handle Unicode characters in directory name." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target-ñoño")) + (source-file (expand-file-name "test.txt" source-dir)) + (target-file (expand-file-name "test.txt" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-emoji-in-filename () + "Should handle emoji in filename." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test-🎉-file.txt" source-dir)) + (target-file (expand-file-name "test-🎉-file.txt" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-rtl-characters () + "Should handle RTL text in filename." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test-مرحبا.txt" source-dir)) + (target-file (expand-file-name "test-مرحبا.txt" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-spaces-in-filename () + "Should handle spaces in filename." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test file with spaces.txt" source-dir)) + (target-file (expand-file-name "test file with spaces.txt" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-special-chars-in-filename () + "Should handle special characters in filename." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test[file]-(1).txt" source-dir)) + (target-file (expand-file-name "test[file]-(1).txt" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +;;; Boundary Cases - File Naming + +(ert-deftest test-move-buffer-and-file-hidden-file () + "Should handle hidden files (starting with dot)." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name ".hidden" source-dir)) + (target-file (expand-file-name ".hidden" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-no-extension () + "Should handle files without extensions." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "README" source-dir)) + (target-file (expand-file-name "README" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-multiple-dots-in-name () + "Should handle multiple dots in filename." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "my.file.name.test.txt" source-dir)) + (target-file (expand-file-name "my.file.name.test.txt" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-single-char-filename () + "Should handle single character filenames." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "x" source-dir)) + (target-file (expand-file-name "x" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-very-long-filename () + "Should handle very long filenames." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (long-name (concat (make-string 200 ?x) ".txt")) + (source-file (expand-file-name long-name source-dir)) + (target-file (expand-file-name long-name target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-very-long-path () + "Should handle very long paths." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((long-dir (make-string 100 ?x)) + (source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory long-dir)) + (long-filename (concat (make-string 100 ?y) ".txt")) + (source-file (expand-file-name long-filename source-dir)) + (target-file (expand-file-name long-filename target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +;;; Boundary Cases - File Content + +(ert-deftest test-move-buffer-and-file-empty-file () + "Should move empty file successfully." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "empty.txt" source-dir)) + (target-file (expand-file-name "empty.txt" target-dir))) + (with-temp-file source-file) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (should (= 0 (buffer-size))) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-large-file () + "Should move large file successfully." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "large.txt" source-dir)) + (large-content (make-string 100000 ?x))) + (with-temp-file source-file + (insert large-content)) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (string= (buffer-string) large-content)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-binary-file () + "Should move binary-like content successfully." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "binary.dat" source-dir)) + (target-file (expand-file-name "binary.dat" target-dir)) + (binary-content (string 0 1 2 3 255 254 253))) + (with-temp-file source-file + (set-buffer-multibyte nil) + (insert binary-content)) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-preserves-newlines () + "Should preserve different newline types." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "newlines.txt" source-dir)) + (content "Line 1\nLine 2\n\nLine 4\n")) + (with-temp-file source-file + (insert content)) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (string= (buffer-string) content)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-preserves-encoding () + "Should preserve UTF-8 encoded content." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "utf8.txt" source-dir)) + (content "Hello 世界 مرحبا Привет")) + (with-temp-file source-file + (insert content)) + (find-file source-file) + (cj/--move-buffer-and-file target-dir) + (should (string= (buffer-string) content)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +;;; Boundary Cases - Buffer State + +(ert-deftest test-move-buffer-and-file-with-unsaved-changes () + "Should handle buffer with unsaved changes." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir)) + (target-file (expand-file-name "test.txt" target-dir)) + (original "original")) + (with-temp-file source-file + (insert original)) + (find-file source-file) + (insert " modified") + (should (buffer-modified-p)) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (should-not (buffer-modified-p)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-with-multiple-windows () + "Should work when buffer is displayed in multiple windows." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir)) + (target-file (expand-file-name "test.txt" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (delete-other-windows) + (split-window) + (other-window 1) + (switch-to-buffer (get-file-buffer source-file)) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (kill-buffer (current-buffer)) + (delete-other-windows)) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-preserves-point-position () + "Should preserve point position in buffer." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir)) + (content "Line 1\nLine 2\nLine 3\n")) + (with-temp-file source-file + (insert content)) + (find-file source-file) + (goto-char (point-min)) + (forward-line 1) + (let ((original-point (point))) + (cj/--move-buffer-and-file target-dir) + (should (= (point) original-point))) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-preserves-mark () + "Should preserve mark in buffer." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir)) + (content "Line 1\nLine 2\nLine 3\n")) + (with-temp-file source-file + (insert content)) + (find-file source-file) + (goto-char (point-min)) + (set-mark (point)) + (forward-line 2) + (let ((original-mark (mark))) + (cj/--move-buffer-and-file target-dir) + (should (= (mark) original-mark))) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +;;; Error Cases - Buffer Issues + +(ert-deftest test-move-buffer-and-file-non-file-buffer-returns-nil () + "Should return nil when buffer not visiting a file." + (test-move-buffer-and-file-setup) + (unwind-protect + (let ((target-dir (cj/create-test-subdirectory "target"))) + (with-temp-buffer + (rename-buffer "non-file-buffer" t) + (let ((result (cj/--move-buffer-and-file target-dir))) + (should-not result)))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-scratch-buffer-returns-nil () + "Should return nil for scratch buffer." + (test-move-buffer-and-file-setup) + (unwind-protect + (let ((target-dir (cj/create-test-subdirectory "target"))) + (with-current-buffer "*scratch*" + (let ((result (cj/--move-buffer-and-file target-dir))) + (should-not result)))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-killed-buffer-should-error () + "Should error when operating on killed buffer." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir)) + (buf nil)) + (with-temp-file source-file + (insert "content")) + (setq buf (find-file source-file)) + (kill-buffer buf) + (should-error + (with-current-buffer buf + (cj/--move-buffer-and-file target-dir)))) + (test-move-buffer-and-file-teardown))) + +;;; Error Cases - Directory Issues + +(ert-deftest test-move-buffer-and-file-nonexistent-target-should-error () + "Should error when target directory doesn't exist." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (source-file (expand-file-name "test.txt" source-dir)) + (nonexistent-dir (expand-file-name "nonexistent" cj/test-base-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (should-error (cj/--move-buffer-and-file nonexistent-dir)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-target-is-file-not-dir-should-error () + "Should error when target is a file, not directory." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (source-file (expand-file-name "test.txt" source-dir)) + (target-file (expand-file-name "notadir.txt" cj/test-base-dir))) + (with-temp-file target-file + (insert "I'm a file")) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (should-error (cj/--move-buffer-and-file target-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-nil-directory-should-error () + "Should error when directory is nil." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (source-file (expand-file-name "test.txt" source-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (should-error (cj/--move-buffer-and-file nil)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-empty-string-directory-should-error () + "Should error when directory is empty string." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (source-file (expand-file-name "test.txt" source-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (should-error (cj/--move-buffer-and-file "")) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +;;; Error Cases - Permission Issues + +(ert-deftest test-move-buffer-and-file-no-read-permission-source-should-error () + "Should error when source file is not readable." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (set-file-modes source-file #o000) + (should-error (cj/--move-buffer-and-file target-dir)) + (set-file-modes source-file #o644) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-no-write-permission-target-should-error () + "Should error when target directory is not writable." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir))) + (with-temp-file source-file + (insert "content")) + (set-file-modes target-dir #o555) + (find-file source-file) + (should-error (cj/--move-buffer-and-file target-dir)) + (set-file-modes target-dir #o755) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-no-delete-permission-source-should-error () + "Should error when source directory doesn't allow deletion." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (set-file-modes source-dir #o555) + (should-error (cj/--move-buffer-and-file target-dir)) + (set-file-modes source-dir #o755) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +;;; Error Cases - File Conflicts + +(ert-deftest test-move-buffer-and-file-target-exists-should-overwrite () + "Should overwrite existing file when ok-if-exists is t." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir)) + (target-file (expand-file-name "test.txt" target-dir)) + (new-content "New content") + (old-content "Old content")) + (with-temp-file target-file + (insert old-content)) + (with-temp-file source-file + (insert new-content)) + (find-file source-file) + (cj/--move-buffer-and-file target-dir t) + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (revert-buffer t t) + (should (string= (buffer-string) new-content)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-target-exists-should-error-if-not-ok () + "Should error when target exists and ok-if-exists is nil." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir)) + (target-file (expand-file-name "test.txt" target-dir))) + (with-temp-file target-file + (insert "existing")) + (with-temp-file source-file + (insert "new")) + (find-file source-file) + (should-error (cj/--move-buffer-and-file target-dir nil)) + ;; Source should still exist since move failed + (should (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-interactive-prompts-if-target-exists () + "Should prompt user when called interactively and target exists." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir)) + (target-file (expand-file-name "test.txt" target-dir)) + (prompted nil)) + (with-temp-file target-file + (insert "existing")) + (with-temp-file source-file + (insert "new")) + (find-file source-file) + ;; Mock yes-or-no-p to capture that it was called + (cl-letf (((symbol-function 'yes-or-no-p) + (lambda (prompt) + (setq prompted t) + t)) + ((symbol-function 'read-directory-name) + (lambda (&rest _) target-dir))) + (call-interactively #'cj/move-buffer-and-file) + (should prompted)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-interactive-no-prompt-if-target-missing () + "Should not prompt when called interactively if target doesn't exist." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir)) + (prompted nil)) + (with-temp-file source-file + (insert "new")) + (find-file source-file) + ;; Mock yes-or-no-p to capture if it was called + (cl-letf (((symbol-function 'yes-or-no-p) + (lambda (prompt) + (setq prompted t) + t)) + ((symbol-function 'read-directory-name) + (lambda (&rest _) target-dir))) + (call-interactively #'cj/move-buffer-and-file) + (should-not prompted)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-source-deleted-during-operation-should-error () + "Should error if source file is deleted during operation." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (delete-file source-file) + (should-error (cj/--move-buffer-and-file target-dir)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +;;; Error Cases - Edge Cases + +(ert-deftest test-move-buffer-and-file-symlink-source-should-handle () + "Should handle symbolic link as source." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (real-file (expand-file-name "real.txt" source-dir)) + (symlink (expand-file-name "link.txt" source-dir)) + (target-file (expand-file-name "link.txt" target-dir))) + (with-temp-file real-file + (insert "content")) + (make-symbolic-link real-file symlink) + (find-file symlink) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(ert-deftest test-move-buffer-and-file-read-only-buffer-should-still-work () + "Should work even if buffer is read-only." + (test-move-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (source-file (expand-file-name "test.txt" source-dir)) + (target-file (expand-file-name "test.txt" target-dir))) + (with-temp-file source-file + (insert "content")) + (find-file source-file) + (read-only-mode 1) + (cj/--move-buffer-and-file target-dir) + (should (file-exists-p target-file)) + (should-not (file-exists-p source-file)) + (kill-buffer (current-buffer))) + (test-move-buffer-and-file-teardown))) + +(provide 'test-custom-buffer-file-move-buffer-and-file) +;;; test-custom-buffer-file-move-buffer-and-file.el ends here diff --git a/tests/test-custom-buffer-file-rename-buffer-and-file.el b/tests/test-custom-buffer-file-rename-buffer-and-file.el new file mode 100644 index 00000000..1eb61f1b --- /dev/null +++ b/tests/test-custom-buffer-file-rename-buffer-and-file.el @@ -0,0 +1,939 @@ +;;; test-custom-buffer-file-rename-buffer-and-file.el --- Tests for cj/--rename-buffer-and-file -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--rename-buffer-and-file function from custom-buffer-file.el +;; +;; This is the internal (non-interactive) implementation that renames both the +;; current buffer and its visited file. The interactive wrapper +;; cj/rename-buffer-and-file handles user prompting and delegates to this +;; implementation. + +;;; 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.") + +;; Stub ps-print package +(provide 'ps-print) + +;; Now load the actual production module +(require 'custom-buffer-file) + +;;; Setup and Teardown + +(defun test-rename-buffer-and-file-setup () + "Setup for rename-buffer-and-file tests." + (cj/create-test-base-dir)) + +(defun test-rename-buffer-and-file-teardown () + "Teardown for rename-buffer-and-file tests." + ;; Kill all buffers visiting files in test directory + (dolist (buf (buffer-list)) + (when (buffer-file-name buf) + (when (string-prefix-p cj/test-base-dir (buffer-file-name buf)) + (with-current-buffer buf + (set-buffer-modified-p nil)) + (kill-buffer buf)))) + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-rename-buffer-and-file-simple-rename () + "Should rename file in same directory." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name "new.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file "new.txt") + (should (file-exists-p new-file)) + (should-not (file-exists-p old-file)) + (should (string= (buffer-name) "new.txt")) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-different-directory () + "Should rename to absolute path in different directory." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (old-file (expand-file-name "file.txt" source-dir)) + (new-file (expand-file-name "renamed.txt" target-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file new-file) + (should (file-exists-p new-file)) + (should-not (file-exists-p old-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-different-extension () + "Should change file extension." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "file.txt" test-dir)) + (new-file (expand-file-name "file.md" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file "file.md") + (should (file-exists-p new-file)) + (should-not (file-exists-p old-file)) + (should (string= (buffer-name) "file.md")) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-preserves-content () + "Should preserve file content after rename." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (content "Important content\nWith multiple lines")) + (with-temp-file old-file + (insert content)) + (find-file old-file) + (cj/--rename-buffer-and-file "new.txt") + (should (string= (buffer-string) content)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-updates-buffer-name () + "Should update buffer name to match new filename." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (should (string= (buffer-name) "old.txt")) + (cj/--rename-buffer-and-file "new.txt") + (should (string= (buffer-name) "new.txt")) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-updates-buffer-file-name () + "Should update buffer-file-name correctly." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name "new.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file "new.txt") + (should (string= (buffer-file-name) new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-clears-modified-flag () + "Should clear modified flag after rename." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (insert "modification") + (should (buffer-modified-p)) + (cj/--rename-buffer-and-file "new.txt") + (should-not (buffer-modified-p)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-returns-t-on-success () + "Should return t when successful." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (should (eq t (cj/--rename-buffer-and-file "new.txt"))) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +;;; Boundary Cases - Naming + +(ert-deftest test-rename-buffer-and-file-unicode-in-name () + "Should handle Unicode characters in name." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name "café.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file "café.txt") + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-emoji-in-name () + "Should handle emoji characters in name." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name "test-🎉.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file "test-🎉.txt") + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-rtl-text-in-name () + "Should handle RTL text in name." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name "مرحبا.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file "مرحبا.txt") + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-spaces-in-name () + "Should handle spaces in name." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name "my new file.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file "my new file.txt") + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-special-chars-in-name () + "Should handle special characters in name." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name "[test]-(1).txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file "[test]-(1).txt") + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-very-long-name () + "Should handle very long filename." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (long-name (concat (make-string 200 ?x) ".txt")) + (new-file (expand-file-name long-name test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file long-name) + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-single-char-name () + "Should handle single character name." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name "x" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file "x") + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-multiple-dots () + "Should handle multiple dots in name." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name "my.file.name.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file "my.file.name.txt") + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-no-extension () + "Should handle files without extension." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name "README" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file "README") + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-hidden-file () + "Should handle hidden files (starting with dot)." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name ".hidden" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file ".hidden") + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-trailing-whitespace () + "Should handle trailing/leading spaces in name." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name " spaced " test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file " spaced ") + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-only-changes-case () + "Should handle case-only rename on case-sensitive filesystems." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "test.txt" test-dir)) + (new-file (expand-file-name "TEST.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + ;; On case-insensitive systems, need ok-if-exists + (cj/--rename-buffer-and-file "TEST.txt" t) + (should (string= (buffer-name) "TEST.txt")) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-adds-extension () + "Should handle adding extension to file." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "file" test-dir)) + (new-file (expand-file-name "file.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file "file.txt") + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-removes-extension () + "Should handle removing extension from file." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "file.txt" test-dir)) + (new-file (expand-file-name "file" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file "file") + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-just-extension () + "Should handle name that is just extension." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name ".gitignore" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file ".gitignore") + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +;;; Boundary Cases - Path Handling + +(ert-deftest test-rename-buffer-and-file-relative-path () + "Should handle relative path." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (old-file (expand-file-name "file.txt" source-dir)) + (new-file (expand-file-name "renamed.txt" target-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file "../target/renamed.txt") + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-absolute-path () + "Should handle absolute path." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (old-file (expand-file-name "file.txt" source-dir)) + (new-file (expand-file-name "renamed.txt" target-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file new-file) + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-parent-directory () + "Should handle parent directory reference." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((parent-dir (cj/create-test-subdirectory "parent")) + (source-dir (cj/create-test-subdirectory "parent/source")) + (old-file (expand-file-name "file.txt" source-dir)) + (new-file (expand-file-name "renamed.txt" parent-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file "../renamed.txt") + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-deeply-nested-target () + "Should handle deeply nested target directory." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "a/b/c/d/target")) + (old-file (expand-file-name "file.txt" source-dir)) + (new-file (expand-file-name "renamed.txt" target-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file new-file) + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-same-directory-basename-only () + "Should rename in same directory using just basename." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name "new.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file "new.txt") + (should (file-exists-p new-file)) + (should-not (file-exists-p old-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-expand-tilde () + "Should expand tilde in path." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + ;; Use a path relative to home that we can create + (home-test-dir (expand-file-name "temp-test-rename" "~")) + (new-file (expand-file-name "renamed.txt" home-test-dir))) + (make-directory home-test-dir t) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (cj/--rename-buffer-and-file (concat "~/temp-test-rename/renamed.txt")) + (should (file-exists-p new-file)) + (kill-buffer (current-buffer)) + (delete-directory home-test-dir t)) + (test-rename-buffer-and-file-teardown))) + +;;; Boundary Cases - File Content + +(ert-deftest test-rename-buffer-and-file-empty-file () + "Should handle empty file." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name "new.txt" test-dir))) + (with-temp-file old-file) + (find-file old-file) + (cj/--rename-buffer-and-file "new.txt") + (should (file-exists-p new-file)) + (should (= 0 (buffer-size))) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-large-file () + "Should handle large file." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (large-content (make-string 100000 ?x))) + (with-temp-file old-file + (insert large-content)) + (find-file old-file) + (cj/--rename-buffer-and-file "new.txt") + (should (string= (buffer-string) large-content)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-binary-content () + "Should handle binary content." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.dat" test-dir)) + (new-file (expand-file-name "new.dat" test-dir)) + (binary-content (string 0 1 2 3 255 254 253))) + (with-temp-file old-file + (set-buffer-multibyte nil) + (insert binary-content)) + (find-file old-file) + (cj/--rename-buffer-and-file "new.dat") + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-preserves-newlines () + "Should preserve different newline types." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (content "Line 1\nLine 2\n\nLine 4\n")) + (with-temp-file old-file + (insert content)) + (find-file old-file) + (cj/--rename-buffer-and-file "new.txt") + (should (string= (buffer-string) content)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-preserves-encoding () + "Should preserve UTF-8 encoded content." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (content "Hello 世界 مرحبا Привет")) + (with-temp-file old-file + (insert content)) + (find-file old-file) + (cj/--rename-buffer-and-file "new.txt") + (should (string= (buffer-string) content)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +;;; Boundary Cases - Buffer State + +(ert-deftest test-rename-buffer-and-file-with-unsaved-changes () + "Should handle buffer with unsaved changes." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir))) + (with-temp-file old-file + (insert "original")) + (find-file old-file) + (insert " modified") + (should (buffer-modified-p)) + (cj/--rename-buffer-and-file "new.txt") + (should-not (buffer-modified-p)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-multiple-windows () + "Should work when buffer displayed in multiple windows." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (delete-other-windows) + (split-window) + (other-window 1) + (switch-to-buffer (get-file-buffer old-file)) + (cj/--rename-buffer-and-file "new.txt") + (should (string= (buffer-name) "new.txt")) + (kill-buffer (current-buffer)) + (delete-other-windows)) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-preserves-point () + "Should preserve point position." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (content "Line 1\nLine 2\nLine 3\n")) + (with-temp-file old-file + (insert content)) + (find-file old-file) + (goto-char (point-min)) + (forward-line 1) + (let ((original-point (point))) + (cj/--rename-buffer-and-file "new.txt") + (should (= (point) original-point))) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-preserves-mark () + "Should preserve mark." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (content "Line 1\nLine 2\nLine 3\n")) + (with-temp-file old-file + (insert content)) + (find-file old-file) + (goto-char (point-min)) + (set-mark (point)) + (forward-line 2) + (let ((original-mark (mark))) + (cj/--rename-buffer-and-file "new.txt") + (should (= (mark) original-mark))) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-read-only-buffer () + "Should work even with read-only buffer." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name "new.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (read-only-mode 1) + (cj/--rename-buffer-and-file "new.txt") + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +;;; Error Cases - Buffer Issues + +(ert-deftest test-rename-buffer-and-file-non-file-buffer-returns-nil () + "Should return nil when buffer not visiting file." + (test-rename-buffer-and-file-setup) + (unwind-protect + (with-temp-buffer + (rename-buffer "non-file-buffer" t) + (let ((result (cj/--rename-buffer-and-file "new.txt"))) + (should-not result))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-scratch-buffer-returns-nil () + "Should return nil for scratch buffer." + (test-rename-buffer-and-file-setup) + (unwind-protect + (with-current-buffer "*scratch*" + (let ((result (cj/--rename-buffer-and-file "new.txt"))) + (should-not result))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-buffer-name-exists-should-error () + "Should error when buffer with new name exists." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (file1 (expand-file-name "file1.txt" test-dir)) + (file2 (expand-file-name "file2.txt" test-dir))) + (with-temp-file file1 + (insert "content1")) + (with-temp-file file2 + (insert "content2")) + (find-file file1) + (let ((buf1 (current-buffer))) + (find-file file2) + ;; Try to rename file2 to file1.txt (buffer exists) + (should-error (cj/--rename-buffer-and-file "file1.txt")) + (kill-buffer (current-buffer)) + (kill-buffer buf1))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-killed-buffer-should-error () + "Should error when operating on killed buffer." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (buf nil)) + (with-temp-file old-file + (insert "content")) + (setq buf (find-file old-file)) + (kill-buffer buf) + (should-error + (with-current-buffer buf + (cj/--rename-buffer-and-file "new.txt")))) + (test-rename-buffer-and-file-teardown))) + +;;; Error Cases - File Conflicts + +(ert-deftest test-rename-buffer-and-file-target-exists-should-error-if-not-ok () + "Should error when target exists and ok-if-exists is nil." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name "new.txt" test-dir))) + (with-temp-file old-file + (insert "old content")) + (with-temp-file new-file + (insert "existing content")) + (find-file old-file) + (should-error (cj/--rename-buffer-and-file "new.txt" nil)) + ;; Old file should still exist since rename failed + (should (file-exists-p old-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-target-exists-should-overwrite-if-ok () + "Should overwrite when target exists and ok-if-exists is t." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name "new.txt" test-dir)) + (old-content "old content") + (new-content "existing content")) + (with-temp-file old-file + (insert old-content)) + (with-temp-file new-file + (insert new-content)) + (find-file old-file) + (cj/--rename-buffer-and-file "new.txt" t) + (should (file-exists-p new-file)) + (should-not (file-exists-p old-file)) + ;; Content should be from old file + (revert-buffer t t) + (should (string= (buffer-string) old-content)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-source-deleted-should-error () + "Should error if source file deleted during operation." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (delete-file old-file) + (should-error (cj/--rename-buffer-and-file "new.txt")) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-same-name-is-noop () + "Should handle rename to same name as no-op." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "file.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + ;; Rename to same name with ok-if-exists + (cj/--rename-buffer-and-file "file.txt" t) + (should (file-exists-p old-file)) + (should (string= (buffer-name) "file.txt")) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +;;; Error Cases - Path Issues + +(ert-deftest test-rename-buffer-and-file-nil-name-should-error () + "Should error when new-name is nil." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (should-error (cj/--rename-buffer-and-file nil)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-empty-name-should-error () + "Should error when new-name is empty string." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (should-error (cj/--rename-buffer-and-file "")) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-nonexistent-target-dir-should-error () + "Should error when target directory doesn't exist." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (nonexistent-path (expand-file-name "nonexistent/new.txt" cj/test-base-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (should-error (cj/--rename-buffer-and-file nonexistent-path)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-target-is-directory-should-error () + "Should error when new-name is existing directory." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (target-dir (cj/create-test-subdirectory "target")) + (old-file (expand-file-name "old.txt" test-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (should-error (cj/--rename-buffer-and-file target-dir)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +;;; Error Cases - Permissions + +(ert-deftest test-rename-buffer-and-file-no-write-permission-target () + "Should error when target directory not writable." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (old-file (expand-file-name "old.txt" source-dir)) + (new-file (expand-file-name "new.txt" target-dir))) + (with-temp-file old-file + (insert "content")) + (set-file-modes target-dir #o555) + (find-file old-file) + (should-error (cj/--rename-buffer-and-file new-file)) + (set-file-modes target-dir #o755) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-no-delete-permission-source-dir () + "Should error when source directory doesn't allow deletion." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((source-dir (cj/create-test-subdirectory "source")) + (target-dir (cj/create-test-subdirectory "target")) + (old-file (expand-file-name "old.txt" source-dir)) + (new-file (expand-file-name "new.txt" target-dir))) + (with-temp-file old-file + (insert "content")) + (find-file old-file) + (set-file-modes source-dir #o555) + (should-error (cj/--rename-buffer-and-file new-file)) + (set-file-modes source-dir #o755) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +;;; Error Cases - Edge Cases + +(ert-deftest test-rename-buffer-and-file-symlink-source () + "Should handle symbolic link as source." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (real-file (expand-file-name "real.txt" test-dir)) + (symlink (expand-file-name "link.txt" test-dir)) + (new-file (expand-file-name "renamed.txt" test-dir))) + (with-temp-file real-file + (insert "content")) + (make-symbolic-link real-file symlink) + (find-file symlink) + (cj/--rename-buffer-and-file "renamed.txt") + (should (file-exists-p new-file)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(ert-deftest test-rename-buffer-and-file-interactive-prompts-on-conflict () + "Should prompt user when called interactively and file exists." + (test-rename-buffer-and-file-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "test")) + (old-file (expand-file-name "old.txt" test-dir)) + (new-file (expand-file-name "new.txt" test-dir)) + (prompted nil)) + (with-temp-file old-file + (insert "old")) + (with-temp-file new-file + (insert "existing")) + (find-file old-file) + ;; Mock yes-or-no-p to capture that it was called + (cl-letf (((symbol-function 'yes-or-no-p) + (lambda (prompt) + (setq prompted t) + t)) + ((symbol-function 'read-string) + (lambda (&rest _) "new.txt"))) + (call-interactively #'cj/rename-buffer-and-file) + (should prompted)) + (kill-buffer (current-buffer))) + (test-rename-buffer-and-file-teardown))) + +(provide 'test-custom-buffer-file-rename-buffer-and-file) +;;; test-custom-buffer-file-rename-buffer-and-file.el ends here diff --git a/tests/test-custom-comments-comment-block-banner.el b/tests/test-custom-comments-comment-block-banner.el new file mode 100644 index 00000000..6561ebfa --- /dev/null +++ b/tests/test-custom-comments-comment-block-banner.el @@ -0,0 +1,228 @@ +;;; test-custom-comments-comment-block-banner.el --- Tests for cj/comment-block-banner -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/comment-block-banner function from custom-comments.el +;; +;; This function generates a 3-line block banner comment (JSDoc/Doxygen style): +;; - Top line: comment-start (e.g., /*) + decoration chars +;; - Text line: space + decoration char + space + text +;; - Bottom line: space + decoration chars + comment-end (e.g., */) +;; +;; This style is common in C, JavaScript, Java, and other languages that use +;; block comments. +;; +;; We test the NON-INTERACTIVE implementation (cj/--comment-block-banner) +;; to avoid mocking user prompts. This follows our testing best practice +;; of separating business logic from UI interaction. +;; +;; Cross-Language Testing Strategy: +;; - Comprehensive testing in C (the primary language for this style) +;; - Representative testing in JavaScript/Java (similar block comment syntax) +;; - This style is specifically designed for block comments, so we focus +;; testing on languages that use /* */ syntax +;; - See test-custom-comments-delete-buffer-comments.el for detailed rationale + +;;; 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-comments) + +;;; Test Helpers + +(defun test-block-banner-at-column (column-pos comment-start comment-end decoration-char text length) + "Test cj/--comment-block-banner at COLUMN-POS indentation. +Insert spaces to reach COLUMN-POS, then call cj/--comment-block-banner with +COMMENT-START, COMMENT-END, DECORATION-CHAR, TEXT, and LENGTH. +Returns the buffer string for assertions." + (with-temp-buffer + (when (> column-pos 0) + (insert (make-string column-pos ?\s))) + (cj/--comment-block-banner comment-start comment-end decoration-char text length) + (buffer-string))) + +;;; C/JavaScript/Java Tests (Block Comment Languages - Comprehensive Coverage) + +;;; Normal Cases + +(ert-deftest test-block-banner-c-basic () + "Should generate 3-line block banner in C style." + (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "Section Header" 70))) + ;; Should have 3 lines + (should (= 3 (length (split-string result "\n" t)))) + ;; First line should start with /* + (should (string-match-p "^/\\*\\*" result)) + ;; Middle line should contain text + (should (string-match-p "\\* Section Header" result)) + ;; Last line should end with */ + (should (string-match-p "\\*/$" result)))) + +(ert-deftest test-block-banner-c-custom-decoration () + "Should use custom decoration character." + (let ((result (test-block-banner-at-column 0 "/*" "*/" "#" "Header" 70))) + (should (string-match-p "/\\*#" result)) + (should (string-match-p " # Header" result)))) + +(ert-deftest test-block-banner-c-custom-text () + "Should include custom text in banner." + (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "Custom Text Here" 70))) + (should (string-match-p "Custom Text Here" result)))) + +(ert-deftest test-block-banner-c-empty-text () + "Should handle empty text string." + (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "" 70))) + ;; Should still generate 3 lines + (should (= 3 (length (split-string result "\n" t)))) + ;; Should have comment delimiters + (should (string-match-p "/\\*" result)) + (should (string-match-p "\\*/$" result)))) + +(ert-deftest test-block-banner-c-at-column-0 () + "Should work at column 0." + (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "Header" 70))) + ;; First character should be / + (should (string-prefix-p "/*" result)))) + +(ert-deftest test-block-banner-c-indented () + "Should work when indented." + (let ((result (test-block-banner-at-column 4 "/*" "*/" "*" "Header" 70))) + ;; First line should start with spaces + (should (string-prefix-p " /*" result)) + ;; Other lines should be indented + (let ((lines (split-string result "\n" t))) + (should (string-prefix-p " " (nth 1 lines))) ; text line has extra space + (should (string-prefix-p " " (nth 2 lines)))))) ; bottom line has extra space + +(ert-deftest test-block-banner-c-short-text () + "Should handle short text properly." + (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "X" 70))) + ;; Should have 3 lines + (should (= 3 (length (split-string result "\n" t)))) + ;; Text should be present + (should (string-match-p "X" result)))) + +(ert-deftest test-block-banner-c-long-text () + "Should handle longer text." + (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "This is a longer header text" 70))) + ;; Should have 3 lines + (should (= 3 (length (split-string result "\n" t)))) + ;; Text should be present + (should (string-match-p "This is a longer header text" result)))) + +(ert-deftest test-block-banner-c-custom-length () + "Should respect custom length." + (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "Header" 50))) + ;; Top line should be approximately 50 chars + (let ((first-line (car (split-string result "\n" t)))) + (should (<= (length first-line) 51)) + (should (>= (length first-line) 48))))) + +;;; Boundary Cases + +(ert-deftest test-block-banner-c-minimum-length () + "Should work with minimum viable length." + (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "X" 10))) + (should (= 3 (length (split-string result "\n" t)))) + (should (string-match-p "X" result)))) + +(ert-deftest test-block-banner-c-very-long-length () + "Should handle very long length." + (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "Header" 200))) + (should (= 3 (length (split-string result "\n" t)))) + ;; Top line should be very long + (let ((first-line (car (split-string result "\n" t)))) + (should (> (length first-line) 100))))) + +(ert-deftest test-block-banner-c-unicode-decoration () + "Should handle unicode decoration character." + (let ((result (test-block-banner-at-column 0 "/*" "*/" "✦" "Header" 70))) + (should (string-match-p "✦" result)))) + +(ert-deftest test-block-banner-c-unicode-text () + "Should handle unicode in text." + (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "Hello 👋 مرحبا café" 70))) + (should (string-match-p "👋" result)) + (should (string-match-p "مرحبا" result)) + (should (string-match-p "café" result)))) + +(ert-deftest test-block-banner-c-very-long-text () + "Should handle very long text." + (let* ((long-text (make-string 100 ?x)) + (result (test-block-banner-at-column 0 "/*" "*/" "*" long-text 70))) + ;; Should still generate output + (should (= 3 (length (split-string result "\n" t)))) + ;; Middle line should contain some of the text + (should (string-match-p "xxx" result)))) + +(ert-deftest test-block-banner-c-max-indentation () + "Should handle maximum practical indentation." + (let ((result (test-block-banner-at-column 60 "/*" "*/" "*" "Header" 100))) + (should (= 3 (length (split-string result "\n" t)))) + ;; First line should start with 60 spaces + (should (string-prefix-p (make-string 60 ?\s) result)))) + +;;; Error Cases + +(ert-deftest test-block-banner-c-length-too-small () + "Should error when length is too small." + (should-error + (test-block-banner-at-column 0 "/*" "*/" "*" "Header" 3) + :type 'error)) + +(ert-deftest test-block-banner-c-negative-length () + "Should error with negative length." + (should-error + (test-block-banner-at-column 0 "/*" "*/" "*" "Header" -10) + :type 'error)) + +(ert-deftest test-block-banner-c-zero-length () + "Should error with zero length." + (should-error + (test-block-banner-at-column 0 "/*" "*/" "*" "Header" 0) + :type 'error)) + +(ert-deftest test-block-banner-c-nil-decoration () + "Should error when decoration-char is nil." + (should-error + (test-block-banner-at-column 0 "/*" "*/" nil "Header" 70) + :type 'wrong-type-argument)) + +(ert-deftest test-block-banner-c-nil-text () + "Should error when text is nil." + (should-error + (test-block-banner-at-column 0 "/*" "*/" "*" nil 70) + :type 'wrong-type-argument)) + +(ert-deftest test-block-banner-c-non-integer-length () + "Should error when length is not an integer." + (should-error + (test-block-banner-at-column 0 "/*" "*/" "*" "Header" "not-a-number") + :type 'wrong-type-argument)) + +;;; Alternative Block Comment Styles + +(ert-deftest test-block-banner-java-style () + "Should work with Java-style block comments." + (let ((result (test-block-banner-at-column 0 "/**" "*/" "*" "JavaDoc Comment" 70))) + (should (= 3 (length (split-string result "\n" t)))) + (should (string-match-p "^/\\*\\*\\*" result)) + (should (string-match-p "JavaDoc Comment" result)))) + +(ert-deftest test-block-banner-js-style () + "Should work with JavaScript-style block comments." + (let ((result (test-block-banner-at-column 2 "/*" "*/" "*" "Function Documentation" 70))) + (should (= 3 (length (split-string result "\n" t)))) + (should (string-prefix-p " /*" result)) + (should (string-match-p "Function Documentation" result)))) + +(provide 'test-custom-comments-comment-block-banner) +;;; test-custom-comments-comment-block-banner.el ends here diff --git a/tests/test-custom-comments-comment-box.el b/tests/test-custom-comments-comment-box.el new file mode 100644 index 00000000..10b1a67d --- /dev/null +++ b/tests/test-custom-comments-comment-box.el @@ -0,0 +1,241 @@ +;;; test-custom-comments-comment-box.el --- Tests for cj/comment-box -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/comment-box function from custom-comments.el +;; +;; This function generates a 3-line box comment: +;; - Top border: comment-start + full decoration line +;; - Text line: comment-start + decoration + spaces + text + spaces + decoration +;; - Bottom border: comment-start + full decoration line +;; +;; The text is centered within the box with decoration characters on the sides. +;; +;; We test the NON-INTERACTIVE implementation (cj/--comment-box) +;; to avoid mocking user prompts. This follows our testing best practice +;; of separating business logic from UI interaction. +;; +;; Cross-Language Testing Strategy: +;; - Comprehensive testing in Emacs Lisp (our primary language) +;; - Representative testing in Python and C (hash-based and C-style comments) +;; - Function handles comment syntax generically, so testing 3 syntaxes +;; proves cross-language compatibility +;; - See test-custom-comments-delete-buffer-comments.el for detailed rationale + +;;; 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-comments) + +;;; Test Helpers + +(defun test-comment-box-at-column (column-pos comment-start comment-end decoration-char text length) + "Test cj/--comment-box at COLUMN-POS indentation. +Insert spaces to reach COLUMN-POS, then call cj/--comment-box with +COMMENT-START, COMMENT-END, DECORATION-CHAR, TEXT, and LENGTH. +Returns the buffer string for assertions." + (with-temp-buffer + (when (> column-pos 0) + (insert (make-string column-pos ?\s))) + (cj/--comment-box comment-start comment-end decoration-char text length) + (buffer-string))) + +;;; Emacs Lisp Tests (Primary Language - Comprehensive Coverage) + +;;; Normal Cases + +(ert-deftest test-comment-box-elisp-basic () + "Should generate 3-line box in emacs-lisp style." + (let ((result (test-comment-box-at-column 0 ";;" "" "-" "Section Header" 70))) + ;; Should have 3 lines + (should (= 3 (length (split-string result "\n" t)))) + ;; First line should start with ;; and have decoration + (should (string-match-p "^;; -" result)) + ;; Middle line should contain text with side borders + (should (string-match-p ";; - .* Section Header .* - ;;" result)) + ;; Should have top and bottom borders + (should (string-match-p "^;; -" result)))) + +(ert-deftest test-comment-box-elisp-custom-decoration () + "Should use custom decoration character." + (let ((result (test-comment-box-at-column 0 ";;" "" "*" "Header" 70))) + (should (string-match-p ";; \\*" result)) + (should-not (string-match-p "-" result)))) + +(ert-deftest test-comment-box-elisp-custom-text () + "Should include custom text centered in box." + (let ((result (test-comment-box-at-column 0 ";;" "" "-" "Custom Text Here" 70))) + (should (string-match-p "Custom Text Here" result)))) + +(ert-deftest test-comment-box-elisp-empty-text () + "Should handle empty text string." + (let ((result (test-comment-box-at-column 0 ";;" "" "-" "" 70))) + ;; Should still generate 3 lines + (should (= 3 (length (split-string result "\n" t)))) + ;; Should have side borders + (should (string-match-p "- .*-" result)))) + +(ert-deftest test-comment-box-elisp-at-column-0 () + "Should work at column 0." + (let ((result (test-comment-box-at-column 0 ";;" "" "-" "Header" 70))) + ;; First character should be semicolon + (should (string-prefix-p ";;" result)))) + +(ert-deftest test-comment-box-elisp-indented () + "Should work when indented." + (let ((result (test-comment-box-at-column 4 ";;" "" "-" "Header" 70))) + ;; First line should start with spaces + (should (string-prefix-p " ;;" result)) + ;; Other lines should be indented + (let ((lines (split-string result "\n" t))) + (should (string-prefix-p " " (nth 1 lines))) + (should (string-prefix-p " " (nth 2 lines)))))) + +(ert-deftest test-comment-box-elisp-short-text () + "Should center short text properly." + (let ((result (test-comment-box-at-column 0 ";;" "" "-" "X" 70))) + ;; Should have 3 lines + (should (= 3 (length (split-string result "\n" t)))) + ;; Text should be present and centered + (should (string-match-p "- .* X .* -" result)))) + +(ert-deftest test-comment-box-elisp-long-text () + "Should handle longer text." + (let ((result (test-comment-box-at-column 0 ";;" "" "-" "This is a longer header text" 70))) + ;; Should have 3 lines + (should (= 3 (length (split-string result "\n" t)))) + ;; Text should be present + (should (string-match-p "This is a longer header text" result)))) + +;;; Boundary Cases + +(ert-deftest test-comment-box-elisp-minimum-length () + "Should work with minimum viable length." + (let ((result (test-comment-box-at-column 0 ";;" "" "-" "X" 15))) + (should (= 3 (length (split-string result "\n" t)))) + (should (string-match-p "X" result)))) + +(ert-deftest test-comment-box-elisp-very-long-length () + "Should handle very long length." + (let ((result (test-comment-box-at-column 0 ";;" "" "-" "Header" 200))) + (should (= 3 (length (split-string result "\n" t)))) + ;; Border lines should be very long + (let ((first-line (car (split-string result "\n" t)))) + (should (> (length first-line) 100))))) + +(ert-deftest test-comment-box-elisp-unicode-decoration () + "Should handle unicode decoration character." + (let ((result (test-comment-box-at-column 0 ";;" "" "═" "Header" 70))) + (should (string-match-p "═" result)))) + +(ert-deftest test-comment-box-elisp-unicode-text () + "Should handle unicode in text." + (let ((result (test-comment-box-at-column 0 ";;" "" "-" "Hello 👋 مرحبا café" 70))) + (should (string-match-p "👋" result)) + (should (string-match-p "مرحبا" result)) + (should (string-match-p "café" result)))) + +(ert-deftest test-comment-box-elisp-very-long-text () + "Should handle very long text." + (let* ((long-text (make-string 100 ?x)) + (result (test-comment-box-at-column 0 ";;" "" "-" long-text 70))) + ;; Should still generate output + (should (= 3 (length (split-string result "\n" t)))) + ;; Middle line should contain some of the text + (should (string-match-p "xxx" result)))) + +(ert-deftest test-comment-box-elisp-comment-end-symmetric () + "Should use symmetric comment syntax when comment-end is empty." + (let ((result (test-comment-box-at-column 0 ";;" "" "-" "Header" 70))) + (should (= 3 (length (split-string result "\n" t)))) + ;; Should use ;; on both sides for symmetry + (should (string-match-p ";;.*;;$" result)))) + +(ert-deftest test-comment-box-elisp-max-indentation () + "Should handle maximum practical indentation." + (let ((result (test-comment-box-at-column 60 ";;" "" "-" "Header" 100))) + (should (= 3 (length (split-string result "\n" t)))) + ;; First line should start with 60 spaces + (should (string-prefix-p (make-string 60 ?\s) result)))) + +(ert-deftest test-comment-box-elisp-text-centering-even () + "Should center text properly with even length." + (let ((result (test-comment-box-at-column 0 ";;" "" "-" "EVEN" 70))) + ;; Text should be centered (roughly equal padding on both sides) + (should (string-match-p "- .* EVEN .* -" result)))) + +(ert-deftest test-comment-box-elisp-text-centering-odd () + "Should center text properly with odd length." + (let ((result (test-comment-box-at-column 0 ";;" "" "-" "ODD" 70))) + ;; Text should be centered (roughly equal padding on both sides) + (should (string-match-p "- .* ODD .* -" result)))) + +;;; Error Cases + +(ert-deftest test-comment-box-elisp-length-too-small () + "Should error when length is too small." + (should-error + (test-comment-box-at-column 0 ";;" "" "-" "Header" 5) + :type 'error)) + +(ert-deftest test-comment-box-elisp-negative-length () + "Should error with negative length." + (should-error + (test-comment-box-at-column 0 ";;" "" "-" "Header" -10) + :type 'error)) + +(ert-deftest test-comment-box-elisp-zero-length () + "Should error with zero length." + (should-error + (test-comment-box-at-column 0 ";;" "" "-" "Header" 0) + :type 'error)) + +(ert-deftest test-comment-box-elisp-nil-decoration () + "Should error when decoration-char is nil." + (should-error + (test-comment-box-at-column 0 ";;" "" nil "Header" 70) + :type 'wrong-type-argument)) + +(ert-deftest test-comment-box-elisp-non-integer-length () + "Should error when length is not an integer." + (should-error + (test-comment-box-at-column 0 ";;" "" "-" "Header" "not-a-number") + :type 'wrong-type-argument)) + +;;; Python Tests (Hash-based comments) + +(ert-deftest test-comment-box-python-basic () + "Should generate box with Python comment syntax." + (let ((result (test-comment-box-at-column 0 "#" "" "-" "Section" 70))) + (should (= 3 (length (split-string result "\n" t)))) + (should (string-match-p "^# -" result)) + (should (string-match-p "Section" result)))) + +(ert-deftest test-comment-box-python-indented () + "Should handle indented Python comments." + (let ((result (test-comment-box-at-column 4 "#" "" "#" "Function Section" 70))) + (should (string-prefix-p " #" result)) + (should (string-match-p "Function Section" result)))) + +;;; C Tests (C-style comments) + +(ert-deftest test-comment-box-c-block-comments () + "Should generate box with C block comment syntax." + (let ((result (test-comment-box-at-column 0 "/*" "*/" "-" "Section" 70))) + (should (= 3 (length (split-string result "\n" t)))) + (should (string-match-p "^/\\* -" result)) + (should (string-match-p "Section" result)) + ;; Should include comment-end + (should (string-match-p "\\*/" result)))) + +(provide 'test-custom-comments-comment-box) +;;; test-custom-comments-comment-box.el ends here diff --git a/tests/test-custom-comments-comment-heavy-box.el b/tests/test-custom-comments-comment-heavy-box.el new file mode 100644 index 00000000..30289625 --- /dev/null +++ b/tests/test-custom-comments-comment-heavy-box.el @@ -0,0 +1,251 @@ +;;; test-custom-comments-comment-heavy-box.el --- Tests for cj/comment-heavy-box -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/comment-heavy-box function from custom-comments.el +;; +;; This function generates a 5-line heavy box comment: +;; - Top border: comment-start + full decoration line +;; - Empty line: decoration char + spaces + decoration char +;; - Centered text: decoration char + spaces + text + spaces + decoration char +;; - Empty line: decoration char + spaces + decoration char +;; - Bottom border: comment-start + full decoration line +;; +;; The text is centered within the box with padding on both sides. +;; +;; We test the NON-INTERACTIVE implementation (cj/--comment-heavy-box) +;; to avoid mocking user prompts. This follows our testing best practice +;; of separating business logic from UI interaction. +;; +;; Cross-Language Testing Strategy: +;; - Comprehensive testing in Emacs Lisp (our primary language) +;; - Representative testing in Python and C (hash-based and C-style comments) +;; - Function handles comment syntax generically, so testing 3 syntaxes +;; proves cross-language compatibility +;; - See test-custom-comments-delete-buffer-comments.el for detailed rationale + +;;; 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-comments) + +;;; Test Helpers + +(defun test-heavy-box-at-column (column-pos comment-start comment-end decoration-char text length) + "Test cj/--comment-heavy-box at COLUMN-POS indentation. +Insert spaces to reach COLUMN-POS, then call cj/--comment-heavy-box with +COMMENT-START, COMMENT-END, DECORATION-CHAR, TEXT, and LENGTH. +Returns the buffer string for assertions." + (with-temp-buffer + (when (> column-pos 0) + (insert (make-string column-pos ?\s))) + (cj/--comment-heavy-box comment-start comment-end decoration-char text length) + (buffer-string))) + +;;; Emacs Lisp Tests (Primary Language - Comprehensive Coverage) + +;;; Normal Cases + +(ert-deftest test-heavy-box-elisp-basic () + "Should generate 5-line heavy box in emacs-lisp style." + (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "Section Header" 70))) + ;; Should have 5 lines + (should (= 5 (length (split-string result "\n" t)))) + ;; First line should start with ;; and have decoration + (should (string-match-p "^;; \\*" result)) + ;; Middle line should contain centered text + (should (string-match-p "Section Header" result)) + ;; Should have side borders + (should (string-match-p "^\\*.*\\*$" result)))) + +(ert-deftest test-heavy-box-elisp-custom-decoration () + "Should use custom decoration character." + (let ((result (test-heavy-box-at-column 0 ";;" "" "#" "Header" 70))) + (should (string-match-p ";; #" result)) + (should-not (string-match-p "\\*" result)))) + +(ert-deftest test-heavy-box-elisp-custom-text () + "Should include custom text centered in box." + (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "Custom Text Here" 70))) + (should (string-match-p "Custom Text Here" result)))) + +(ert-deftest test-heavy-box-elisp-empty-text () + "Should handle empty text string." + (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "" 70))) + ;; Should still generate 5 lines + (should (= 5 (length (split-string result "\n" t)))) + ;; Middle line should just have side borders and spaces + (should (string-match-p "^\\*.*\\*$" result)))) + +(ert-deftest test-heavy-box-elisp-at-column-0 () + "Should work at column 0." + (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "Header" 70))) + ;; First character should be semicolon + (should (string-prefix-p ";;" result)))) + +(ert-deftest test-heavy-box-elisp-indented () + "Should work when indented." + (let ((result (test-heavy-box-at-column 4 ";;" "" "*" "Header" 70))) + ;; First line should start with spaces + (should (string-prefix-p " ;;" result)) + ;; Other lines should be indented + (let ((lines (split-string result "\n" t))) + (should (string-prefix-p " " (nth 1 lines))) + (should (string-prefix-p " " (nth 2 lines)))))) + +(ert-deftest test-heavy-box-elisp-short-text () + "Should center short text properly." + (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "X" 70))) + ;; Should have 5 lines + (should (= 5 (length (split-string result "\n" t)))) + ;; Text should be present and centered + (should (string-match-p "\\* .* X .* \\*" result)))) + +(ert-deftest test-heavy-box-elisp-long-text () + "Should handle longer text." + (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "This is a longer header text" 70))) + ;; Should have 5 lines + (should (= 5 (length (split-string result "\n" t)))) + ;; Text should be present + (should (string-match-p "This is a longer header text" result)))) + +;;; Boundary Cases + +(ert-deftest test-heavy-box-elisp-minimum-length () + "Should work with minimum viable length." + ;; Minimum for a box: comment + spaces + borders + minimal content + (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "X" 15))) + (should (= 5 (length (split-string result "\n" t)))) + (should (string-match-p "X" result)))) + +(ert-deftest test-heavy-box-elisp-very-long-length () + "Should handle very long length." + (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "Header" 200))) + (should (= 5 (length (split-string result "\n" t)))) + ;; Border lines should be very long + (let ((first-line (car (split-string result "\n" t)))) + (should (> (length first-line) 100))))) + +(ert-deftest test-heavy-box-elisp-unicode-decoration () + "Should handle unicode decoration character." + (let ((result (test-heavy-box-at-column 0 ";;" "" "═" "Header" 70))) + (should (string-match-p "═" result)))) + +(ert-deftest test-heavy-box-elisp-unicode-text () + "Should handle unicode in text." + (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "Hello 👋 مرحبا café" 70))) + (should (string-match-p "👋" result)) + (should (string-match-p "مرحبا" result)) + (should (string-match-p "café" result)))) + +(ert-deftest test-heavy-box-elisp-very-long-text () + "Should handle very long text." + (let* ((long-text (make-string 100 ?x)) + (result (test-heavy-box-at-column 0 ";;" "" "*" long-text 70))) + ;; Should still generate output + (should (= 5 (length (split-string result "\n" t)))) + ;; Middle line should contain some of the text + (should (string-match-p "xxx" result)))) + +(ert-deftest test-heavy-box-elisp-comment-end-empty () + "Should handle empty comment-end by using symmetric comment syntax." + (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "Header" 70))) + (should (= 5 (length (split-string result "\n" t)))) + ;; When comment-end is empty, function uses comment-char for symmetry + ;; So border lines will have ";; ... ;;" for visual balance + (should (string-match-p ";;.*;;$" result)))) + +(ert-deftest test-heavy-box-elisp-max-indentation () + "Should handle maximum practical indentation." + (let ((result (test-heavy-box-at-column 60 ";;" "" "*" "Header" 100))) + (should (= 5 (length (split-string result "\n" t)))) + ;; First line should start with 60 spaces + (should (string-prefix-p (make-string 60 ?\s) result)))) + +(ert-deftest test-heavy-box-elisp-text-centering-even () + "Should center text properly with even length." + (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "EVEN" 70))) + ;; Text should be centered (roughly equal padding on both sides) + (should (string-match-p "\\* .* EVEN .* \\*" result)))) + +(ert-deftest test-heavy-box-elisp-text-centering-odd () + "Should center text properly with odd length." + (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "ODD" 70))) + ;; Text should be centered (roughly equal padding on both sides) + (should (string-match-p "\\* .* ODD .* \\*" result)))) + +;;; Error Cases + +(ert-deftest test-heavy-box-elisp-length-too-small () + "Should error when length is too small." + (should-error + (test-heavy-box-at-column 0 ";;" "" "*" "Header" 5) + :type 'error)) + +(ert-deftest test-heavy-box-elisp-negative-length () + "Should error with negative length." + (should-error + (test-heavy-box-at-column 0 ";;" "" "*" "Header" -10) + :type 'error)) + +(ert-deftest test-heavy-box-elisp-zero-length () + "Should error with zero length." + (should-error + (test-heavy-box-at-column 0 ";;" "" "*" "Header" 0) + :type 'error)) + +(ert-deftest test-heavy-box-elisp-nil-decoration () + "Should error when decoration-char is nil." + (should-error + (test-heavy-box-at-column 0 ";;" "" nil "Header" 70) + :type 'wrong-type-argument)) + +(ert-deftest test-heavy-box-elisp-nil-text () + "Should error when text is nil." + (should-error + (test-heavy-box-at-column 0 ";;" "" "*" nil 70) + :type 'wrong-type-argument)) + +(ert-deftest test-heavy-box-elisp-non-integer-length () + "Should error when length is not an integer." + (should-error + (test-heavy-box-at-column 0 ";;" "" "*" "Header" "not-a-number") + :type 'wrong-type-argument)) + +;;; Python Tests (Hash-based comments) + +(ert-deftest test-heavy-box-python-basic () + "Should generate heavy box with Python comment syntax." + (let ((result (test-heavy-box-at-column 0 "#" "" "*" "Section" 70))) + (should (= 5 (length (split-string result "\n" t)))) + (should (string-match-p "^# \\*" result)) + (should (string-match-p "Section" result)))) + +(ert-deftest test-heavy-box-python-indented () + "Should handle indented Python comments." + (let ((result (test-heavy-box-at-column 4 "#" "" "#" "Function Section" 70))) + (should (string-prefix-p " #" result)) + (should (string-match-p "Function Section" result)))) + +;;; C Tests (C-style comments) + +(ert-deftest test-heavy-box-c-block-comments () + "Should generate heavy box with C block comment syntax." + (let ((result (test-heavy-box-at-column 0 "/*" "*/" "*" "Section" 70))) + (should (= 5 (length (split-string result "\n" t)))) + (should (string-match-p "^/\\* \\*" result)) + (should (string-match-p "Section" result)) + ;; Should include comment-end + (should (string-match-p "\\*/" result)))) + +(provide 'test-custom-comments-comment-heavy-box) +;;; test-custom-comments-comment-heavy-box.el ends here diff --git a/tests/test-custom-comments-comment-inline-border.el b/tests/test-custom-comments-comment-inline-border.el new file mode 100644 index 00000000..ca2bef06 --- /dev/null +++ b/tests/test-custom-comments-comment-inline-border.el @@ -0,0 +1,235 @@ +;;; test-custom-comments-comment-inline-border.el --- Tests for cj/comment-inline-border -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/comment-inline-border function from custom-comments.el +;; +;; This function generates a single-line centered comment with decoration borders: +;; Format: comment-start + decoration + space + text + space + decoration + comment-end +;; Example: ";; ======= Section Header =======" +;; +;; The text is centered with decoration characters on both sides. When text has +;; odd length, the right side gets one less decoration character. +;; +;; We test the NON-INTERACTIVE implementation (cj/--comment-inline-border) +;; to avoid mocking user prompts. This follows our testing best practice +;; of separating business logic from UI interaction. +;; +;; Cross-Language Testing Strategy: +;; - Comprehensive testing in Emacs Lisp (our primary language) +;; - Representative testing in Python and C (hash-based and C-style comments) +;; - Function handles comment syntax generically, so testing 3 syntaxes +;; proves cross-language compatibility +;; - See test-custom-comments-delete-buffer-comments.el for detailed rationale + +;;; 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-comments) + +;;; Test Helpers + +(defun test-inline-border-at-column (column-pos comment-start comment-end decoration-char text length) + "Test cj/--comment-inline-border at COLUMN-POS indentation. +Insert spaces to reach COLUMN-POS, then call cj/--comment-inline-border with +COMMENT-START, COMMENT-END, DECORATION-CHAR, TEXT, and LENGTH. +Returns the buffer string for assertions." + (with-temp-buffer + (when (> column-pos 0) + (insert (make-string column-pos ?\s))) + (cj/--comment-inline-border comment-start comment-end decoration-char text length) + (buffer-string))) + +;;; Emacs Lisp Tests (Primary Language - Comprehensive Coverage) + +;;; Normal Cases + +(ert-deftest test-inline-border-elisp-basic () + "Should generate single-line centered comment in emacs-lisp style." + (let ((result (test-inline-border-at-column 0 ";;" "" "=" "Section Header" 70))) + ;; Should be single line + (should (= 1 (length (split-string result "\n" t)))) + ;; Should start with ;; + (should (string-match-p "^;; =" result)) + ;; Should contain text + (should (string-match-p "Section Header" result)) + ;; Should have decoration on both sides + (should (string-match-p "= Section Header =" result)))) + +(ert-deftest test-inline-border-elisp-custom-decoration () + "Should use custom decoration character." + (let ((result (test-inline-border-at-column 0 ";;" "" "#" "Header" 70))) + (should (string-match-p ";; #" result)) + (should (string-match-p "# Header #" result)) + (should-not (string-match-p "=" result)))) + +(ert-deftest test-inline-border-elisp-custom-text () + "Should include custom text centered." + (let ((result (test-inline-border-at-column 0 ";;" "" "=" "Custom Text Here" 70))) + (should (string-match-p "Custom Text Here" result)))) + +(ert-deftest test-inline-border-elisp-empty-text () + "Should handle empty text string." + (let ((result (test-inline-border-at-column 0 ";;" "" "=" "" 70))) + ;; Should still generate output with decoration + (should (string-match-p ";; =" result)) + ;; Should not have extra spaces where text would be + (should-not (string-match-p " " result)))) + +(ert-deftest test-inline-border-elisp-at-column-0 () + "Should work at column 0." + (let ((result (test-inline-border-at-column 0 ";;" "" "=" "Header" 70))) + ;; First character should be semicolon + (should (string-prefix-p ";;" result)))) + +(ert-deftest test-inline-border-elisp-indented () + "Should work when indented." + (let ((result (test-inline-border-at-column 4 ";;" "" "=" "Header" 70))) + ;; Result should start with spaces + (should (string-prefix-p " ;;" result)))) + +(ert-deftest test-inline-border-elisp-short-text () + "Should center short text properly." + (let ((result (test-inline-border-at-column 0 ";;" "" "=" "X" 70))) + (should (string-match-p "X" result)) + ;; Should have decoration on both sides + (should (string-match-p "= X =" result)))) + +(ert-deftest test-inline-border-elisp-custom-length () + "Should respect custom length." + (let ((result (test-inline-border-at-column 0 ";;" "" "=" "Header" 50))) + ;; Line should be approximately 50 chars + (let ((line (car (split-string result "\n" t)))) + (should (<= (length line) 51)) + (should (>= (length line) 48))))) + +;;; Boundary Cases + +(ert-deftest test-inline-border-elisp-minimum-length () + "Should work with minimum viable length." + ;; Minimum: 2 (;;) + 1 (space) + 1 (space) + 2 (min decoration each side) = 6 + (let ((result (test-inline-border-at-column 0 ";;" "" "=" "" 10))) + (should (string-match-p ";" result)))) + +(ert-deftest test-inline-border-elisp-text-centering-even () + "Should center text properly with even length." + (let ((result (test-inline-border-at-column 0 ";;" "" "=" "EVEN" 70))) + ;; Text should be centered with roughly equal decoration + (should (string-match-p "= EVEN =" result)))) + +(ert-deftest test-inline-border-elisp-text-centering-odd () + "Should center text properly with odd length." + (let ((result (test-inline-border-at-column 0 ";;" "" "=" "ODD" 70))) + ;; Text should be centered (right side has one less due to odd length) + (should (string-match-p "= ODD =" result)))) + +(ert-deftest test-inline-border-elisp-very-long-text () + "Should handle text that fills most of the line." + (let* ((long-text (make-string 50 ?x)) + (result (test-inline-border-at-column 0 ";;" "" "=" long-text 70))) + ;; Should still have decoration + (should (string-match-p "=" result)) + ;; Text should be present + (should (string-match-p "xxx" result)))) + +(ert-deftest test-inline-border-elisp-unicode-decoration () + "Should handle unicode decoration character." + (let ((result (test-inline-border-at-column 0 ";;" "" "─" "Header" 70))) + (should (string-match-p "─" result)))) + +(ert-deftest test-inline-border-elisp-unicode-text () + "Should handle unicode in text." + (let ((result (test-inline-border-at-column 0 ";;" "" "=" "Hello 👋 café" 70))) + (should (string-match-p "👋" result)) + (should (string-match-p "café" result)))) + +(ert-deftest test-inline-border-elisp-comment-end-empty () + "Should handle empty comment-end correctly." + (let ((result (test-inline-border-at-column 0 ";;" "" "=" "Header" 70))) + ;; Line should not have trailing comment-end + (should-not (string-match-p ";;$" result)))) + +(ert-deftest test-inline-border-elisp-max-indentation () + "Should handle maximum practical indentation." + (let ((result (test-inline-border-at-column 60 ";;" "" "=" "H" 100))) + (should (string-prefix-p (make-string 60 ?\s) result)))) + +(ert-deftest test-inline-border-elisp-minimum-decoration-each-side () + "Should have at least 2 decoration chars on each side." + (let ((result (test-inline-border-at-column 0 ";;" "" "=" "Test" 20))) + ;; Should have at least == on each side + (should (string-match-p "== Test ==" result)))) + +;;; Error Cases + +(ert-deftest test-inline-border-elisp-length-too-small () + "Should error when length is too small for text." + (should-error + (test-inline-border-at-column 0 ";;" "" "=" "Very Long Header Text" 20) + :type 'error)) + +(ert-deftest test-inline-border-elisp-negative-length () + "Should error with negative length." + (should-error + (test-inline-border-at-column 0 ";;" "" "=" "Header" -10) + :type 'error)) + +(ert-deftest test-inline-border-elisp-zero-length () + "Should error with zero length." + (should-error + (test-inline-border-at-column 0 ";;" "" "=" "Header" 0) + :type 'error)) + +(ert-deftest test-inline-border-elisp-nil-decoration () + "Should error when decoration-char is nil." + (should-error + (test-inline-border-at-column 0 ";;" "" nil "Header" 70) + :type 'wrong-type-argument)) + +(ert-deftest test-inline-border-elisp-non-integer-length () + "Should error when length is not an integer." + (should-error + (test-inline-border-at-column 0 ";;" "" "=" "Header" "not-a-number") + :type 'wrong-type-argument)) + +;;; Python Tests (Hash-based comments) + +(ert-deftest test-inline-border-python-basic () + "Should generate inline border with Python comment syntax." + (let ((result (test-inline-border-at-column 0 "#" "" "=" "Section" 70))) + (should (string-match-p "^# =" result)) + (should (string-match-p "Section" result)))) + +(ert-deftest test-inline-border-python-indented () + "Should handle indented Python comments." + (let ((result (test-inline-border-at-column 4 "#" "" "-" "Function Section" 70))) + (should (string-prefix-p " #" result)) + (should (string-match-p "Function Section" result)))) + +;;; C Tests (C-style comments) + +(ert-deftest test-inline-border-c-block-comments () + "Should generate inline border with C block comment syntax." + (let ((result (test-inline-border-at-column 0 "/*" "*/" "=" "Section" 70))) + (should (string-match-p "^/\\* =" result)) + (should (string-match-p "Section" result)) + ;; Should include comment-end + (should (string-match-p "\\*/$" result)))) + +(ert-deftest test-inline-border-c-line-comments () + "Should generate inline border with C line comment syntax." + (let ((result (test-inline-border-at-column 0 "//" "" "-" "Header" 70))) + (should (string-match-p "^// -" result)) + (should (string-match-p "Header" result)))) + +(provide 'test-custom-comments-comment-inline-border) +;;; test-custom-comments-comment-inline-border.el ends here diff --git a/tests/test-custom-comments-comment-padded-divider.el b/tests/test-custom-comments-comment-padded-divider.el new file mode 100644 index 00000000..702a4c67 --- /dev/null +++ b/tests/test-custom-comments-comment-padded-divider.el @@ -0,0 +1,250 @@ +;;; test-custom-comments-comment-padded-divider.el --- Tests for cj/comment-padded-divider -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/comment-padded-divider function from custom-comments.el +;; +;; This function generates a padded 3-line comment divider banner: +;; - Top line: comment-start + decoration chars +;; - Middle line: comment-start + padding spaces + text +;; - Bottom line: comment-start + decoration chars +;; +;; The key difference from simple-divider is the PADDING parameter which +;; adds spaces before the text to create visual indentation. +;; +;; We test the NON-INTERACTIVE implementation (cj/--comment-padded-divider) +;; to avoid mocking user prompts. This follows our testing best practice +;; of separating business logic from UI interaction. +;; +;; Cross-Language Testing Strategy: +;; - Comprehensive testing in Emacs Lisp (our primary language) +;; - Representative testing in Python and C (hash-based and C-style comments) +;; - Function handles comment syntax generically, so testing 3 syntaxes +;; proves cross-language compatibility +;; - See test-custom-comments-delete-buffer-comments.el for detailed rationale + +;;; 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-comments) + +;;; Test Helpers + +(defun test-padded-divider-at-column (column-pos comment-start comment-end decoration-char text length padding) + "Test cj/--comment-padded-divider at COLUMN-POS indentation. +Insert spaces to reach COLUMN-POS, then call cj/--comment-padded-divider with +COMMENT-START, COMMENT-END, DECORATION-CHAR, TEXT, LENGTH, and PADDING. +Returns the buffer string for assertions." + (with-temp-buffer + (when (> column-pos 0) + (insert (make-string column-pos ?\s))) + (cj/--comment-padded-divider comment-start comment-end decoration-char text length padding) + (buffer-string))) + +;;; Emacs Lisp Tests (Primary Language - Comprehensive Coverage) + +;;; Normal Cases + +(ert-deftest test-padded-divider-elisp-basic () + "Should generate padded 3-line divider in emacs-lisp style." + (let ((result (test-padded-divider-at-column 0 ";;" "" "=" "Section Header" 70 2))) + ;; Should have 3 lines + (should (= 3 (length (split-string result "\n" t)))) + ;; First line should start with ;; and have decoration + (should (string-match-p "^;; =" result)) + ;; Middle line should contain text with padding + (should (string-match-p ";; Section Header" result)))) + +(ert-deftest test-padded-divider-elisp-custom-padding () + "Should respect custom padding value." + (let ((result (test-padded-divider-at-column 0 ";;" "" "=" "Header" 70 4))) + ;; Middle line should have 4 spaces before text + (should (string-match-p ";; Header" result)))) + +(ert-deftest test-padded-divider-elisp-zero-padding () + "Should work with zero padding." + (let ((result (test-padded-divider-at-column 0 ";;" "" "-" "Header" 70 0))) + ;; Middle line should have text immediately after comment-start + space + (should (string-match-p "^;; Header$" result)))) + +(ert-deftest test-padded-divider-elisp-large-padding () + "Should work with large padding value." + (let ((result (test-padded-divider-at-column 0 ";;" "" "=" "Text" 70 10))) + ;; Middle line should have 10 spaces before text + (should (string-match-p ";; Text" result)))) + +(ert-deftest test-padded-divider-elisp-custom-decoration () + "Should use custom decoration character." + (let ((result (test-padded-divider-at-column 0 ";;" "" "*" "Header" 70 2))) + (should (string-match-p ";; \\*" result)) + (should-not (string-match-p ";; =" result)))) + +(ert-deftest test-padded-divider-elisp-custom-text () + "Should include custom text in middle line." + (let ((result (test-padded-divider-at-column 0 ";;" "" "=" "Custom Text Here" 70 2))) + (should (string-match-p "Custom Text Here" result)))) + +(ert-deftest test-padded-divider-elisp-empty-text () + "Should handle empty text string." + (let ((result (test-padded-divider-at-column 0 ";;" "" "-" "" 70 2))) + ;; Should still generate 3 lines + (should (= 3 (length (split-string result "\n" t)))) + ;; Middle line should just be comment-start + padding + (should (string-match-p "^;; *\n" result)))) + +(ert-deftest test-padded-divider-elisp-at-column-0 () + "Should work at column 0." + (let ((result (test-padded-divider-at-column 0 ";;" "" "=" "Header" 70 2))) + ;; First character should be semicolon + (should (string-prefix-p ";;" result)))) + +(ert-deftest test-padded-divider-elisp-indented () + "Should work when indented." + (let ((result (test-padded-divider-at-column 4 ";;" "" "=" "Header" 70 2))) + ;; Result should start with spaces + (should (string-prefix-p " ;;" result)) + ;; All lines should be indented + (dolist (line (split-string result "\n" t)) + (should (string-prefix-p " ;;" line))))) + +;;; Boundary Cases + +(ert-deftest test-padded-divider-elisp-minimum-length () + "Should work with minimum viable length at column 0." + ;; Minimum: 2 (;;) + 1 (space) + 1 (space) + 3 (dashes) = 7 + (let ((result (test-padded-divider-at-column 0 ";;" "" "-" "" 7 0))) + (should (= 3 (length (split-string result "\n" t)))))) + +(ert-deftest test-padded-divider-elisp-very-long-length () + "Should handle very long length." + (let ((result (test-padded-divider-at-column 0 ";;" "" "=" "Header" 200 2))) + (should (= 3 (length (split-string result "\n" t)))) + ;; Decoration lines should be very long + (let ((first-line (car (split-string result "\n" t)))) + (should (> (length first-line) 100))))) + +(ert-deftest test-padded-divider-elisp-padding-larger-than-length () + "Should handle padding that exceeds reasonable bounds." + ;; This tests behavior when padding is very large relative to length + (let ((result (test-padded-divider-at-column 0 ";;" "" "=" "X" 70 50))) + ;; Should still generate output (text may extend beyond decoration) + (should (= 3 (length (split-string result "\n" t)))) + (should (string-match-p "X" result)))) + +(ert-deftest test-padded-divider-elisp-unicode-decoration () + "Should handle unicode decoration character." + (let ((result (test-padded-divider-at-column 0 ";;" "" "─" "Header" 70 2))) + (should (string-match-p "─" result)))) + +(ert-deftest test-padded-divider-elisp-unicode-text () + "Should handle unicode in text." + (let ((result (test-padded-divider-at-column 0 ";;" "" "=" "Hello 👋 مرحبا café" 70 2))) + (should (string-match-p "👋" result)) + (should (string-match-p "مرحبا" result)) + (should (string-match-p "café" result)))) + +(ert-deftest test-padded-divider-elisp-very-long-text () + "Should handle very long text." + (let* ((long-text (make-string 100 ?x)) + (result (test-padded-divider-at-column 0 ";;" "" "=" long-text 70 2))) + ;; Should still generate output + (should (= 3 (length (split-string result "\n" t)))) + ;; Middle line should contain some of the text + (should (string-match-p "xxx" result)))) + +(ert-deftest test-padded-divider-elisp-comment-end-empty () + "Should handle empty comment-end correctly." + (let ((result (test-padded-divider-at-column 0 ";;" "" "=" "Header" 70 2))) + (should (= 3 (length (split-string result "\n" t)))) + ;; Lines should not have trailing comment-end + (should-not (string-match-p ";;.*;;$" result)))) + +(ert-deftest test-padded-divider-elisp-max-indentation () + "Should handle maximum practical indentation." + (let ((result (test-padded-divider-at-column 60 ";;" "" "=" "Header" 100 2))) + (should (= 3 (length (split-string result "\n" t)))) + ;; All lines should start with 60 spaces + (dolist (line (split-string result "\n" t)) + (should (string-prefix-p (make-string 60 ?\s) line))))) + +;;; Error Cases + +(ert-deftest test-padded-divider-elisp-negative-padding () + "Should error with negative padding." + (should-error + (test-padded-divider-at-column 0 ";;" "" "=" "Header" 70 -5) + :type 'error)) + +(ert-deftest test-padded-divider-elisp-negative-length () + "Should error with negative length." + (should-error + (test-padded-divider-at-column 0 ";;" "" "=" "Header" -10 2) + :type 'error)) + +(ert-deftest test-padded-divider-elisp-zero-length () + "Should error with zero length." + (should-error + (test-padded-divider-at-column 0 ";;" "" "=" "Header" 0 2) + :type 'error)) + +(ert-deftest test-padded-divider-elisp-nil-decoration () + "Should error when decoration-char is nil." + (should-error + (test-padded-divider-at-column 0 ";;" "" nil "Header" 70 2) + :type 'wrong-type-argument)) + +(ert-deftest test-padded-divider-elisp-nil-text () + "Should error when text is nil." + (should-error + (test-padded-divider-at-column 0 ";;" "" "=" nil 70 2) + :type 'wrong-type-argument)) + +(ert-deftest test-padded-divider-elisp-non-integer-length () + "Should error when length is not an integer." + (should-error + (test-padded-divider-at-column 0 ";;" "" "=" "Header" "not-a-number" 2) + :type 'wrong-type-argument)) + +(ert-deftest test-padded-divider-elisp-non-integer-padding () + "Should error when padding is not an integer." + (should-error + (test-padded-divider-at-column 0 ";;" "" "=" "Header" 70 "not-a-number") + :type 'wrong-type-argument)) + +;;; Python Tests (Hash-based comments) + +(ert-deftest test-padded-divider-python-basic () + "Should generate padded divider with Python comment syntax." + (let ((result (test-padded-divider-at-column 0 "#" "" "=" "Section" 70 2))) + (should (= 3 (length (split-string result "\n" t)))) + (should (string-match-p "^# =" result)) + (should (string-match-p "# Section" result)))) + +(ert-deftest test-padded-divider-python-indented () + "Should handle indented Python comments with padding." + (let ((result (test-padded-divider-at-column 4 "#" "" "-" "Function Section" 70 4))) + (should (string-prefix-p " #" result)) + (should (string-match-p "Function Section" result)))) + +;;; C Tests (C-style comments) + +(ert-deftest test-padded-divider-c-block-comments () + "Should generate padded divider with C block comment syntax." + (let ((result (test-padded-divider-at-column 0 "/*" "*/" "=" "Section" 70 2))) + (should (= 3 (length (split-string result "\n" t)))) + (should (string-match-p "^/\\* =" result)) + (should (string-match-p "/\\* Section" result)) + ;; Should include comment-end + (should (string-match-p "\\*/" result)))) + +(provide 'test-custom-comments-comment-padded-divider) +;;; test-custom-comments-comment-padded-divider.el ends here diff --git a/tests/test-custom-comments-comment-reformat.el b/tests/test-custom-comments-comment-reformat.el new file mode 100644 index 00000000..83248aee --- /dev/null +++ b/tests/test-custom-comments-comment-reformat.el @@ -0,0 +1,191 @@ +;;; test-custom-comments-comment-reformat.el --- Tests for cj/comment-reformat -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/comment-reformat function from custom-comments.el +;; +;; This function reformats multi-line comments into a single paragraph by: +;; 1. Uncommenting the selected region +;; 2. Joining lines together (via cj/join-line-or-region) +;; 3. Re-commenting the result +;; 4. Temporarily reducing fill-column by 3 during the join operation +;; +;; Dependencies: +;; - Requires cj/join-line-or-region from custom-line-paragraph.el +;; - We load the REAL module to test actual integration behavior +;; - This follows our "test production code" guideline +;; - If join-line-or-region has bugs, our tests will catch integration issues +;; +;; Cross-Language Testing Strategy: +;; - Comprehensive testing in Emacs Lisp (12 tests) +;; - Representative testing in Python and C (1 test each) +;; - Function delegates to uncomment-region/comment-region, so we test OUR logic +;; - See test-custom-comments-delete-buffer-comments.el for detailed rationale + +;;; 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.") + +;; Load the real custom-line-paragraph module (for cj/join-line-or-region) +(require 'custom-line-paragraph) + +;; Now load the actual production module +(require 'custom-comments) + +;;; Test Helpers + +(defun test-comment-reformat-in-mode (mode content-before expected-after) + "Test comment reformatting in MODE. +Insert CONTENT-BEFORE, select all, run cj/comment-reformat, verify EXPECTED-AFTER." + (with-temp-buffer + (transient-mark-mode 1) ; Enable transient-mark-mode for batch testing + (funcall mode) + (insert content-before) + (mark-whole-buffer) + (activate-mark) ; Explicitly activate the mark + (cj/comment-reformat) + (should (equal (string-trim (buffer-string)) (string-trim expected-after))))) + +;;; Emacs Lisp Tests (Primary Language - Comprehensive Coverage) + +(ert-deftest test-comment-reformat-elisp-simple-multiline () + "Should join multiple commented lines into one." + (test-comment-reformat-in-mode + 'emacs-lisp-mode + ";; Line one\n;; Line two\n;; Line three" + ";; Line one Line two Line three")) + +(ert-deftest test-comment-reformat-elisp-preserves-content () + "Should preserve text content after reformat." + (test-comment-reformat-in-mode + 'emacs-lisp-mode + ";; Hello world\n;; from Emacs" + ";; Hello world from Emacs")) + +(ert-deftest test-comment-reformat-elisp-restores-fill-column () + "Should restore fill-column after operation." + (with-temp-buffer + (transient-mark-mode 1) + (emacs-lisp-mode) + (let ((original-fill-column fill-column)) + (insert ";; Line one\n;; Line two") + (mark-whole-buffer) + (activate-mark) + (cj/comment-reformat) + (should (= fill-column original-fill-column))))) + +(ert-deftest test-comment-reformat-elisp-single-line () + "Should handle single commented line." + (test-comment-reformat-in-mode + 'emacs-lisp-mode + ";; Single line comment" + ";; Single line comment")) + +(ert-deftest test-comment-reformat-elisp-empty-region () + "Should error when trying to comment empty buffer." + (with-temp-buffer + (transient-mark-mode 1) + (emacs-lisp-mode) + (mark-whole-buffer) + (activate-mark) + (should-error (cj/comment-reformat)))) + +(ert-deftest test-comment-reformat-elisp-whitespace-in-comments () + "Should handle comments with only whitespace." + (test-comment-reformat-in-mode + 'emacs-lisp-mode + ";; \n;; \n;; text" + ";; text")) + +(ert-deftest test-comment-reformat-elisp-unicode () + "Should handle unicode in comments." + (test-comment-reformat-in-mode + 'emacs-lisp-mode + ";; Hello 👋\n;; مرحبا café" + ";; Hello 👋 مرحبا café")) + +(ert-deftest test-comment-reformat-elisp-long-text () + "Should handle many lines of comments." + (test-comment-reformat-in-mode + 'emacs-lisp-mode + ";; Line 1\n;; Line 2\n;; Line 3\n;; Line 4\n;; Line 5" + ";; Line 1 Line 2 Line 3 Line 4 Line 5")) + +(ert-deftest test-comment-reformat-elisp-indented-comments () + "Should handle indented comments." + (with-temp-buffer + (transient-mark-mode 1) + (emacs-lisp-mode) + (insert " ;; Indented line 1\n ;; Indented line 2") + (mark-whole-buffer) + (activate-mark) + (cj/comment-reformat) + ;; After reformatting, should still be commented + (should (string-match-p ";;" (buffer-string))) + ;; Content should be joined + (should (string-match-p "line 1.*line 2" (buffer-string))))) + +(ert-deftest test-comment-reformat-elisp-region-at-buffer-start () + "Should handle region at buffer start." + (with-temp-buffer + (transient-mark-mode 1) + (emacs-lisp-mode) + (insert ";; Start line 1\n;; Start line 2\n(setq x 1)") + (goto-char (point-min)) + (set-mark (point)) + (forward-line 2) + (activate-mark) + (cj/comment-reformat) + (should (string-match-p ";; Start line 1.*Start line 2" (buffer-string))))) + +(ert-deftest test-comment-reformat-elisp-no-region-active () + "Should show message when no region selected." + (with-temp-buffer + (emacs-lisp-mode) + (insert ";; Comment line") + (deactivate-mark) + (let ((message-log-max nil) + (messages '())) + ;; Capture messages + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (cj/comment-reformat) + (should (string-match-p "No region was selected" (car messages))))))) + +(ert-deftest test-comment-reformat-elisp-read-only-buffer () + "Should signal error in read-only buffer." + (with-temp-buffer + (emacs-lisp-mode) + (insert ";; Line 1\n;; Line 2") + (mark-whole-buffer) + (read-only-mode 1) + (should-error (cj/comment-reformat)))) + +;;; Python Tests (Hash-based comments) + +(ert-deftest test-comment-reformat-python-simple () + "Should join Python hash comments." + (test-comment-reformat-in-mode + 'python-mode + "# Line one\n# Line two" + "# Line one Line two")) + +;;; C Tests (C-style comments) + +(ert-deftest test-comment-reformat-c-line-comments () + "Should join C line comments (C-mode converts to block comments)." + (test-comment-reformat-in-mode + 'c-mode + "// Line one\n// Line two" + "/* Line one Line two */")) + +(provide 'test-custom-comments-comment-reformat) +;;; test-custom-comments-comment-reformat.el ends here diff --git a/tests/test-custom-comments-comment-simple-divider.el b/tests/test-custom-comments-comment-simple-divider.el new file mode 100644 index 00000000..a61e6b4c --- /dev/null +++ b/tests/test-custom-comments-comment-simple-divider.el @@ -0,0 +1,246 @@ +;;; test-custom-comments-comment-simple-divider.el --- Tests for cj/comment-simple-divider -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/comment-simple-divider function from custom-comments.el +;; +;; This function generates a simple 3-line comment divider banner: +;; - Top line: comment-start + decoration chars +;; - Middle line: comment-start + text +;; - Bottom line: comment-start + decoration chars +;; +;; We test the NON-INTERACTIVE implementation (cj/--comment-simple-divider) +;; to avoid mocking user prompts. This follows our testing best practice +;; of separating business logic from UI interaction. +;; +;; Cross-Language Testing Strategy: +;; - Comprehensive testing in Emacs Lisp (our primary language) +;; - Representative testing in Python and C (hash-based and C-style comments) +;; - Function handles comment syntax generically, so testing 3 syntaxes +;; proves cross-language compatibility +;; - See test-custom-comments-delete-buffer-comments.el for detailed rationale + +;;; 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-comments) + +;;; Test Helpers + +(defun test-simple-divider-at-column (column-pos comment-start comment-end decoration-char text length) + "Test cj/--comment-simple-divider at COLUMN-POS indentation. +Insert spaces to reach COLUMN-POS, then call cj/--comment-simple-divider with +COMMENT-START, COMMENT-END, DECORATION-CHAR, TEXT, and LENGTH. +Returns the buffer string for assertions." + (with-temp-buffer + (when (> column-pos 0) + (insert (make-string column-pos ?\s))) + (cj/--comment-simple-divider comment-start comment-end decoration-char text length) + (buffer-string))) + +;;; Emacs Lisp Tests (Primary Language - Comprehensive Coverage) + +;;; Normal Cases + +(ert-deftest test-simple-divider-elisp-basic () + "Should generate simple 3-line divider in emacs-lisp style." + (let ((result (test-simple-divider-at-column 0 ";;" "" "-" "Section Header" 70))) + ;; Should have 3 lines + (should (= 3 (length (split-string result "\n" t)))) + ;; Each line should start with ;; + (should (string-match-p "^;; -" result)) + ;; Middle line should contain text + (should (string-match-p ";; Section Header" result)))) + +(ert-deftest test-simple-divider-elisp-custom-decoration () + "Should use custom decoration character." + (let ((result (test-simple-divider-at-column 0 ";;" "" "=" "Header" 70))) + (should (string-match-p ";; =" result)) + (should-not (string-match-p ";; -" result)))) + +(ert-deftest test-simple-divider-elisp-custom-text () + "Should include custom text in middle line." + (let ((result (test-simple-divider-at-column 0 ";;" "" "-" "Custom Text Here" 70))) + (should (string-match-p ";; Custom Text Here" result)))) + +(ert-deftest test-simple-divider-elisp-custom-length () + "Should respect custom length." + (let* ((result (test-simple-divider-at-column 0 ";;" "" "-" "Header" 50)) + (lines (split-string result "\n" t))) + ;; Should have 3 lines + (should (= 3 (length lines))) + ;; First and last lines (decoration) should be approximately 50 chars + (should (<= (length (car lines)) 51)) + (should (>= (length (car lines)) 48)) + (should (<= (length (car (last lines))) 51)) + (should (>= (length (car (last lines))) 48)))) + +(ert-deftest test-simple-divider-elisp-empty-text () + "Should handle empty text string." + (let ((result (test-simple-divider-at-column 0 ";;" "" "-" "" 70))) + ;; Should still generate 3 lines + (should (= 3 (length (split-string result "\n" t)))) + ;; Middle line should just be comment-start + (should (string-match-p "^;; *\n" result)))) + +(ert-deftest test-simple-divider-elisp-at-column-0 () + "Should work at column 0." + (let ((result (test-simple-divider-at-column 0 ";;" "" "-""Header" 70))) + ;; First character should be semicolon + (should (string-prefix-p ";;" result)))) + +(ert-deftest test-simple-divider-elisp-indented () + "Should work when indented." + (let ((result (test-simple-divider-at-column 4 ";;" "" "-""Header" 70))) + ;; Result should start with spaces + (should (string-prefix-p " ;;" result)) + ;; All lines should be indented + (dolist (line (split-string result "\n" t)) + (should (string-prefix-p " ;;" line))))) + +;;; Boundary Cases + +(ert-deftest test-simple-divider-elisp-minimum-length () + "Should work with minimum viable length at column 0." + ;; Minimum length at column 0: 2 (;;) + 1 (space) + 1 (space) + 3 (dashes) = 7 + (let ((result (test-simple-divider-at-column 0 ";;" "" "-""" 7))) + (should (= 3 (length (split-string result "\n" t)))))) + +(ert-deftest test-simple-divider-elisp-minimum-length-indented () + "Should work with minimum viable length when indented." + ;; At column 4, minimum is 4 + 2 + 1 + 1 + 3 = 11 + (let ((result (test-simple-divider-at-column 4 ";;" "" "-""" 11))) + (should (= 3 (length (split-string result "\n" t)))))) + +(ert-deftest test-simple-divider-elisp-very-long-length () + "Should handle very long length." + (let ((result (test-simple-divider-at-column 0 ";;" "" "-""Header" 200))) + (should (= 3 (length (split-string result "\n" t)))) + ;; Decoration lines should be very long + (let ((first-line (car (split-string result "\n" t)))) + (should (> (length first-line) 100))))) + +(ert-deftest test-simple-divider-elisp-unicode-decoration () + "Should handle unicode decoration character." + (let ((result (test-simple-divider-at-column 0 ";;" "" "─""Header" 70))) + (should (string-match-p "─" result)))) + +(ert-deftest test-simple-divider-elisp-unicode-text () + "Should handle unicode in text." + (let ((result (test-simple-divider-at-column 0 ";;" "" "-""Hello 👋 مرحبا café" 70))) + (should (string-match-p "👋" result)) + (should (string-match-p "مرحبا" result)) + (should (string-match-p "café" result)))) + +(ert-deftest test-simple-divider-elisp-very-long-text () + "Should handle very long text (may wrap or truncate)." + (let* ((long-text (make-string 100 ?x)) + (result (test-simple-divider-at-column 0 ";;" "" "-"long-text 70))) + ;; Should still generate output (behavior may vary) + (should (= 3 (length (split-string result "\n" t)))) + ;; Middle line should contain some of the text + (should (string-match-p "xxx" result)))) + +(ert-deftest test-simple-divider-elisp-comment-end-empty () + "Should handle empty comment-end correctly." + (let ((result (test-simple-divider-at-column 0 ";;" "" "-""Header" 70))) + (should (= 3 (length (split-string result "\n" t)))) + ;; Lines should not have trailing comment-end + (should-not (string-match-p ";;.*;;$" result)))) + +(ert-deftest test-simple-divider-elisp-max-indentation () + "Should handle maximum practical indentation." + (let ((result (test-simple-divider-at-column 60 ";;" "" "-""Header" 100))) + (should (= 3 (length (split-string result "\n" t)))) + ;; All lines should start with 60 spaces + (dolist (line (split-string result "\n" t)) + (should (string-prefix-p (make-string 60 ?\s) line))))) + +;;; Error Cases + +(ert-deftest test-simple-divider-elisp-length-too-small-column-0 () + "Should error when length is too small at column 0." + (should-error + (test-simple-divider-at-column 0 ";;" "" "-" "Header" 5) + :type 'error)) + +(ert-deftest test-simple-divider-elisp-length-too-small-indented () + "Should error when length is too small for indentation level." + (should-error + (test-simple-divider-at-column 10 ";;" "" "-" "Header" 15) + :type 'error)) + +(ert-deftest test-simple-divider-elisp-negative-length () + "Should error with negative length." + (should-error + (test-simple-divider-at-column 0 ";;" "" "-" "Header" -10) + :type 'error)) + +(ert-deftest test-simple-divider-elisp-zero-length () + "Should error with zero length." + (should-error + (test-simple-divider-at-column 0 ";;" "" "-" "Header" 0) + :type 'error)) + +(ert-deftest test-simple-divider-elisp-nil-decoration () + "Should error when decoration-char is nil." + (should-error + (test-simple-divider-at-column 0 ";;" "" nil "Header" 70) + :type 'wrong-type-argument)) + +(ert-deftest test-simple-divider-elisp-nil-text () + "Should error when text is nil." + (should-error + (test-simple-divider-at-column 0 ";;" "" "-" nil 70) + :type 'wrong-type-argument)) + +(ert-deftest test-simple-divider-elisp-non-integer-length () + "Should error when length is not an integer." + (should-error + (test-simple-divider-at-column 0 ";;" "" "-""Header" "not-a-number") + :type 'wrong-type-argument)) + +;;; Python Tests (Hash-based comments) + +(ert-deftest test-simple-divider-python-basic () + "Should generate simple divider with Python comment syntax." + (let ((result (test-simple-divider-at-column 0 "#" "" "-""Section" 70))) + (should (= 3 (length (split-string result "\n" t)))) + (should (string-match-p "^# -" result)) + (should (string-match-p "# Section" result)))) + +(ert-deftest test-simple-divider-python-indented () + "Should handle indented Python comments." + (let ((result (test-simple-divider-at-column 4 "#" "" "=""Function Section" 70))) + (should (string-prefix-p " #" result)) + (should (string-match-p "Function Section" result)))) + +;;; C Tests (C-style comments) + +(ert-deftest test-simple-divider-c-block-comments () + "Should generate simple divider with C block comment syntax." + (let ((result (test-simple-divider-at-column 0 "/*" "*/" "-""Section" 70))) + (should (= 3 (length (split-string result "\n" t)))) + (should (string-match-p "^/\\* -" result)) + (should (string-match-p "/\\* Section" result)) + ;; Should include comment-end + (should (string-match-p "\\*/" result)))) + +(ert-deftest test-simple-divider-c-line-comments () + "Should generate simple divider with C line comment syntax." + (let ((result (test-simple-divider-at-column 0 "//" "" "=""Header" 70))) + (should (= 3 (length (split-string result "\n" t)))) + (should (string-match-p "^// =" result)) + (should (string-match-p "// Header" result)))) + +(provide 'test-custom-comments-comment-simple-divider) +;;; test-custom-comments-comment-simple-divider.el ends here diff --git a/tests/test-custom-comments-comment-unicode-box.el b/tests/test-custom-comments-comment-unicode-box.el new file mode 100644 index 00000000..f34329c8 --- /dev/null +++ b/tests/test-custom-comments-comment-unicode-box.el @@ -0,0 +1,264 @@ +;;; test-custom-comments-comment-unicode-box.el --- Tests for cj/comment-unicode-box -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/comment-unicode-box function from custom-comments.el +;; +;; This function generates a 3-line unicode box comment: +;; - Top line: comment-start + top-left corner + horizontal lines + top-right corner +;; - Text line: comment-start + vertical bar + text + vertical bar +;; - Bottom line: comment-start + bottom-left corner + horizontal lines + bottom-right corner +;; +;; Supports both 'single and 'double box styles with different unicode characters. +;; +;; We test the NON-INTERACTIVE implementation (cj/--comment-unicode-box) +;; to avoid mocking user prompts. This follows our testing best practice +;; of separating business logic from UI interaction. +;; +;; Cross-Language Testing Strategy: +;; - Comprehensive testing in Emacs Lisp (our primary language) +;; - Representative testing in Python and C (hash-based and C-style comments) +;; - Function handles comment syntax generically, so testing 3 syntaxes +;; proves cross-language compatibility +;; - See test-custom-comments-delete-buffer-comments.el for detailed rationale + +;;; 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-comments) + +;;; Test Helpers + +(defun test-unicode-box-at-column (column-pos comment-start comment-end text length box-style) + "Test cj/--comment-unicode-box at COLUMN-POS indentation. +Insert spaces to reach COLUMN-POS, then call cj/--comment-unicode-box with +COMMENT-START, COMMENT-END, TEXT, LENGTH, and BOX-STYLE. +Returns the buffer string for assertions." + (with-temp-buffer + (when (> column-pos 0) + (insert (make-string column-pos ?\s))) + (cj/--comment-unicode-box comment-start comment-end text length box-style) + (buffer-string))) + +;;; Emacs Lisp Tests (Primary Language - Comprehensive Coverage) + +;;; Normal Cases - Single Box Style + +(ert-deftest test-unicode-box-elisp-single-basic () + "Should generate 3-line single-line unicode box in emacs-lisp style." + (let ((result (test-unicode-box-at-column 0 ";;" "" "Section Header" 70 'single))) + ;; Should have 3 lines + (should (= 3 (length (split-string result "\n" t)))) + ;; Should have single-line box characters + (should (string-match-p "┌" result)) + (should (string-match-p "┐" result)) + (should (string-match-p "└" result)) + (should (string-match-p "┘" result)) + (should (string-match-p "─" result)) + (should (string-match-p "│" result)) + ;; Should contain text + (should (string-match-p "Section Header" result)))) + +(ert-deftest test-unicode-box-elisp-double-basic () + "Should generate 3-line double-line unicode box in emacs-lisp style." + (let ((result (test-unicode-box-at-column 0 ";;" "" "Section Header" 70 'double))) + ;; Should have 3 lines + (should (= 3 (length (split-string result "\n" t)))) + ;; Should have double-line box characters + (should (string-match-p "╔" result)) + (should (string-match-p "╗" result)) + (should (string-match-p "╚" result)) + (should (string-match-p "╝" result)) + (should (string-match-p "═" result)) + (should (string-match-p "║" result)) + ;; Should contain text + (should (string-match-p "Section Header" result)))) + +(ert-deftest test-unicode-box-elisp-single-vs-double () + "Should use different characters for single vs double." + (let ((single-result (test-unicode-box-at-column 0 ";;" "" "Header" 70 'single)) + (double-result (test-unicode-box-at-column 0 ";;" "" "Header" 70 'double))) + ;; Single should have single-line chars but not double + (should (string-match-p "─" single-result)) + (should-not (string-match-p "═" single-result)) + ;; Double should have double-line chars but not single + (should (string-match-p "═" double-result)) + (should-not (string-match-p "─" double-result)))) + +(ert-deftest test-unicode-box-elisp-custom-text () + "Should include custom text in box." + (let ((result (test-unicode-box-at-column 0 ";;" "" "Custom Text Here" 70 'single))) + (should (string-match-p "Custom Text Here" result)))) + +(ert-deftest test-unicode-box-elisp-empty-text () + "Should handle empty text string." + (let ((result (test-unicode-box-at-column 0 ";;" "" "" 70 'single))) + ;; Should still generate 3 lines + (should (= 3 (length (split-string result "\n" t)))) + ;; Should have box characters + (should (string-match-p "┌" result)))) + +(ert-deftest test-unicode-box-elisp-at-column-0 () + "Should work at column 0." + (let ((result (test-unicode-box-at-column 0 ";;" "" "Header" 70 'single))) + ;; First character should be semicolon + (should (string-prefix-p ";;" result)))) + +(ert-deftest test-unicode-box-elisp-indented () + "Should work when indented." + (let ((result (test-unicode-box-at-column 4 ";;" "" "Header" 70 'single))) + ;; Result should start with spaces + (should (string-prefix-p " ;;" result)) + ;; All lines should be indented + (dolist (line (split-string result "\n" t)) + (should (string-prefix-p " ;;" line))))) + +(ert-deftest test-unicode-box-elisp-short-text () + "Should handle short text properly." + (let ((result (test-unicode-box-at-column 0 ";;" "" "X" 70 'single))) + ;; Should have 3 lines + (should (= 3 (length (split-string result "\n" t)))) + ;; Text should be present + (should (string-match-p "X" result)))) + +(ert-deftest test-unicode-box-elisp-long-text () + "Should handle longer text." + (let ((result (test-unicode-box-at-column 0 ";;" "" "This is a longer header text" 70 'single))) + ;; Should have 3 lines + (should (= 3 (length (split-string result "\n" t)))) + ;; Text should be present + (should (string-match-p "This is a longer header text" result)))) + +;;; Boundary Cases + +(ert-deftest test-unicode-box-elisp-minimum-length () + "Should work with minimum viable length." + (let ((result (test-unicode-box-at-column 0 ";;" "" "X" 15 'single))) + (should (= 3 (length (split-string result "\n" t)))) + (should (string-match-p "X" result)))) + +(ert-deftest test-unicode-box-elisp-very-long-length () + "Should handle very long length." + (let ((result (test-unicode-box-at-column 0 ";;" "" "Header" 200 'single))) + (should (= 3 (length (split-string result "\n" t)))) + ;; Border lines should be very long + (let ((first-line (car (split-string result "\n" t)))) + (should (> (length first-line) 100))))) + +(ert-deftest test-unicode-box-elisp-unicode-text () + "Should handle unicode in text." + (let ((result (test-unicode-box-at-column 0 ";;" "" "Hello 👋 مرحبا café" 70 'single))) + (should (string-match-p "👋" result)) + (should (string-match-p "مرحبا" result)) + (should (string-match-p "café" result)))) + +(ert-deftest test-unicode-box-elisp-very-long-text () + "Should handle very long text." + (let* ((long-text (make-string 100 ?x)) + (result (test-unicode-box-at-column 0 ";;" "" long-text 70 'single))) + ;; Should still generate output + (should (= 3 (length (split-string result "\n" t)))) + ;; Middle line should contain some of the text + (should (string-match-p "xxx" result)))) + +(ert-deftest test-unicode-box-elisp-comment-end-empty () + "Should handle empty comment-end correctly." + (let ((result (test-unicode-box-at-column 0 ";;" "" "Header" 70 'single))) + (should (= 3 (length (split-string result "\n" t)))) + ;; Lines should not have trailing comment-end + (should-not (string-match-p ";;.*;;$" result)))) + +(ert-deftest test-unicode-box-elisp-max-indentation () + "Should handle maximum practical indentation." + (let ((result (test-unicode-box-at-column 60 ";;" "" "Header" 100 'single))) + (should (= 3 (length (split-string result "\n" t)))) + ;; All lines should start with 60 spaces + (dolist (line (split-string result "\n" t)) + (should (string-prefix-p (make-string 60 ?\s) line))))) + +;;; Error Cases + +(ert-deftest test-unicode-box-elisp-length-too-small () + "Should error when length is too small." + (should-error + (test-unicode-box-at-column 0 ";;" "" "Header" 5 'single) + :type 'error)) + +(ert-deftest test-unicode-box-elisp-negative-length () + "Should error with negative length." + (should-error + (test-unicode-box-at-column 0 ";;" "" "Header" -10 'single) + :type 'error)) + +(ert-deftest test-unicode-box-elisp-zero-length () + "Should error with zero length." + (should-error + (test-unicode-box-at-column 0 ";;" "" "Header" 0 'single) + :type 'error)) + +(ert-deftest test-unicode-box-elisp-nil-text () + "Should error when text is nil." + (should-error + (test-unicode-box-at-column 0 ";;" "" nil 70 'single) + :type 'wrong-type-argument)) + +(ert-deftest test-unicode-box-elisp-non-integer-length () + "Should error when length is not an integer." + (should-error + (test-unicode-box-at-column 0 ";;" "" "Header" "not-a-number" 'single) + :type 'wrong-type-argument)) + +(ert-deftest test-unicode-box-elisp-invalid-box-style () + "Should handle invalid box-style gracefully." + ;; Function may use a default or error - either is acceptable + (let ((result (test-unicode-box-at-column 0 ";;" "" "Header" 70 'invalid))) + ;; Should still generate some output + (should (stringp result)))) + +;;; Python Tests (Hash-based comments) + +(ert-deftest test-unicode-box-python-single () + "Should generate unicode box with Python comment syntax." + (let ((result (test-unicode-box-at-column 0 "#" "" "Section" 70 'single))) + (should (= 3 (length (split-string result "\n" t)))) + (should (string-match-p "^# ┌" result)) + (should (string-match-p "Section" result)))) + +(ert-deftest test-unicode-box-python-double () + "Should generate double-line unicode box with Python comment syntax." + (let ((result (test-unicode-box-at-column 0 "#" "" "Section" 70 'double))) + (should (= 3 (length (split-string result "\n" t)))) + (should (string-match-p "^# ╔" result)) + (should (string-match-p "Section" result)))) + +;;; C Tests (C-style comments) + +(ert-deftest test-unicode-box-c-block-comments-single () + "Should generate unicode box with C block comment syntax." + (let ((result (test-unicode-box-at-column 0 "/*" "*/" "Section" 70 'single))) + (should (= 3 (length (split-string result "\n" t)))) + (should (string-match-p "^/\\* ┌" result)) + (should (string-match-p "Section" result)) + ;; Should include comment-end + (should (string-match-p "\\*/" result)))) + +(ert-deftest test-unicode-box-c-block-comments-double () + "Should generate double-line unicode box with C block comment syntax." + (let ((result (test-unicode-box-at-column 0 "/*" "*/" "Section" 70 'double))) + (should (= 3 (length (split-string result "\n" t)))) + (should (string-match-p "^/\\* ╔" result)) + (should (string-match-p "Section" result)) + ;; Should include comment-end + (should (string-match-p "\\*/" result)))) + +(provide 'test-custom-comments-comment-unicode-box) +;;; test-custom-comments-comment-unicode-box.el ends here diff --git a/tests/test-custom-comments-delete-buffer-comments.el b/tests/test-custom-comments-delete-buffer-comments.el new file mode 100644 index 00000000..a21386f9 --- /dev/null +++ b/tests/test-custom-comments-delete-buffer-comments.el @@ -0,0 +1,224 @@ +;;; test-custom-comments-delete-buffer-comments.el --- Tests for cj/delete-buffer-comments -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/delete-buffer-comments function from custom-comments.el +;; +;; This function deletes all comments in the current buffer by delegating to +;; Emacs' built-in `comment-kill` function. +;; +;; Cross-Language Testing Strategy: +;; -------------------------------- +;; This function works across multiple programming languages/major modes because +;; it delegates to `comment-kill`, which respects each mode's comment syntax +;; (comment-start, comment-end). +;; +;; Rather than testing exhaustively in every language (8+ languages = 100+ tests), +;; we test strategically: +;; +;; 1. EXTENSIVE testing in Emacs Lisp (our primary language): +;; - ~15 tests covering all normal/boundary/error cases +;; - Tests edge cases: empty buffers, inline comments, unicode, etc. +;; +;; 2. REPRESENTATIVE testing in Python and C: +;; - ~3 tests each proving different comment syntaxes work +;; - Python: hash-based comments (#) +;; - C: C-style line (//) and block (/* */) comments +;; +;; Why this approach? +;; - OUR code is simple: (goto-char (point-min)) + (comment-kill ...) +;; - We're testing OUR integration logic, not Emacs' comment-kill implementation +;; - After proving 3 different syntaxes work, additional languages have +;; diminishing returns (testing Emacs internals, not our code) +;; - Avoids test suite bloat (21 tests vs 100+) while maintaining confidence +;; - Groups languages by similarity: C-style covers C/Java/Go/JavaScript/Rust +;; +;; See ai-prompts/quality-engineer.org: "Testing Framework/Library Integration" + +;;; 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-comments) + +;;; Test Helper + +(defun test-delete-comments-in-mode (mode content-before expected-after) + "Test comment deletion in MODE. +Insert CONTENT-BEFORE, run cj/delete-buffer-comments, verify EXPECTED-AFTER." + (with-temp-buffer + (funcall mode) + (insert content-before) + (cj/delete-buffer-comments) + (should (equal (string-trim (buffer-string)) (string-trim expected-after))))) + +;;; Emacs Lisp Tests (Primary Language - Comprehensive Coverage) + +(ert-deftest test-delete-comments-elisp-simple-line-comments () + "Should delete simple line comments in emacs-lisp-mode." + (test-delete-comments-in-mode + 'emacs-lisp-mode + ";; This is a comment\n(defun foo () nil)" + "(defun foo () nil)")) + +(ert-deftest test-delete-comments-elisp-inline-comments () + "Should delete inline/end-of-line comments." + (test-delete-comments-in-mode + 'emacs-lisp-mode + "(setq x 10) ;; set x to 10" + "(setq x 10)")) + +(ert-deftest test-delete-comments-elisp-only-comments () + "Buffer with only comments should become empty." + (test-delete-comments-in-mode + 'emacs-lisp-mode + ";; Comment 1\n;; Comment 2\n;; Comment 3" + "")) + +(ert-deftest test-delete-comments-elisp-mixed-code-and-comments () + "Should preserve code and delete all comments." + (test-delete-comments-in-mode + 'emacs-lisp-mode + ";; Header comment\n(defun foo ()\n ;; body comment\n (+ 1 2)) ;; inline" + "(defun foo ()\n\n (+ 1 2))")) + +(ert-deftest test-delete-comments-elisp-empty-buffer () + "Should do nothing in empty buffer." + (test-delete-comments-in-mode + 'emacs-lisp-mode + "" + "")) + +(ert-deftest test-delete-comments-elisp-no-comments () + "Should preserve all content when no comments exist." + (test-delete-comments-in-mode + 'emacs-lisp-mode + "(defun foo ()\n (+ 1 2))" + "(defun foo ()\n (+ 1 2))")) + +(ert-deftest test-delete-comments-elisp-whitespace-only-comments () + "Should delete comments containing only whitespace." + (test-delete-comments-in-mode + 'emacs-lisp-mode + ";; \n;; \t\n(setq x 1)" + "(setq x 1)")) + +(ert-deftest test-delete-comments-elisp-unicode-in-comments () + "Should handle unicode characters in comments." + (test-delete-comments-in-mode + 'emacs-lisp-mode + ";; Hello 👋 مرحبا café\n(setq x 1)" + "(setq x 1)")) + +(ert-deftest test-delete-comments-elisp-indented-comments () + "Should delete comments at various indentation levels." + (test-delete-comments-in-mode + 'emacs-lisp-mode + "(defun foo ()\n ;; indented comment\n ;; more indented\n (+ 1 2))" + "(defun foo ()\n\n\n (+ 1 2))")) + +(ert-deftest test-delete-comments-elisp-special-chars-in-comments () + "Should handle special characters in comments." + (test-delete-comments-in-mode + 'emacs-lisp-mode + ";; Special: !@#$%^&*()[]{}|\\/<>?\n(setq x 1)" + "(setq x 1)")) + +(ert-deftest test-delete-comments-elisp-point-not-at-beginning () + "Should work regardless of initial point position." + (with-temp-buffer + (emacs-lisp-mode) + (insert ";; Comment 1\n(setq x 1)\n;; Comment 2") + (goto-char (point-max)) ; Point at end + (cj/delete-buffer-comments) + (should (equal (string-trim (buffer-string)) "(setq x 1)")))) + +(ert-deftest test-delete-comments-elisp-does-not-affect-kill-ring () + "Should not add deleted comments to kill-ring." + (with-temp-buffer + (emacs-lisp-mode) + (insert ";; Comment\n(setq x 1)") + (setq kill-ring nil) + (cj/delete-buffer-comments) + (should (null kill-ring)))) + +(ert-deftest test-delete-comments-elisp-read-only-buffer () + "Should signal error in read-only buffer." + (with-temp-buffer + (emacs-lisp-mode) + (insert ";; Comment\n(setq x 1)") + (read-only-mode 1) + (should-error (cj/delete-buffer-comments)))) + +(ert-deftest test-delete-comments-elisp-narrowed-buffer () + "Should only affect visible region when narrowed." + (with-temp-buffer + (emacs-lisp-mode) + (insert ";; Comment 1\n(setq x 1)\n;; Comment 2\n(setq y 2)") + (goto-char (point-min)) + (forward-line 2) + (narrow-to-region (point) (point-max)) + (cj/delete-buffer-comments) + (widen) + ;; First comment should remain (was outside narrowed region) + ;; Second comment should be deleted + (should (string-match-p "Comment 1" (buffer-string))) + (should-not (string-match-p "Comment 2" (buffer-string))))) + + +;;; Python Tests (Hash-based comments) + +(ert-deftest test-delete-comments-python-simple () + "Should delete Python hash comments." + (test-delete-comments-in-mode + 'python-mode + "# This is a comment\ndef foo():\n return 42" + "def foo():\n return 42")) + +(ert-deftest test-delete-comments-python-inline () + "Should delete inline Python comments." + (test-delete-comments-in-mode + 'python-mode + "x = 10 # set x to 10\ny = 20" + "x = 10\ny = 20")) + +(ert-deftest test-delete-comments-python-mixed () + "Should preserve code and delete Python comments." + (test-delete-comments-in-mode + 'python-mode + "# Header\ndef foo():\n # body\n return 42 # inline" + "def foo():\n\n return 42")) + +;;; C Tests (C-style line and block comments) + +(ert-deftest test-delete-comments-c-line-comments () + "Should delete C line comments (//)." + (test-delete-comments-in-mode + 'c-mode + "// This is a comment\nint main() {\n return 0;\n}" + "int main() {\n return 0;\n}")) + +(ert-deftest test-delete-comments-c-block-comments () + "Should delete C block comments (/* */)." + (test-delete-comments-in-mode + 'c-mode + "/* Block comment */\nint x = 10;" + "int x = 10;")) + +(ert-deftest test-delete-comments-c-mixed () + "Should delete both line and block comments in C." + (test-delete-comments-in-mode + 'c-mode + "// Line comment\n/* Block comment */\nint x = 10; // inline" + "int x = 10;")) + +(provide 'test-custom-comments-delete-buffer-comments) +;;; test-custom-comments-delete-buffer-comments.el ends here diff --git a/tests/test-custom-line-paragraph-duplicate-line-or-region.el b/tests/test-custom-line-paragraph-duplicate-line-or-region.el index 22f19c16..bd82e00f 100644 --- a/tests/test-custom-line-paragraph-duplicate-line-or-region.el +++ b/tests/test-custom-line-paragraph-duplicate-line-or-region.el @@ -435,18 +435,6 @@ (kill-buffer (current-buffer))) (test-duplicate-line-or-region-teardown))) -(ert-deftest test-duplicate-line-or-region-comment-without-syntax () - "Should error when comment requested but no comment syntax defined." - (test-duplicate-line-or-region-setup) - (unwind-protect - (with-temp-buffer - ;; Fundamental mode has no comment syntax - (fundamental-mode) - (insert "line") - (goto-char (point-min)) - ;; Should error when trying to comment without syntax - (should-error (cj/duplicate-line-or-region t))) - (test-duplicate-line-or-region-teardown))) (ert-deftest test-duplicate-line-or-region-special-characters () "Should handle control characters." diff --git a/tests/test-custom-misc-cj--count-characters.el b/tests/test-custom-misc-cj--count-characters.el new file mode 100644 index 00000000..1834b5c4 --- /dev/null +++ b/tests/test-custom-misc-cj--count-characters.el @@ -0,0 +1,171 @@ +;;; test-custom-misc-cj--count-characters.el --- Tests for cj/--count-characters -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--count-characters internal implementation function from custom-misc.el +;; +;; This internal function counts characters between START and END positions. +;; It validates that START is not greater than END and returns the character count. + +;;; Code: + +(require 'ert) + +;; 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) + +;;; Setup and Teardown + +(defun test-count-characters-setup () + "Set up test environment." + ;; No setup needed for this function + nil) + +(defun test-count-characters-teardown () + "Clean up test environment." + ;; No teardown needed for this function + nil) + +;;; Normal Cases + +(ert-deftest test-custom-misc-cj--count-characters-normal-simple-text-returns-count () + "Should count characters in simple text region." + (test-count-characters-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello, world!") + (let ((result (cj/--count-characters 1 14))) + (should (= result 13)))) + (test-count-characters-teardown))) + +(ert-deftest test-custom-misc-cj--count-characters-normal-partial-region-returns-count () + "Should count characters in partial region." + (test-count-characters-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello, world!") + (let ((result (cj/--count-characters 1 6))) + (should (= result 5)))) + (test-count-characters-teardown))) + +(ert-deftest test-custom-misc-cj--count-characters-normal-multiline-returns-count () + "Should count characters including newlines." + (test-count-characters-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + ;; 6 + 1 + 6 + 1 + 6 = 20 characters + (let ((result (cj/--count-characters (point-min) (point-max)))) + (should (= result 20)))) + (test-count-characters-teardown))) + +;;; Boundary Cases + +(ert-deftest test-custom-misc-cj--count-characters-boundary-empty-region-returns-zero () + "Should return 0 for empty region (start equals end)." + (test-count-characters-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello") + (let ((result (cj/--count-characters 3 3))) + (should (= result 0)))) + (test-count-characters-teardown))) + +(ert-deftest test-custom-misc-cj--count-characters-boundary-single-character-returns-one () + "Should return 1 for single character region." + (test-count-characters-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello") + (let ((result (cj/--count-characters 1 2))) + (should (= result 1)))) + (test-count-characters-teardown))) + +(ert-deftest test-custom-misc-cj--count-characters-boundary-large-region-returns-count () + "Should handle very large region." + (test-count-characters-setup) + (unwind-protect + (with-temp-buffer + (let ((large-content (make-string 100000 ?x))) + (insert large-content) + (let ((result (cj/--count-characters (point-min) (point-max)))) + (should (= result 100000))))) + (test-count-characters-teardown))) + +(ert-deftest test-custom-misc-cj--count-characters-boundary-unicode-returns-count () + "Should count unicode characters (emoji, RTL text, combining characters)." + (test-count-characters-setup) + (unwind-protect + (with-temp-buffer + ;; "Hello 👋 مرحبا" contains emoji and Arabic text + (insert "Hello 👋 مرحبا") + (let ((result (cj/--count-characters (point-min) (point-max)))) + ;; Count the actual characters in the buffer + (should (= result (- (point-max) (point-min)))))) + (test-count-characters-teardown))) + +(ert-deftest test-custom-misc-cj--count-characters-boundary-whitespace-only-returns-count () + "Should count whitespace characters." + (test-count-characters-setup) + (unwind-protect + (with-temp-buffer + (insert " \t\n ") + ;; 3 spaces + 1 tab + 1 newline + 2 spaces = 7 characters + (let ((result (cj/--count-characters (point-min) (point-max)))) + (should (= result 7)))) + (test-count-characters-teardown))) + +(ert-deftest test-custom-misc-cj--count-characters-boundary-newlines-at-boundaries-returns-count () + "Should count newlines at start and end." + (test-count-characters-setup) + (unwind-protect + (with-temp-buffer + (insert "\n\nHello\n\n") + ;; 2 newlines + 5 chars + 2 newlines = 9 characters + (let ((result (cj/--count-characters (point-min) (point-max)))) + (should (= result 9)))) + (test-count-characters-teardown))) + +(ert-deftest test-custom-misc-cj--count-characters-boundary-binary-content-returns-count () + "Should handle binary content." + (test-count-characters-setup) + (unwind-protect + (with-temp-buffer + (insert (string 0 1 2 255)) + (let ((result (cj/--count-characters (point-min) (point-max)))) + (should (= result 4)))) + (test-count-characters-teardown))) + +;;; Error Cases + +(ert-deftest test-custom-misc-cj--count-characters-error-start-greater-than-end-signals-error () + "Should signal error when start is greater than end." + (test-count-characters-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello, world!") + (should-error (cj/--count-characters 10 5) + :type 'error)) + (test-count-characters-teardown))) + +(ert-deftest test-custom-misc-cj--count-characters-error-positions-out-of-bounds-handled () + "Should handle positions beyond buffer bounds (Emacs handles this)." + (test-count-characters-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello") + ;; Emacs will error if positions are truly out of bounds, + ;; but this tests that our function doesn't add additional errors + ;; Buffer has 6 positions (1-6), testing valid bounds + (let ((result (cj/--count-characters 1 6))) + (should (= result 5)))) + (test-count-characters-teardown))) + +(provide 'test-custom-misc-cj--count-characters) +;;; test-custom-misc-cj--count-characters.el ends here diff --git a/tests/test-custom-misc-cj-count-characters-buffer-or-region.el b/tests/test-custom-misc-cj-count-characters-buffer-or-region.el new file mode 100644 index 00000000..dbbda00d --- /dev/null +++ b/tests/test-custom-misc-cj-count-characters-buffer-or-region.el @@ -0,0 +1,231 @@ +;;; test-custom-misc-cj-count-characters-buffer-or-region.el --- Tests for cj/count-characters-buffer-or-region -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/count-characters-buffer-or-region function from custom-misc.el +;; +;; This function counts characters in the active region or the entire buffer +;; if no region is active. It displays the count in the minibuffer. + +;;; Code: + +(require 'ert) + +;; 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) + +;;; Setup and Teardown + +(defun test-count-characters-buffer-or-region-setup () + "Set up test environment." + ;; No setup needed + nil) + +(defun test-count-characters-buffer-or-region-teardown () + "Clean up test environment." + ;; Clear any active region + (when (use-region-p) + (deactivate-mark))) + +;;; Normal Cases + +(ert-deftest test-custom-misc-cj-count-characters-buffer-or-region-normal-whole-buffer-counts-all () + "Should count all characters in buffer when no region is active." + (test-count-characters-buffer-or-region-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello, world!") + ;; Ensure no region is active + (deactivate-mark) + (let ((message-output nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (setq message-output (apply #'format format-string args))))) + (cj/count-characters-buffer-or-region) + (should (string-match-p "13 characters.*buffer" message-output))))) + (test-count-characters-buffer-or-region-teardown))) + +(ert-deftest test-custom-misc-cj-count-characters-buffer-or-region-normal-active-region-counts-region () + "Should count characters in active region." + (test-count-characters-buffer-or-region-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello, world!") + ;; Select "Hello" (positions 1-6) + (goto-char 1) + (push-mark 1) + (goto-char 6) + (activate-mark) + (let ((message-output nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (setq message-output (apply #'format format-string args))))) + (cj/count-characters-buffer-or-region) + (should (string-match-p "5 characters.*region" message-output))))) + (test-count-characters-buffer-or-region-teardown))) + +(ert-deftest test-custom-misc-cj-count-characters-buffer-or-region-normal-multiline-buffer-counts-all () + "Should count characters including newlines in buffer." + (test-count-characters-buffer-or-region-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + (deactivate-mark) + (let ((message-output nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (setq message-output (apply #'format format-string args))))) + (cj/count-characters-buffer-or-region) + ;; 6 + 1 + 6 + 1 + 6 = 20 characters + (should (string-match-p "20 characters.*buffer" message-output))))) + (test-count-characters-buffer-or-region-teardown))) + +(ert-deftest test-custom-misc-cj-count-characters-buffer-or-region-normal-multiline-region-counts-region () + "Should count characters including newlines in region." + (test-count-characters-buffer-or-region-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3") + ;; Select first two lines including newlines + (goto-char 1) + (push-mark 1) + (goto-char 14) ; After "Line 1\nLine 2" + (activate-mark) + (let ((message-output nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (setq message-output (apply #'format format-string args))))) + (cj/count-characters-buffer-or-region) + ;; "Line 1\nLine 2" = 6 + 1 + 6 = 13 characters + (should (string-match-p "13 characters.*region" message-output))))) + (test-count-characters-buffer-or-region-teardown))) + +;;; Boundary Cases + +(ert-deftest test-custom-misc-cj-count-characters-buffer-or-region-boundary-empty-buffer-returns-zero () + "Should return 0 for empty buffer." + (test-count-characters-buffer-or-region-setup) + (unwind-protect + (with-temp-buffer + (deactivate-mark) + (let ((message-output nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (setq message-output (apply #'format format-string args))))) + (cj/count-characters-buffer-or-region) + (should (string-match-p "0 characters.*buffer" message-output))))) + (test-count-characters-buffer-or-region-teardown))) + +(ert-deftest test-custom-misc-cj-count-characters-buffer-or-region-boundary-empty-region-counts-buffer () + "Should count whole buffer when region is empty (point equals mark). +When mark and point are at the same position, use-region-p returns nil, +so the function correctly falls back to counting the entire buffer." + (test-count-characters-buffer-or-region-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello, world!") + ;; Create empty region (point equals mark) + ;; Even with activate-mark, use-region-p returns nil when mark == point + (goto-char 5) + (push-mark 5) + (activate-mark) + (let ((message-output nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (setq message-output (apply #'format format-string args))))) + (cj/count-characters-buffer-or-region) + ;; Should count the whole buffer (13 characters) not the empty region + (should (string-match-p "13 characters.*buffer" message-output))))) + (test-count-characters-buffer-or-region-teardown))) + +(ert-deftest test-custom-misc-cj-count-characters-buffer-or-region-boundary-large-buffer-counts-all () + "Should handle very large buffer." + (test-count-characters-buffer-or-region-setup) + (unwind-protect + (with-temp-buffer + (let ((large-content (make-string 100000 ?x))) + (insert large-content) + (deactivate-mark) + (let ((message-output nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (setq message-output (apply #'format format-string args))))) + (cj/count-characters-buffer-or-region) + (should (string-match-p "100000 characters.*buffer" message-output)))))) + (test-count-characters-buffer-or-region-teardown))) + +(ert-deftest test-custom-misc-cj-count-characters-buffer-or-region-boundary-unicode-counts-correctly () + "Should count unicode characters (emoji, RTL text) correctly." + (test-count-characters-buffer-or-region-setup) + (unwind-protect + (with-temp-buffer + (insert "Hello 👋 مرحبا") + (deactivate-mark) + (let ((message-output nil) + (expected-count (- (point-max) (point-min)))) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (setq message-output (apply #'format format-string args))))) + (cj/count-characters-buffer-or-region) + (should (string-match-p (format "%d characters.*buffer" expected-count) + message-output))))) + (test-count-characters-buffer-or-region-teardown))) + +(ert-deftest test-custom-misc-cj-count-characters-buffer-or-region-boundary-whitespace-only-counts-whitespace () + "Should count whitespace characters." + (test-count-characters-buffer-or-region-setup) + (unwind-protect + (with-temp-buffer + (insert " \t\n ") + (deactivate-mark) + (let ((message-output nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (setq message-output (apply #'format format-string args))))) + (cj/count-characters-buffer-or-region) + ;; 3 spaces + 1 tab + 1 newline + 2 spaces = 7 characters + (should (string-match-p "7 characters.*buffer" message-output))))) + (test-count-characters-buffer-or-region-teardown))) + +(ert-deftest test-custom-misc-cj-count-characters-buffer-or-region-boundary-single-character-returns-one () + "Should return 1 for single character buffer." + (test-count-characters-buffer-or-region-setup) + (unwind-protect + (with-temp-buffer + (insert "x") + (deactivate-mark) + (let ((message-output nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (setq message-output (apply #'format format-string args))))) + (cj/count-characters-buffer-or-region) + (should (string-match-p "1 character.*buffer" message-output))))) + (test-count-characters-buffer-or-region-teardown))) + +(ert-deftest test-custom-misc-cj-count-characters-buffer-or-region-boundary-narrowed-buffer-counts-visible () + "Should count only visible characters in narrowed buffer." + (test-count-characters-buffer-or-region-setup) + (unwind-protect + (with-temp-buffer + (insert "Line 1\nLine 2\nLine 3\n") + (goto-char (point-min)) + (forward-line 1) + (narrow-to-region (point) (progn (forward-line 1) (point))) + (deactivate-mark) + (let ((message-output nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (setq message-output (apply #'format format-string args))))) + (cj/count-characters-buffer-or-region) + ;; "Line 2\n" = 7 characters + (should (string-match-p "7 characters.*buffer" message-output))))) + (test-count-characters-buffer-or-region-teardown))) + +(provide 'test-custom-misc-cj-count-characters-buffer-or-region) +;;; test-custom-misc-cj-count-characters-buffer-or-region.el ends here 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 diff --git a/tests/test-custom-ordering-alphabetize.el b/tests/test-custom-ordering-alphabetize.el new file mode 100644 index 00000000..c609e324 --- /dev/null +++ b/tests/test-custom-ordering-alphabetize.el @@ -0,0 +1,176 @@ +;;; test-custom-ordering-alphabetize.el --- Tests for cj/--alphabetize-region -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--alphabetize-region function from custom-ordering.el +;; +;; This function alphabetically sorts words in a region. +;; It splits by whitespace and commas, sorts alphabetically, and joins with ", ". +;; +;; Examples: +;; Input: "zebra apple banana" +;; Output: "apple, banana, zebra" +;; +;; Input: "dog, cat, bird" +;; Output: "bird, cat, dog" +;; +;; We test the NON-INTERACTIVE implementation (cj/--alphabetize-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-ordering) + +;;; Test Helpers + +(defun test-alphabetize (input-text) + "Test cj/--alphabetize-region on INPUT-TEXT. +Returns the sorted, comma-separated string." + (with-temp-buffer + (insert input-text) + (cj/--alphabetize-region (point-min) (point-max)))) + +;;; Normal Cases - Simple Words + +(ert-deftest test-alphabetize-simple-words () + "Should alphabetize simple words." + (let ((result (test-alphabetize "zebra apple banana"))) + (should (string= result "apple, banana, zebra")))) + +(ert-deftest test-alphabetize-already-sorted () + "Should handle already sorted words." + (let ((result (test-alphabetize "apple banana cherry"))) + (should (string= result "apple, banana, cherry")))) + +(ert-deftest test-alphabetize-reverse-order () + "Should alphabetize reverse-ordered words." + (let ((result (test-alphabetize "zebra yankee xray"))) + (should (string= result "xray, yankee, zebra")))) + +(ert-deftest test-alphabetize-two-words () + "Should alphabetize two words." + (let ((result (test-alphabetize "world hello"))) + (should (string= result "hello, world")))) + +;;; Normal Cases - With Commas + +(ert-deftest test-alphabetize-comma-separated () + "Should alphabetize comma-separated words." + (let ((result (test-alphabetize "dog, cat, bird"))) + (should (string= result "bird, cat, dog")))) + +(ert-deftest test-alphabetize-comma-separated-with-spaces () + "Should handle comma-separated with various spacing." + (let ((result (test-alphabetize "dog,cat,bird"))) + (should (string= result "bird, cat, dog")))) + +;;; Normal Cases - With Newlines + +(ert-deftest test-alphabetize-multiline () + "Should alphabetize words across multiple lines." + (let ((result (test-alphabetize "zebra\napple\nbanana"))) + (should (string= result "apple, banana, zebra")))) + +(ert-deftest test-alphabetize-mixed-separators () + "Should alphabetize with mixed separators (spaces, commas, newlines)." + (let ((result (test-alphabetize "zebra, apple\nbanana cherry"))) + (should (string= result "apple, banana, cherry, zebra")))) + +;;; Normal Cases - Case Sensitivity + +(ert-deftest test-alphabetize-case-sensitive () + "Should sort case-sensitively (uppercase before lowercase)." + (let ((result (test-alphabetize "zebra Apple banana"))) + ;; string-lessp sorts uppercase before lowercase + (should (string= result "Apple, banana, zebra")))) + +(ert-deftest test-alphabetize-mixed-case () + "Should handle mixed case words." + (let ((result (test-alphabetize "ZEBRA apple BANANA"))) + (should (string= result "BANANA, ZEBRA, apple")))) + +;;; Normal Cases - Numbers and Special Characters + +(ert-deftest test-alphabetize-with-numbers () + "Should alphabetize numbers as strings." + (let ((result (test-alphabetize "10 2 1 20"))) + ;; Alphabetic sort: "1", "10", "2", "20" + (should (string= result "1, 10, 2, 20")))) + +(ert-deftest test-alphabetize-mixed-alphanumeric () + "Should alphabetize mixed alphanumeric content." + (let ((result (test-alphabetize "item2 item1 item10"))) + (should (string= result "item1, item10, item2")))) + +(ert-deftest test-alphabetize-with-punctuation () + "Should alphabetize words with punctuation." + (let ((result (test-alphabetize "world! hello? test."))) + (should (string= result "hello?, test., world!")))) + +;;; Boundary Cases + +(ert-deftest test-alphabetize-empty-string () + "Should handle empty string." + (let ((result (test-alphabetize ""))) + (should (string= result "")))) + +(ert-deftest test-alphabetize-single-word () + "Should handle single word." + (let ((result (test-alphabetize "hello"))) + (should (string= result "hello")))) + +(ert-deftest test-alphabetize-only-whitespace () + "Should handle whitespace-only text." + (let ((result (test-alphabetize " \n\n\t\t "))) + (should (string= result "")))) + +(ert-deftest test-alphabetize-duplicates () + "Should handle duplicate words." + (let ((result (test-alphabetize "apple banana apple cherry"))) + (should (string= result "apple, apple, banana, cherry")))) + +(ert-deftest test-alphabetize-many-commas () + "Should handle multiple consecutive commas." + (let ((result (test-alphabetize "apple,,,banana,,,cherry"))) + (should (string= result "apple, banana, cherry")))) + +(ert-deftest test-alphabetize-very-long-list () + "Should handle very long list." + (let* ((words (mapcar (lambda (i) (format "word%03d" i)) (number-sequence 100 1 -1))) + (input (mapconcat #'identity words " ")) + (result (test-alphabetize input)) + (sorted-words (split-string result ", "))) + (should (= 100 (length sorted-words))) + (should (string= "word001" (car sorted-words))) + (should (string= "word100" (car (last sorted-words)))))) + +;;; Error Cases + +(ert-deftest test-alphabetize-start-greater-than-end () + "Should error when start > end." + (should-error + (with-temp-buffer + (insert "hello world") + (cj/--alphabetize-region (point-max) (point-min))) + :type 'error)) + +(ert-deftest test-alphabetize-empty-region () + "Should handle empty region (start == end)." + (with-temp-buffer + (insert "hello world") + (let ((pos (/ (+ (point-min) (point-max)) 2))) + (should (string= "" (cj/--alphabetize-region pos pos)))))) + +(provide 'test-custom-ordering-alphabetize) +;;; test-custom-ordering-alphabetize.el ends here diff --git a/tests/test-custom-ordering-arrayify.el b/tests/test-custom-ordering-arrayify.el new file mode 100644 index 00000000..9aedbc46 --- /dev/null +++ b/tests/test-custom-ordering-arrayify.el @@ -0,0 +1,215 @@ +;;; test-custom-ordering-arrayify.el --- Tests for cj/--arrayify -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--arrayify function from custom-ordering.el +;; +;; This function converts lines of text into a quoted, comma-separated array format. +;; It splits input by whitespace, wraps each element in quotes, and joins with ", ". +;; +;; Examples: +;; Input: "apple\nbanana\ncherry" +;; Output: "\"apple\", \"banana\", \"cherry\"" +;; +;; Input: "one two three" (with single quotes) +;; Output: "'one', 'two', 'three'" +;; +;; We test the NON-INTERACTIVE implementation (cj/--arrayify) to avoid +;; mocking user input for quote characters. 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-ordering) + +;;; Test Helpers + +(defun test-arrayify (input-text quote) + "Test cj/--arrayify on INPUT-TEXT with QUOTE character. +Returns the transformed string." + (with-temp-buffer + (insert input-text) + (cj/--arrayify (point-min) (point-max) quote))) + +(defun test-arrayify-with-prefix-suffix (input-text quote prefix suffix) + "Test cj/--arrayify with PREFIX and SUFFIX on INPUT-TEXT. +Returns the transformed string." + (with-temp-buffer + (insert input-text) + (cj/--arrayify (point-min) (point-max) quote prefix suffix))) + +;;; Normal Cases - Double Quotes + +(ert-deftest test-arrayify-single-line-double-quotes () + "Should arrayify single line with double quotes." + (let ((result (test-arrayify "apple banana cherry" "\""))) + (should (string= result "\"apple\", \"banana\", \"cherry\"")))) + +(ert-deftest test-arrayify-multiple-lines-double-quotes () + "Should arrayify multiple lines with double quotes." + (let ((result (test-arrayify "apple\nbanana\ncherry" "\""))) + (should (string= result "\"apple\", \"banana\", \"cherry\"")))) + +(ert-deftest test-arrayify-mixed-whitespace-double-quotes () + "Should arrayify text with mixed whitespace using double quotes." + (let ((result (test-arrayify "apple \n\n banana\t\tcherry" "\""))) + (should (string= result "\"apple\", \"banana\", \"cherry\"")))) + +;;; Normal Cases - Single Quotes + +(ert-deftest test-arrayify-single-line-single-quotes () + "Should arrayify single line with single quotes." + (let ((result (test-arrayify "one two three" "'"))) + (should (string= result "'one', 'two', 'three'")))) + +(ert-deftest test-arrayify-multiple-lines-single-quotes () + "Should arrayify multiple lines with single quotes." + (let ((result (test-arrayify "one\ntwo\nthree" "'"))) + (should (string= result "'one', 'two', 'three'")))) + +;;; Normal Cases - Various Quote Types + +(ert-deftest test-arrayify-backticks () + "Should arrayify with backticks." + (let ((result (test-arrayify "foo bar baz" "`"))) + (should (string= result "`foo`, `bar`, `baz`")))) + +(ert-deftest test-arrayify-no-quotes () + "Should arrayify with empty quote string." + (let ((result (test-arrayify "alpha beta gamma" ""))) + (should (string= result "alpha, beta, gamma")))) + +(ert-deftest test-arrayify-square-brackets () + "Should arrayify with square brackets as quotes." + (let ((result (test-arrayify "x y z" "[]"))) + (should (string= result "[]x[], []y[], []z[]")))) + +;;; Normal Cases - Various Content + +(ert-deftest test-arrayify-with-numbers () + "Should arrayify numbers." + (let ((result (test-arrayify "1 2 3 4 5" "\""))) + (should (string= result "\"1\", \"2\", \"3\", \"4\", \"5\"")))) + +(ert-deftest test-arrayify-with-punctuation () + "Should arrayify words with punctuation." + (let ((result (test-arrayify "hello! world? test." "\""))) + (should (string= result "\"hello!\", \"world?\", \"test.\"")))) + +(ert-deftest test-arrayify-mixed-content () + "Should arrayify mixed alphanumeric content." + (let ((result (test-arrayify "item1 item2 item3" "\""))) + (should (string= result "\"item1\", \"item2\", \"item3\"")))) + +;;; Boundary Cases + +(ert-deftest test-arrayify-empty-string () + "Should handle empty string." + (let ((result (test-arrayify "" "\""))) + (should (string= result "")))) + +(ert-deftest test-arrayify-single-word () + "Should arrayify single word." + (let ((result (test-arrayify "hello" "\""))) + (should (string= result "\"hello\"")))) + +(ert-deftest test-arrayify-only-whitespace () + "Should handle whitespace-only text." + (let ((result (test-arrayify " \n\n\t\t " "\""))) + (should (string= result "")))) + +(ert-deftest test-arrayify-leading-trailing-whitespace () + "Should ignore leading and trailing whitespace." + (let ((result (test-arrayify " apple banana " "\""))) + (should (string= result "\"apple\", \"banana\"")))) + +(ert-deftest test-arrayify-very-long-list () + "Should handle very long list." + (let* ((words (make-list 100 "word")) + (input (mapconcat #'identity words " ")) + (result (test-arrayify input "\""))) + (should (= 100 (length (split-string result ", ")))))) + +(ert-deftest test-arrayify-two-words () + "Should arrayify two words." + (let ((result (test-arrayify "hello world" "\""))) + (should (string= result "\"hello\", \"world\"")))) + +;;; Normal Cases - Prefix/Suffix + +(ert-deftest test-arrayify-with-square-brackets () + "Should arrayify with square brackets prefix/suffix." + (let ((result (test-arrayify-with-prefix-suffix "apple banana cherry" "\"" "[" "]"))) + (should (string= result "[\"apple\", \"banana\", \"cherry\"]")))) + +(ert-deftest test-arrayify-with-parens () + "Should arrayify with parentheses prefix/suffix." + (let ((result (test-arrayify-with-prefix-suffix "one two three" "\"" "(" ")"))) + (should (string= result "(\"one\", \"two\", \"three\")")))) + +(ert-deftest test-arrayify-unquoted-with-brackets () + "Should create unquoted list with brackets." + (let ((result (test-arrayify-with-prefix-suffix "a b c" "" "[" "]"))) + (should (string= result "[a, b, c]")))) + +(ert-deftest test-arrayify-single-quotes-with-brackets () + "Should create single-quoted array with brackets." + (let ((result (test-arrayify-with-prefix-suffix "x y z" "'" "[" "]"))) + (should (string= result "['x', 'y', 'z']")))) + +(ert-deftest test-arrayify-only-prefix () + "Should handle only prefix, no suffix." + (let ((result (test-arrayify-with-prefix-suffix "foo bar" "\"" "[" nil))) + (should (string= result "[\"foo\", \"bar\"")))) + +(ert-deftest test-arrayify-only-suffix () + "Should handle only suffix, no prefix." + (let ((result (test-arrayify-with-prefix-suffix "foo bar" "\"" nil "]"))) + (should (string= result "\"foo\", \"bar\"]")))) + +(ert-deftest test-arrayify-multichar-prefix-suffix () + "Should handle multi-character prefix/suffix." + (let ((result (test-arrayify-with-prefix-suffix "a b" "\"" "Array(" ")"))) + (should (string= result "Array(\"a\", \"b\")")))) + +(ert-deftest test-arrayify-json-style () + "Should create JSON-style array." + (let ((result (test-arrayify-with-prefix-suffix "apple banana" "\"" "[" "]"))) + (should (string= result "[\"apple\", \"banana\"]")))) + +;;; Error Cases + +(ert-deftest test-arrayify-start-greater-than-end () + "Should error when start > end." + (should-error + (with-temp-buffer + (insert "hello world") + (cj/--arrayify (point-max) (point-min) "\"")) + :type 'error)) + +(ert-deftest test-arrayify-empty-region () + "Should handle empty region (start == end)." + (with-temp-buffer + (insert "hello world") + (let ((pos (/ (+ (point-min) (point-max)) 2))) + (should (string= "" (cj/--arrayify pos pos "\"")))))) + +(ert-deftest test-arrayify-empty-region-with-brackets () + "Should handle empty region with brackets." + (with-temp-buffer + (insert "hello world") + (let ((pos (/ (+ (point-min) (point-max)) 2))) + (should (string= "[]" (cj/--arrayify pos pos "\"" "[" "]")))))) + +(provide 'test-custom-ordering-arrayify) +;;; test-custom-ordering-arrayify.el ends here diff --git a/tests/test-custom-ordering-comma-to-lines.el b/tests/test-custom-ordering-comma-to-lines.el new file mode 100644 index 00000000..93e37ec6 --- /dev/null +++ b/tests/test-custom-ordering-comma-to-lines.el @@ -0,0 +1,159 @@ +;;; test-custom-ordering-comma-to-lines.el --- Tests for cj/--comma-separated-text-to-lines -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--comma-separated-text-to-lines function from custom-ordering.el +;; +;; This function converts comma-separated text to separate lines. +;; It replaces commas with newlines and removes trailing whitespace from each line. +;; +;; Examples: +;; Input: "apple, banana, cherry" +;; Output: "apple\nbanana\ncherry" +;; +;; Input: "one,two,three" +;; Output: "one\ntwo\nthree" +;; +;; We test the NON-INTERACTIVE implementation (cj/--comma-separated-text-to-lines) +;; 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-ordering) + +;;; Test Helpers + +(defun test-comma-to-lines (input-text) + "Test cj/--comma-separated-text-to-lines on INPUT-TEXT. +Returns the transformed string." + (with-temp-buffer + (insert input-text) + (cj/--comma-separated-text-to-lines (point-min) (point-max)))) + +;;; Normal Cases - Simple Comma-Separated + +(ert-deftest test-comma-to-lines-simple () + "Should convert simple comma-separated text to lines." + (let ((result (test-comma-to-lines "apple, banana, cherry"))) + (should (string= result "apple\n banana\n cherry")))) + +(ert-deftest test-comma-to-lines-no-spaces () + "Should convert comma-separated text without spaces." + (let ((result (test-comma-to-lines "one,two,three"))) + (should (string= result "one\ntwo\nthree")))) + +(ert-deftest test-comma-to-lines-two-elements () + "Should convert two comma-separated elements." + (let ((result (test-comma-to-lines "hello,world"))) + (should (string= result "hello\nworld")))) + +(ert-deftest test-comma-to-lines-with-varied-spacing () + "Should preserve leading spaces after commas." + (let ((result (test-comma-to-lines "alpha, beta, gamma"))) + (should (string= result "alpha\n beta\n gamma")))) + +;;; Normal Cases - Trailing Whitespace + +(ert-deftest test-comma-to-lines-trailing-spaces () + "Should remove trailing spaces but preserve leading spaces." + (let ((result (test-comma-to-lines "apple , banana , cherry "))) + (should (string= result "apple\n banana\n cherry")))) + +(ert-deftest test-comma-to-lines-trailing-tabs () + "Should remove trailing tabs after conversion." + (let ((result (test-comma-to-lines "apple\t,banana\t,cherry\t"))) + (should (string= result "apple\nbanana\ncherry")))) + +;;; Boundary Cases + +(ert-deftest test-comma-to-lines-empty-string () + "Should handle empty string." + (let ((result (test-comma-to-lines ""))) + (should (string= result "")))) + +(ert-deftest test-comma-to-lines-single-element () + "Should handle single element with no comma." + (let ((result (test-comma-to-lines "hello"))) + (should (string= result "hello")))) + +(ert-deftest test-comma-to-lines-single-element-with-trailing-comma () + "Should handle single element with trailing comma." + (let ((result (test-comma-to-lines "hello,"))) + (should (string= result "hello\n")))) + +(ert-deftest test-comma-to-lines-leading-comma () + "Should handle leading comma." + (let ((result (test-comma-to-lines ",apple,banana"))) + (should (string= result "\napple\nbanana")))) + +(ert-deftest test-comma-to-lines-consecutive-commas () + "Should handle consecutive commas." + (let ((result (test-comma-to-lines "apple,,banana"))) + (should (string= result "apple\n\nbanana")))) + +(ert-deftest test-comma-to-lines-many-consecutive-commas () + "Should handle many consecutive commas." + (let ((result (test-comma-to-lines "apple,,,banana"))) + (should (string= result "apple\n\n\nbanana")))) + +(ert-deftest test-comma-to-lines-only-commas () + "Should handle string with only commas (trailing blank lines removed)." + (let ((result (test-comma-to-lines ",,,"))) + ;; delete-trailing-whitespace removes trailing blank lines + (should (string= result "\n")))) + +;;; Normal Cases - With Spaces Around Elements + +(ert-deftest test-comma-to-lines-leading-spaces () + "Should preserve leading spaces within elements." + (let ((result (test-comma-to-lines " apple, banana, cherry"))) + (should (string= result " apple\n banana\n cherry")))) + +(ert-deftest test-comma-to-lines-mixed-content () + "Should handle mixed alphanumeric content." + (let ((result (test-comma-to-lines "item1,item2,item3"))) + (should (string= result "item1\nitem2\nitem3")))) + +(ert-deftest test-comma-to-lines-with-numbers () + "Should handle numbers." + (let ((result (test-comma-to-lines "1,2,3,4,5"))) + (should (string= result "1\n2\n3\n4\n5")))) + +(ert-deftest test-comma-to-lines-very-long-list () + "Should handle very long list." + (let* ((elements (mapcar #'number-to-string (number-sequence 1 100))) + (input (mapconcat #'identity elements ",")) + (result (test-comma-to-lines input)) + (lines (split-string result "\n"))) + (should (= 100 (length lines))))) + +;;; Error Cases + +(ert-deftest test-comma-to-lines-start-greater-than-end () + "Should error when start > end." + (should-error + (with-temp-buffer + (insert "a,b,c") + (cj/--comma-separated-text-to-lines (point-max) (point-min))) + :type 'error)) + +(ert-deftest test-comma-to-lines-empty-region () + "Should handle empty region (start == end)." + (with-temp-buffer + (insert "a,b,c") + (let ((pos (/ (+ (point-min) (point-max)) 2))) + (should (string= "" (cj/--comma-separated-text-to-lines pos pos)))))) + +(provide 'test-custom-ordering-comma-to-lines) +;;; test-custom-ordering-comma-to-lines.el ends here diff --git a/tests/test-custom-ordering-number-lines.el b/tests/test-custom-ordering-number-lines.el new file mode 100644 index 00000000..adda84f0 --- /dev/null +++ b/tests/test-custom-ordering-number-lines.el @@ -0,0 +1,181 @@ +;;; test-custom-ordering-number-lines.el --- Tests for cj/--number-lines -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--number-lines function from custom-ordering.el +;; +;; This function numbers lines in a region with a customizable format. +;; The format string uses "N" as a placeholder for the line number. +;; Optionally supports zero-padding for alignment. +;; +;; Examples: +;; Input: "apple\nbanana\ncherry" +;; Format: "N. " +;; Output: "1. apple\n2. banana\n3. cherry" +;; +;; With zero-padding and 100 lines: +;; "001. line\n002. line\n...\n100. line" +;; +;; We test the NON-INTERACTIVE implementation (cj/--number-lines) to avoid +;; mocking user input. This follows our testing best practice of +;; separating business logic from UI interaction. + +;;; Code: + +(require 'ert) +(require 'testutil-general) +(require 'cl-lib) + +;; 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-ordering) + +;;; Test Helpers + +(defun test-number-lines (input-text format-string zero-pad) + "Test cj/--number-lines on INPUT-TEXT. +FORMAT-STRING is the format template. +ZERO-PAD enables zero-padding. +Returns the transformed string." + (with-temp-buffer + (insert input-text) + (cj/--number-lines (point-min) (point-max) format-string zero-pad))) + +;;; Normal Cases - Standard Format "N. " + +(ert-deftest test-number-lines-standard-format () + "Should number lines with standard format." + (let ((result (test-number-lines "apple\nbanana\ncherry" "N. " nil))) + (should (string= result "1. apple\n2. banana\n3. cherry")))) + +(ert-deftest test-number-lines-two-lines () + "Should number two lines." + (let ((result (test-number-lines "first\nsecond" "N. " nil))) + (should (string= result "1. first\n2. second")))) + +(ert-deftest test-number-lines-single-line () + "Should number single line." + (let ((result (test-number-lines "only" "N. " nil))) + (should (string= result "1. only")))) + +;;; Normal Cases - Alternative Formats + +(ert-deftest test-number-lines-parenthesis-format () + "Should number with parenthesis format." + (let ((result (test-number-lines "a\nb\nc" "N) " nil))) + (should (string= result "1) a\n2) b\n3) c")))) + +(ert-deftest test-number-lines-bracket-format () + "Should number with bracket format." + (let ((result (test-number-lines "x\ny\nz" "[N] " nil))) + (should (string= result "[1] x\n[2] y\n[3] z")))) + +(ert-deftest test-number-lines-no-space-format () + "Should number without space." + (let ((result (test-number-lines "a\nb" "N." nil))) + (should (string= result "1.a\n2.b")))) + +(ert-deftest test-number-lines-custom-format () + "Should number with custom format." + (let ((result (test-number-lines "foo\nbar" "Item N: " nil))) + (should (string= result "Item 1: foo\nItem 2: bar")))) + +;;; Normal Cases - Zero Padding + +(ert-deftest test-number-lines-zero-pad-single-digit () + "Should not pad when max is single digit." + (let ((result (test-number-lines "a\nb\nc" "N. " t))) + (should (string= result "1. a\n2. b\n3. c")))) + +(ert-deftest test-number-lines-zero-pad-double-digit () + "Should pad to 2 digits when max is 10-99." + (let* ((lines (make-list 12 "line")) + (input (mapconcat #'identity lines "\n")) + (result (test-number-lines input "N. " t)) + (result-lines (split-string result "\n"))) + (should (string-prefix-p "01. " (nth 0 result-lines))) + (should (string-prefix-p "09. " (nth 8 result-lines))) + (should (string-prefix-p "10. " (nth 9 result-lines))) + (should (string-prefix-p "12. " (nth 11 result-lines))))) + +(ert-deftest test-number-lines-zero-pad-triple-digit () + "Should pad to 3 digits when max is 100+." + (let* ((lines (make-list 105 "x")) + (input (mapconcat #'identity lines "\n")) + (result (test-number-lines input "N. " t)) + (result-lines (split-string result "\n"))) + (should (string-prefix-p "001. " (nth 0 result-lines))) + (should (string-prefix-p "099. " (nth 98 result-lines))) + (should (string-prefix-p "100. " (nth 99 result-lines))) + (should (string-prefix-p "105. " (nth 104 result-lines))))) + +;;; Boundary Cases + +(ert-deftest test-number-lines-empty-string () + "Should handle empty string." + (let ((result (test-number-lines "" "N. " nil))) + (should (string= result "1. ")))) + +(ert-deftest test-number-lines-empty-lines () + "Should number empty lines." + (let ((result (test-number-lines "\n\n" "N. " nil))) + (should (string= result "1. \n2. \n3. ")))) + +(ert-deftest test-number-lines-with-existing-numbers () + "Should number lines that already have content." + (let ((result (test-number-lines "1. old\n2. old" "N. " nil))) + (should (string= result "1. 1. old\n2. 2. old")))) + +(ert-deftest test-number-lines-multiple-N-in-format () + "Should replace multiple N occurrences." + (let ((result (test-number-lines "a\nb" "N-N. " nil))) + (should (string= result "1-1. a\n2-2. b")))) + +(ert-deftest test-number-lines-long-content () + "Should number lines with long content." + (let* ((long-line (make-string 100 ?x)) + (input (format "%s\n%s" long-line long-line)) + (result (test-number-lines input "N. " nil))) + (should (string-prefix-p "1. " result)) + (should (string-match "2\\. " result)))) + +;;; Normal Cases - No Zero Padding vs Zero Padding + +(ert-deftest test-number-lines-comparison-no-pad-vs-pad () + "Should show difference between no padding and padding." + (let* ((input "a\nb\nc\nd\ne\nf\ng\nh\ni\nj") + (no-pad (test-number-lines input "N. " nil)) + (with-pad (test-number-lines input "N. " t)) + (no-pad-lines (split-string no-pad "\n")) + (with-pad-lines (split-string with-pad "\n"))) + ;; Without padding: "1. ", "10. " + (should (string-prefix-p "1. " (nth 0 no-pad-lines))) + (should (string-prefix-p "10. " (nth 9 no-pad-lines))) + ;; With padding: "01. ", "10. " + (should (string-prefix-p "01. " (nth 0 with-pad-lines))) + (should (string-prefix-p "10. " (nth 9 with-pad-lines))))) + +;;; Error Cases + +(ert-deftest test-number-lines-start-greater-than-end () + "Should error when start > end." + (should-error + (with-temp-buffer + (insert "line1\nline2") + (cj/--number-lines (point-max) (point-min) "N. " nil)) + :type 'error)) + +(ert-deftest test-number-lines-empty-region () + "Should handle empty region (start == end)." + (with-temp-buffer + (insert "line1\nline2") + (let ((pos (/ (+ (point-min) (point-max)) 2))) + (should (string= "1. " (cj/--number-lines pos pos "N. " nil)))))) + +(provide 'test-custom-ordering-number-lines) +;;; test-custom-ordering-number-lines.el ends here diff --git a/tests/test-custom-ordering-reverse-lines.el b/tests/test-custom-ordering-reverse-lines.el new file mode 100644 index 00000000..3c71362d --- /dev/null +++ b/tests/test-custom-ordering-reverse-lines.el @@ -0,0 +1,131 @@ +;;; test-custom-ordering-reverse-lines.el --- Tests for cj/--reverse-lines -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--reverse-lines function from custom-ordering.el +;; +;; This function reverses the order of lines in a region. +;; The first line becomes last, last becomes first, etc. +;; +;; Examples: +;; Input: "line1\nline2\nline3" +;; Output: "line3\nline2\nline1" +;; +;; We test the NON-INTERACTIVE implementation (cj/--reverse-lines) 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-ordering) + +;;; Test Helpers + +(defun test-reverse-lines (input-text) + "Test cj/--reverse-lines on INPUT-TEXT. +Returns the transformed string." + (with-temp-buffer + (insert input-text) + (cj/--reverse-lines (point-min) (point-max)))) + +;;; Normal Cases + +(ert-deftest test-reverse-lines-three-lines () + "Should reverse three lines." + (let ((result (test-reverse-lines "line1\nline2\nline3"))) + (should (string= result "line3\nline2\nline1")))) + +(ert-deftest test-reverse-lines-two-lines () + "Should reverse two lines." + (let ((result (test-reverse-lines "first\nsecond"))) + (should (string= result "second\nfirst")))) + +(ert-deftest test-reverse-lines-many-lines () + "Should reverse many lines." + (let ((result (test-reverse-lines "a\nb\nc\nd\ne"))) + (should (string= result "e\nd\nc\nb\na")))) + +(ert-deftest test-reverse-lines-with-content () + "Should reverse lines with actual content." + (let ((result (test-reverse-lines "apple banana\ncherry date\negg fig"))) + (should (string= result "egg fig\ncherry date\napple banana")))) + +(ert-deftest test-reverse-lines-bidirectional () + "Should reverse back and forth correctly." + (let* ((original "line1\nline2\nline3") + (reversed (test-reverse-lines original)) + (back (test-reverse-lines reversed))) + (should (string= reversed "line3\nline2\nline1")) + (should (string= back original)))) + +;;; Boundary Cases + +(ert-deftest test-reverse-lines-empty-string () + "Should handle empty string." + (let ((result (test-reverse-lines ""))) + (should (string= result "")))) + +(ert-deftest test-reverse-lines-single-line () + "Should handle single line (no change)." + (let ((result (test-reverse-lines "single line"))) + (should (string= result "single line")))) + +(ert-deftest test-reverse-lines-empty-lines () + "Should reverse including empty lines." + (let ((result (test-reverse-lines "a\n\nb"))) + (should (string= result "b\n\na")))) + +(ert-deftest test-reverse-lines-trailing-newline () + "Should handle trailing newline." + (let ((result (test-reverse-lines "line1\nline2\n"))) + (should (string= result "\nline2\nline1")))) + +(ert-deftest test-reverse-lines-only-newlines () + "Should reverse lines that are only newlines." + (let ((result (test-reverse-lines "\n\n\n"))) + (should (string= result "\n\n\n")))) + +(ert-deftest test-reverse-lines-numbers () + "Should reverse numbered lines." + (let ((result (test-reverse-lines "1\n2\n3\n4\n5"))) + (should (string= result "5\n4\n3\n2\n1")))) + +(ert-deftest test-reverse-lines-very-long () + "Should reverse very long list." + (let* ((lines (mapcar #'number-to-string (number-sequence 1 100))) + (input (mapconcat #'identity lines "\n")) + (result (test-reverse-lines input)) + (result-lines (split-string result "\n"))) + (should (= 100 (length result-lines))) + (should (string= "100" (car result-lines))) + (should (string= "1" (car (last result-lines)))))) + +;;; Error Cases + +(ert-deftest test-reverse-lines-start-greater-than-end () + "Should error when start > end." + (should-error + (with-temp-buffer + (insert "line1\nline2") + (cj/--reverse-lines (point-max) (point-min))) + :type 'error)) + +(ert-deftest test-reverse-lines-empty-region () + "Should handle empty region (start == end)." + (with-temp-buffer + (insert "line1\nline2") + (let ((pos (/ (+ (point-min) (point-max)) 2))) + (should (string= "" (cj/--reverse-lines pos pos)))))) + +(provide 'test-custom-ordering-reverse-lines) +;;; test-custom-ordering-reverse-lines.el ends here diff --git a/tests/test-custom-ordering-toggle-quotes.el b/tests/test-custom-ordering-toggle-quotes.el new file mode 100644 index 00000000..e11305ee --- /dev/null +++ b/tests/test-custom-ordering-toggle-quotes.el @@ -0,0 +1,155 @@ +;;; test-custom-ordering-toggle-quotes.el --- Tests for cj/--toggle-quotes -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--toggle-quotes function from custom-ordering.el +;; +;; This function toggles between double quotes and single quotes. +;; All " become ' and all ' become ". +;; +;; Examples: +;; Input: "apple", "banana" +;; Output: 'apple', 'banana' +;; +;; Input: 'hello', 'world' +;; Output: "hello", "world" +;; +;; We test the NON-INTERACTIVE implementation (cj/--toggle-quotes) 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-ordering) + +;;; Test Helpers + +(defun test-toggle-quotes (input-text) + "Test cj/--toggle-quotes on INPUT-TEXT. +Returns the transformed string." + (with-temp-buffer + (insert input-text) + (cj/--toggle-quotes (point-min) (point-max)))) + +;;; Normal Cases - Double to Single + +(ert-deftest test-toggle-quotes-double-to-single () + "Should convert double quotes to single quotes." + (let ((result (test-toggle-quotes "\"apple\", \"banana\""))) + (should (string= result "'apple', 'banana'")))) + +(ert-deftest test-toggle-quotes-single-double-quote () + "Should convert single double quote." + (let ((result (test-toggle-quotes "\""))) + (should (string= result "'")))) + +(ert-deftest test-toggle-quotes-multiple-double-quotes () + "Should convert multiple double quotes." + (let ((result (test-toggle-quotes "\"hello\" \"world\" \"test\""))) + (should (string= result "'hello' 'world' 'test'")))) + +;;; Normal Cases - Single to Double + +(ert-deftest test-toggle-quotes-single-to-double () + "Should convert single quotes to double quotes." + (let ((result (test-toggle-quotes "'apple', 'banana'"))) + (should (string= result "\"apple\", \"banana\"")))) + +(ert-deftest test-toggle-quotes-single-single-quote () + "Should convert single single quote." + (let ((result (test-toggle-quotes "'"))) + (should (string= result "\"")))) + +(ert-deftest test-toggle-quotes-multiple-single-quotes () + "Should convert multiple single quotes." + (let ((result (test-toggle-quotes "'hello' 'world' 'test'"))) + (should (string= result "\"hello\" \"world\" \"test\"")))) + +;;; Normal Cases - Mixed Quotes + +(ert-deftest test-toggle-quotes-mixed () + "Should toggle mixed quotes." + (let ((result (test-toggle-quotes "\"double\" 'single'"))) + (should (string= result "'double' \"single\"")))) + +(ert-deftest test-toggle-quotes-bidirectional () + "Should toggle back and forth correctly." + (let* ((original "\"apple\", \"banana\"") + (toggled (test-toggle-quotes original)) + (back (test-toggle-quotes toggled))) + (should (string= toggled "'apple', 'banana'")) + (should (string= back original)))) + +;;; Normal Cases - With Text Content + +(ert-deftest test-toggle-quotes-preserves-content () + "Should preserve content while toggling quotes." + (let ((result (test-toggle-quotes "var x = \"hello world\";"))) + (should (string= result "var x = 'hello world';")))) + +(ert-deftest test-toggle-quotes-sql-style () + "Should toggle SQL-style quotes." + (let ((result (test-toggle-quotes "SELECT * FROM users WHERE name='John'"))) + (should (string= result "SELECT * FROM users WHERE name=\"John\"")))) + +(ert-deftest test-toggle-quotes-multiline () + "Should toggle quotes across multiple lines." + (let ((result (test-toggle-quotes "\"line1\"\n\"line2\"\n\"line3\""))) + (should (string= result "'line1'\n'line2'\n'line3'")))) + +;;; Boundary Cases + +(ert-deftest test-toggle-quotes-empty-string () + "Should handle empty string." + (let ((result (test-toggle-quotes ""))) + (should (string= result "")))) + +(ert-deftest test-toggle-quotes-no-quotes () + "Should handle text with no quotes." + (let ((result (test-toggle-quotes "hello world"))) + (should (string= result "hello world")))) + +(ert-deftest test-toggle-quotes-only-double-quotes () + "Should handle string with only double quotes." + (let ((result (test-toggle-quotes "\"\"\"\""))) + (should (string= result "''''")))) + +(ert-deftest test-toggle-quotes-only-single-quotes () + "Should handle string with only single quotes." + (let ((result (test-toggle-quotes "''''"))) + (should (string= result "\"\"\"\"")))) + +(ert-deftest test-toggle-quotes-adjacent-quotes () + "Should handle adjacent quotes." + (let ((result (test-toggle-quotes "\"\"''"))) + (should (string= result "''\"\"")))) + +;;; Error Cases + +(ert-deftest test-toggle-quotes-start-greater-than-end () + "Should error when start > end." + (should-error + (with-temp-buffer + (insert "\"hello\"") + (cj/--toggle-quotes (point-max) (point-min))) + :type 'error)) + +(ert-deftest test-toggle-quotes-empty-region () + "Should handle empty region (start == end)." + (with-temp-buffer + (insert "\"hello\"") + (let ((pos (/ (+ (point-min) (point-max)) 2))) + (should (string= "" (cj/--toggle-quotes pos pos)))))) + +(provide 'test-custom-ordering-toggle-quotes) +;;; test-custom-ordering-toggle-quotes.el ends here diff --git a/tests/test-custom-ordering-unarrayify.el b/tests/test-custom-ordering-unarrayify.el new file mode 100644 index 00000000..a778f419 --- /dev/null +++ b/tests/test-custom-ordering-unarrayify.el @@ -0,0 +1,159 @@ +;;; test-custom-ordering-unarrayify.el --- Tests for cj/--unarrayify -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--unarrayify function from custom-ordering.el +;; +;; This function converts comma-separated array format back to separate lines. +;; It splits by ", " (comma-space), removes quotes (both " and '), and joins with newlines. +;; +;; Examples: +;; Input: "\"apple\", \"banana\", \"cherry\"" +;; Output: "apple\nbanana\ncherry" +;; +;; Input: "'one', 'two', 'three'" +;; Output: "one\ntwo\nthree" +;; +;; We test the NON-INTERACTIVE implementation (cj/--unarrayify) 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-ordering) + +;;; Test Helpers + +(defun test-unarrayify (input-text) + "Test cj/--unarrayify on INPUT-TEXT. +Returns the transformed string." + (with-temp-buffer + (insert input-text) + (cj/--unarrayify (point-min) (point-max)))) + +;;; Normal Cases - Double Quotes + +(ert-deftest test-unarrayify-double-quotes-simple () + "Should unarrayify double-quoted elements." + (let ((result (test-unarrayify "\"apple\", \"banana\", \"cherry\""))) + (should (string= result "apple\nbanana\ncherry")))) + +(ert-deftest test-unarrayify-double-quotes-single-element () + "Should unarrayify single double-quoted element." + (let ((result (test-unarrayify "\"hello\""))) + (should (string= result "hello")))) + +(ert-deftest test-unarrayify-double-quotes-two-elements () + "Should unarrayify two double-quoted elements." + (let ((result (test-unarrayify "\"one\", \"two\""))) + (should (string= result "one\ntwo")))) + +;;; Normal Cases - Single Quotes + +(ert-deftest test-unarrayify-single-quotes-simple () + "Should unarrayify single-quoted elements." + (let ((result (test-unarrayify "'alpha', 'beta', 'gamma'"))) + (should (string= result "alpha\nbeta\ngamma")))) + +(ert-deftest test-unarrayify-single-quotes-single-element () + "Should unarrayify single single-quoted element." + (let ((result (test-unarrayify "'hello'"))) + (should (string= result "hello")))) + +;;; Normal Cases - Mixed Quotes + +(ert-deftest test-unarrayify-mixed-quotes () + "Should unarrayify mixed quote types." + (let ((result (test-unarrayify "\"apple\", 'banana', \"cherry\""))) + (should (string= result "apple\nbanana\ncherry")))) + +;;; Normal Cases - No Quotes + +(ert-deftest test-unarrayify-no-quotes () + "Should unarrayify unquoted elements." + (let ((result (test-unarrayify "foo, bar, baz"))) + (should (string= result "foo\nbar\nbaz")))) + +;;; Normal Cases - Various Content + +(ert-deftest test-unarrayify-with-numbers () + "Should unarrayify numbers." + (let ((result (test-unarrayify "\"1\", \"2\", \"3\""))) + (should (string= result "1\n2\n3")))) + +(ert-deftest test-unarrayify-with-spaces-in-elements () + "Should preserve spaces within elements." + (let ((result (test-unarrayify "\"hello world\", \"foo bar\""))) + (should (string= result "hello world\nfoo bar")))) + +(ert-deftest test-unarrayify-mixed-content () + "Should unarrayify mixed alphanumeric content." + (let ((result (test-unarrayify "\"item1\", \"item2\", \"item3\""))) + (should (string= result "item1\nitem2\nitem3")))) + +;;; Boundary Cases + +(ert-deftest test-unarrayify-empty-string () + "Should handle empty string." + (let ((result (test-unarrayify ""))) + (should (string= result "")))) + +(ert-deftest test-unarrayify-only-quotes () + "Should remove quotes from quote-only string." + (let ((result (test-unarrayify "\"\""))) + (should (string= result "")))) + +(ert-deftest test-unarrayify-very-long-list () + "Should handle very long list." + (let* ((elements (mapcar (lambda (i) (format "\"%d\"" i)) (number-sequence 1 100))) + (input (mapconcat #'identity elements ", ")) + (result (test-unarrayify input)) + (lines (split-string result "\n"))) + (should (= 100 (length lines))))) + +(ert-deftest test-unarrayify-with-empty-elements () + "Should handle empty quoted elements." + (let ((result (test-unarrayify "\"\", \"test\", \"\""))) + (should (string= result "\ntest\n")))) + +;;; Edge Cases - Nested or Mismatched Quotes + +(ert-deftest test-unarrayify-double-quotes-in-single () + "Should handle double quotes inside single-quoted strings." + (let ((result (test-unarrayify "'he said \"hello\"', 'world'"))) + (should (string= result "he said hello\nworld")))) + +(ert-deftest test-unarrayify-only-opening-quotes () + "Should remove all quote characters even if mismatched." + (let ((result (test-unarrayify "\"apple, \"banana, \"cherry"))) + (should (string= result "apple\nbanana\ncherry")))) + +;;; Error Cases + +(ert-deftest test-unarrayify-start-greater-than-end () + "Should error when start > end." + (should-error + (with-temp-buffer + (insert "\"a\", \"b\"") + (cj/--unarrayify (point-max) (point-min))) + :type 'error)) + +(ert-deftest test-unarrayify-empty-region () + "Should handle empty region (start == end)." + (with-temp-buffer + (insert "\"a\", \"b\"") + (let ((pos (/ (+ (point-min) (point-max)) 2))) + (should (string= "" (cj/--unarrayify pos pos)))))) + +(provide 'test-custom-ordering-unarrayify) +;;; test-custom-ordering-unarrayify.el ends here diff --git a/tests/test-custom-org-agenda-functions.el.disabled b/tests/test-custom-org-agenda-functions.el.disabled deleted file mode 100644 index 44f9f43d..00000000 --- a/tests/test-custom-org-agenda-functions.el.disabled +++ /dev/null @@ -1,94 +0,0 @@ -;;; test-custom-org-agenda-functions.el --- Tests for custom functions in org-agenda -*- lexical-binding: t; -*- - -;;; Commentary: -;; This tests the custom functions created to build the main agenda in org-agenda-config.el - -;;; Code: - -(add-to-list 'load-path (concat user-emacs-directory "modules")) -(require 'org-agenda-config) - -(ert-deftest test-cj/org-skip-subtree-if-habit-positive () - (with-temp-buffer - (insert "* TODO [#A] Test task\n") - (insert ":PROPERTIES:\n") - (insert ":STYLE: habit\n") - (insert ":RESET_CHECK_BOXES: t\n") - (insert ":END:\n") - (org-mode) - (goto-char (point-min)) - (should (not (eq nil (cj/org-skip-subtree-if-habit)))))) - -(ert-deftest test-cj/org-skip-subtree-if-habit-negative () - (with-temp-buffer - (insert "* TODO [#A] Test task\n") - (org-mode) - (goto-char (point-min)) - (should (eq nil (cj/org-skip-subtree-if-habit))))) - -(ert-deftest test-cj/org-skip-subtree-if-priority-positive () - (with-temp-buffer - (insert "* TODO [#A] Test task\n") - (org-mode) - (goto-char (point-min)) - (should (not (eq nil (cj/org-skip-subtree-if-priority ?A)))))) - -(ert-deftest test-cj/org-skip-subtree-if-priority-negative () - (erase-buffer) - (insert "* TODO [#B] Test task\n") - (org-mode) - (goto-char (point-min)) - (should (eq nil (cj/org-skip-subtree-if-priority ?A)))) - -(ert-deftest test-cj/org-skip-subtree-if-priority-boundary0 () - (erase-buffer) - (insert "* TODO Test task\n") - (org-mode) - (goto-char (point-min)) - (should (eq nil (cj/org-skip-subtree-if-priority ?A)))) - -(ert-deftest test-cj/org-skip-subtree-if-priority-boundary1 () - (erase-buffer) - (insert "* Test entry\n") - (org-mode) - (goto-char (point-min)) - (should (eq nil (cj/org-skip-subtree-if-priority ?A)))) - -(ert-deftest test-cj/org-skip-subtree-if-keyword-positive () - (with-temp-buffer - (insert "* TODO [#A] Test task\n") - (org-mode) - (goto-char (point-min)) - (should (not (eq nil (cj/org-skip-subtree-if-keyword '("TODO"))))))) - -(ert-deftest test-cj/org-skip-subtree-if-keyword-positive-multiple () - (with-temp-buffer - (insert "* PROJECT Test entry\n") - (org-mode) - (goto-char (point-min)) - (should (not (eq nil (cj/org-skip-subtree-if-keyword '("TODO" "PROJECT"))))))) - -(ert-deftest test-cj/org-skip-subtree-if-keyword-negative () - (erase-buffer) - (insert "* PROJECT [#A] Test task\n") - (org-mode) - (goto-char (point-min)) - (should (eq nil (cj/org-skip-subtree-if-keyword '("TODO"))))) - -(ert-deftest test-cj/org-skip-subtree-if-keyword-negative-superset () - (erase-buffer) - (insert "* PROJECT [#A] Test task\n") - (org-mode) - (goto-char (point-min)) - (should (eq nil (cj/org-skip-subtree-if-keyword '("TODOTODO"))))) - -(ert-deftest test-cj/org-skip-subtree-if-keyword-negative-multiple () - (erase-buffer) - (insert "* PROJECT [#A] Test task\n") - (org-mode) - (goto-char (point-min)) - (should (eq nil (cj/org-skip-subtree-if-keyword '("TODO" "DONE"))))) - - -(provide 'test-custom-org-agenda-functions) -;;; test-custom-org-agenda-functions.el ends here. diff --git a/tests/test-custom-text-enclose-append.el b/tests/test-custom-text-enclose-append.el new file mode 100644 index 00000000..3593a7f5 --- /dev/null +++ b/tests/test-custom-text-enclose-append.el @@ -0,0 +1,190 @@ +;;; test-custom-text-enclose-append.el --- Tests for cj/--append-to-lines -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--append-to-lines function from custom-text-enclose.el +;; +;; This function appends a suffix string to the end of each line in text. +;; It preserves the structure of lines and handles trailing newlines correctly. +;; +;; Examples: +;; Input: "line1\nline2", suffix: ";" +;; Output: "line1;\nline2;" +;; +;; Input: "single", suffix: "!" +;; Output: "single!" +;; +;; We test the NON-INTERACTIVE implementation (cj/--append-to-lines) 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-text-enclose) + +;;; Test Helpers + +(defun test-append-to-lines (text suffix) + "Test cj/--append-to-lines on TEXT with SUFFIX. +Returns the transformed string." + (cj/--append-to-lines text suffix)) + +;;; Normal Cases - Single Line + +(ert-deftest test-append-single-line () + "Should append to single line." + (let ((result (test-append-to-lines "hello" ";"))) + (should (string= result "hello;")))) + +(ert-deftest test-append-single-line-semicolon () + "Should append semicolon to single line." + (let ((result (test-append-to-lines "var x = 5" ";"))) + (should (string= result "var x = 5;")))) + +(ert-deftest test-append-single-line-exclamation () + "Should append exclamation mark to single line." + (let ((result (test-append-to-lines "Hello world" "!"))) + (should (string= result "Hello world!")))) + +;;; Normal Cases - Multiple Lines + +(ert-deftest test-append-two-lines () + "Should append to two lines." + (let ((result (test-append-to-lines "line1\nline2" ";"))) + (should (string= result "line1;\nline2;")))) + +(ert-deftest test-append-three-lines () + "Should append to three lines." + (let ((result (test-append-to-lines "a\nb\nc" "."))) + (should (string= result "a.\nb.\nc.")))) + +(ert-deftest test-append-many-lines () + "Should append to many lines." + (let* ((lines (make-list 10 "line")) + (input (mapconcat #'identity lines "\n")) + (result (test-append-to-lines input ";")) + (result-lines (split-string result "\n"))) + (should (= 10 (length result-lines))) + (should (cl-every (lambda (line) (string-suffix-p ";" line)) result-lines)))) + +;;; Normal Cases - Various Suffixes + +(ert-deftest test-append-comma () + "Should append comma to lines." + (let ((result (test-append-to-lines "apple\nbanana" ","))) + (should (string= result "apple,\nbanana,")))) + +(ert-deftest test-append-multi-char () + "Should append multi-character suffix." + (let ((result (test-append-to-lines "line" " // comment"))) + (should (string= result "line // comment")))) + +(ert-deftest test-append-pipe () + "Should append pipe character." + (let ((result (test-append-to-lines "col1\ncol2" " |"))) + (should (string= result "col1 |\ncol2 |")))) + +(ert-deftest test-append-empty-suffix () + "Should handle empty suffix." + (let ((result (test-append-to-lines "line1\nline2" ""))) + (should (string= result "line1\nline2")))) + +;;; Boundary Cases - Trailing Newlines + +(ert-deftest test-append-with-trailing-newline () + "Should preserve trailing newline." + (let ((result (test-append-to-lines "line1\nline2\n" ";"))) + (should (string= result "line1;\nline2;\n")))) + +(ert-deftest test-append-no-trailing-newline () + "Should work without trailing newline." + (let ((result (test-append-to-lines "line1\nline2" ";"))) + (should (string= result "line1;\nline2;")))) + +(ert-deftest test-append-single-line-with-newline () + "Should preserve trailing newline on single line." + (let ((result (test-append-to-lines "line\n" ";"))) + (should (string= result "line;\n")))) + +;;; Boundary Cases - Empty Lines + +(ert-deftest test-append-empty-line-between () + "Should append to empty line between other lines." + (let ((result (test-append-to-lines "line1\n\nline3" ";"))) + (should (string= result "line1;\n;\nline3;")))) + +(ert-deftest test-append-only-empty-lines () + "Should append to only empty lines." + (let ((result (test-append-to-lines "\n\n" ";"))) + (should (string= result ";\n;\n")))) + +(ert-deftest test-append-empty-first-line () + "Should append to empty first line." + (let ((result (test-append-to-lines "\nline2\nline3" ";"))) + (should (string= result ";\nline2;\nline3;")))) + +;;; Boundary Cases - Whitespace + +(ert-deftest test-append-preserves-leading-whitespace () + "Should preserve leading whitespace." + (let ((result (test-append-to-lines " line1\n line2" ";"))) + (should (string= result " line1;\n line2;")))) + +(ert-deftest test-append-preserves-trailing-whitespace () + "Should preserve trailing whitespace on line." + (let ((result (test-append-to-lines "line1 \nline2 " ";"))) + (should (string= result "line1 ;\nline2 ;")))) + +(ert-deftest test-append-whitespace-only-line () + "Should append to whitespace-only line." + (let ((result (test-append-to-lines "line1\n \nline3" ";"))) + (should (string= result "line1;\n ;\nline3;")))) + +;;; Boundary Cases - Special Cases + +(ert-deftest test-append-empty-string () + "Should handle empty string." + (let ((result (test-append-to-lines "" ";"))) + (should (string= result ";")))) + +(ert-deftest test-append-very-long-line () + "Should append to very long line." + (let* ((long-line (make-string 1000 ?a)) + (result (test-append-to-lines long-line ";"))) + (should (string-suffix-p ";" result)) + (should (= (length result) 1001)))) + +(ert-deftest test-append-with-existing-suffix () + "Should append even if line already has the suffix." + (let ((result (test-append-to-lines "line;" ";"))) + (should (string= result "line;;")))) + +;;; Edge Cases - Special Characters in Suffix + +(ert-deftest test-append-newline-suffix () + "Should append newline as suffix." + (let ((result (test-append-to-lines "line1\nline2" "\n"))) + (should (string= result "line1\n\nline2\n")))) + +(ert-deftest test-append-tab-suffix () + "Should append tab as suffix." + (let ((result (test-append-to-lines "col1\ncol2" "\t"))) + (should (string= result "col1\t\ncol2\t")))) + +(ert-deftest test-append-quote-suffix () + "Should append quote as suffix." + (let ((result (test-append-to-lines "value1\nvalue2" "\""))) + (should (string= result "value1\"\nvalue2\"")))) + +(provide 'test-custom-text-enclose-append) +;;; test-custom-text-enclose-append.el ends here diff --git a/tests/test-custom-text-enclose-indent.el b/tests/test-custom-text-enclose-indent.el new file mode 100644 index 00000000..e9042d35 --- /dev/null +++ b/tests/test-custom-text-enclose-indent.el @@ -0,0 +1,241 @@ +;;; test-custom-text-enclose-indent.el --- Tests for cj/--indent-lines and cj/--dedent-lines -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--indent-lines and cj/--dedent-lines functions from custom-text-enclose.el +;; +;; cj/--indent-lines adds leading whitespace (spaces or tabs) to each line. +;; cj/--dedent-lines removes up to COUNT leading whitespace characters from each line. +;; +;; Examples (indent): +;; Input: "line1\nline2", count: 4, use-tabs: nil +;; Output: " line1\n line2" +;; +;; Examples (dedent): +;; Input: " line1\n line2", count: 4 +;; Output: "line1\nline2" +;; +;; We test the NON-INTERACTIVE implementations to avoid mocking user input. + +;;; 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-text-enclose) + +;;; Test Helpers + +(defun test-indent (text count use-tabs) + "Test cj/--indent-lines on TEXT with COUNT and USE-TABS. +Returns the transformed string." + (cj/--indent-lines text count use-tabs)) + +(defun test-dedent (text count) + "Test cj/--dedent-lines on TEXT with COUNT. +Returns the transformed string." + (cj/--dedent-lines text count)) + +;;; Indent Tests - Normal Cases with Spaces + +(ert-deftest test-indent-single-line-4-spaces () + "Should indent single line with 4 spaces." + (let ((result (test-indent "line" 4 nil))) + (should (string= result " line")))) + +(ert-deftest test-indent-two-lines-4-spaces () + "Should indent two lines with 4 spaces." + (let ((result (test-indent "line1\nline2" 4 nil))) + (should (string= result " line1\n line2")))) + +(ert-deftest test-indent-three-lines-2-spaces () + "Should indent three lines with 2 spaces." + (let ((result (test-indent "a\nb\nc" 2 nil))) + (should (string= result " a\n b\n c")))) + +(ert-deftest test-indent-many-lines () + "Should indent many lines." + (let ((result (test-indent "1\n2\n3\n4\n5" 4 nil))) + (should (string= result " 1\n 2\n 3\n 4\n 5")))) + +;;; Indent Tests - Normal Cases with Tabs + +(ert-deftest test-indent-single-line-1-tab () + "Should indent single line with 1 tab." + (let ((result (test-indent "line" 1 t))) + (should (string= result "\tline")))) + +(ert-deftest test-indent-two-lines-1-tab () + "Should indent two lines with 1 tab." + (let ((result (test-indent "line1\nline2" 1 t))) + (should (string= result "\tline1\n\tline2")))) + +(ert-deftest test-indent-with-2-tabs () + "Should indent with 2 tabs." + (let ((result (test-indent "code" 2 t))) + (should (string= result "\t\tcode")))) + +;;; Indent Tests - Boundary Cases + +(ert-deftest test-indent-empty-string () + "Should indent empty string." + (let ((result (test-indent "" 4 nil))) + (should (string= result " ")))) + +(ert-deftest test-indent-zero-count () + "Should not indent with count 0." + (let ((result (test-indent "line" 0 nil))) + (should (string= result "line")))) + +(ert-deftest test-indent-already-indented () + "Should add more indentation to already indented lines." + (let ((result (test-indent " line1\n line2" 2 nil))) + (should (string= result " line1\n line2")))) + +(ert-deftest test-indent-empty-lines () + "Should indent empty lines." + (let ((result (test-indent "line1\n\nline3" 4 nil))) + (should (string= result " line1\n \n line3")))) + +(ert-deftest test-indent-trailing-newline () + "Should preserve trailing newline." + (let ((result (test-indent "line1\nline2\n" 4 nil))) + (should (string= result " line1\n line2\n")))) + +(ert-deftest test-indent-no-trailing-newline () + "Should work without trailing newline." + (let ((result (test-indent "line1\nline2" 4 nil))) + (should (string= result " line1\n line2")))) + +;;; Dedent Tests - Normal Cases + +(ert-deftest test-dedent-single-line-4-spaces () + "Should dedent single line with 4 spaces." + (let ((result (test-dedent " line" 4))) + (should (string= result "line")))) + +(ert-deftest test-dedent-two-lines-4-spaces () + "Should dedent two lines with 4 spaces." + (let ((result (test-dedent " line1\n line2" 4))) + (should (string= result "line1\nline2")))) + +(ert-deftest test-dedent-three-lines-2-spaces () + "Should dedent three lines with 2 spaces." + (let ((result (test-dedent " a\n b\n c" 2))) + (should (string= result "a\nb\nc")))) + +(ert-deftest test-dedent-with-tabs () + "Should dedent lines with tabs." + (let ((result (test-dedent "\tline1\n\tline2" 1))) + (should (string= result "line1\nline2")))) + +(ert-deftest test-dedent-mixed-spaces-tabs () + "Should dedent mixed spaces and tabs." + (let ((result (test-dedent " \tline" 3))) + (should (string= result "line")))) + +;;; Dedent Tests - Partial Dedent + +(ert-deftest test-dedent-partial () + "Should dedent only COUNT characters." + (let ((result (test-dedent " line" 2))) + (should (string= result " line")))) + +(ert-deftest test-dedent-less-than-count () + "Should dedent all available spaces when less than COUNT." + (let ((result (test-dedent " line" 4))) + (should (string= result "line")))) + +(ert-deftest test-dedent-no-leading-space () + "Should not affect lines with no leading whitespace." + (let ((result (test-dedent "line" 4))) + (should (string= result "line")))) + +(ert-deftest test-dedent-varying-indentation () + "Should dedent each line independently." + (let ((result (test-dedent " line1\n line2\nline3" 2))) + (should (string= result " line1\nline2\nline3")))) + +;;; Dedent Tests - Boundary Cases + +(ert-deftest test-dedent-empty-string () + "Should handle empty string." + (let ((result (test-dedent "" 4))) + (should (string= result "")))) + +(ert-deftest test-dedent-zero-count () + "Should not dedent with count 0." + (let ((result (test-dedent " line" 0))) + (should (string= result " line")))) + +(ert-deftest test-dedent-empty-lines () + "Should handle empty lines." + (let ((result (test-dedent " line1\n \n line3" 4))) + (should (string= result "line1\n\nline3")))) + +(ert-deftest test-dedent-only-whitespace () + "Should dedent whitespace-only lines." + (let ((result (test-dedent " " 4))) + (should (string= result "")))) + +(ert-deftest test-dedent-trailing-newline () + "Should preserve trailing newline." + (let ((result (test-dedent " line1\n line2\n" 4))) + (should (string= result "line1\nline2\n")))) + +(ert-deftest test-dedent-preserves-internal-spaces () + "Should not affect internal whitespace." + (let ((result (test-dedent " hello world" 4))) + (should (string= result "hello world")))) + +;;; Round-trip Tests + +(ert-deftest test-indent-dedent-roundtrip () + "Should be able to indent then dedent back to original." + (let* ((original "line1\nline2") + (indented (test-indent original 4 nil)) + (dedented (test-dedent indented 4))) + (should (string= dedented original)))) + +(ert-deftest test-dedent-indent-roundtrip () + "Should be able to dedent then indent back to original." + (let* ((original " line1\n line2") + (dedented (test-dedent original 4)) + (indented (test-indent dedented 4 nil))) + (should (string= indented original)))) + +;;; Edge Cases + +(ert-deftest test-indent-very-long-line () + "Should indent very long line." + (let* ((long-line (make-string 1000 ?a)) + (result (test-indent long-line 4 nil))) + (should (string-prefix-p " " result)) + (should (= (length result) 1004)))) + +(ert-deftest test-dedent-very-indented () + "Should dedent very indented line." + (let* ((many-spaces (make-string 100 ?\s)) + (text (concat many-spaces "text")) + (result (test-dedent text 50))) + (should (string-prefix-p (make-string 50 ?\s) result)))) + +(ert-deftest test-indent-with-existing-tabs () + "Should indent lines that already have tabs." + (let ((result (test-indent "\tcode" 4 nil))) + (should (string= result " \tcode")))) + +(ert-deftest test-dedent-stops-at-non-whitespace () + "Should stop dedenting at first non-whitespace character." + (let ((result (test-dedent " a b" 4))) + (should (string= result "a b")))) + +(provide 'test-custom-text-enclose-indent) +;;; test-custom-text-enclose-indent.el ends here diff --git a/tests/test-custom-text-enclose-prepend.el b/tests/test-custom-text-enclose-prepend.el new file mode 100644 index 00000000..e03375ff --- /dev/null +++ b/tests/test-custom-text-enclose-prepend.el @@ -0,0 +1,207 @@ +;;; test-custom-text-enclose-prepend.el --- Tests for cj/--prepend-to-lines -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--prepend-to-lines function from custom-text-enclose.el +;; +;; This function prepends a prefix string to the beginning of each line in text. +;; It preserves the structure of lines and handles trailing newlines correctly. +;; +;; Examples: +;; Input: "line1\nline2", prefix: "// " +;; Output: "// line1\n// line2" +;; +;; Input: "single", prefix: "> " +;; Output: "> single" +;; +;; We test the NON-INTERACTIVE implementation (cj/--prepend-to-lines) 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-text-enclose) + +;;; Test Helpers + +(defun test-prepend-to-lines (text prefix) + "Test cj/--prepend-to-lines on TEXT with PREFIX. +Returns the transformed string." + (cj/--prepend-to-lines text prefix)) + +;;; Normal Cases - Single Line + +(ert-deftest test-prepend-single-line () + "Should prepend to single line." + (let ((result (test-prepend-to-lines "hello" "> "))) + (should (string= result "> hello")))) + +(ert-deftest test-prepend-single-line-comment () + "Should prepend comment marker to single line." + (let ((result (test-prepend-to-lines "code here" "// "))) + (should (string= result "// code here")))) + +(ert-deftest test-prepend-single-line-bullet () + "Should prepend bullet to single line." + (let ((result (test-prepend-to-lines "item" "- "))) + (should (string= result "- item")))) + +;;; Normal Cases - Multiple Lines + +(ert-deftest test-prepend-two-lines () + "Should prepend to two lines." + (let ((result (test-prepend-to-lines "line1\nline2" "> "))) + (should (string= result "> line1\n> line2")))) + +(ert-deftest test-prepend-three-lines () + "Should prepend to three lines." + (let ((result (test-prepend-to-lines "a\nb\nc" "* "))) + (should (string= result "* a\n* b\n* c")))) + +(ert-deftest test-prepend-many-lines () + "Should prepend to many lines." + (let* ((lines (make-list 10 "line")) + (input (mapconcat #'identity lines "\n")) + (result (test-prepend-to-lines input "# ")) + (result-lines (split-string result "\n"))) + (should (= 10 (length result-lines))) + (should (cl-every (lambda (line) (string-prefix-p "# " line)) result-lines)))) + +;;; Normal Cases - Various Prefixes + +(ert-deftest test-prepend-comment-marker () + "Should prepend comment marker." + (let ((result (test-prepend-to-lines "line1\nline2" "// "))) + (should (string= result "// line1\n// line2")))) + +(ert-deftest test-prepend-hash-comment () + "Should prepend hash comment." + (let ((result (test-prepend-to-lines "line1\nline2" "# "))) + (should (string= result "# line1\n# line2")))) + +(ert-deftest test-prepend-multi-char () + "Should prepend multi-character prefix." + (let ((result (test-prepend-to-lines "line" "TODO: "))) + (should (string= result "TODO: line")))) + +(ert-deftest test-prepend-empty-prefix () + "Should handle empty prefix." + (let ((result (test-prepend-to-lines "line1\nline2" ""))) + (should (string= result "line1\nline2")))) + +;;; Boundary Cases - Trailing Newlines + +(ert-deftest test-prepend-with-trailing-newline () + "Should preserve trailing newline." + (let ((result (test-prepend-to-lines "line1\nline2\n" "> "))) + (should (string= result "> line1\n> line2\n")))) + +(ert-deftest test-prepend-no-trailing-newline () + "Should work without trailing newline." + (let ((result (test-prepend-to-lines "line1\nline2" "> "))) + (should (string= result "> line1\n> line2")))) + +(ert-deftest test-prepend-single-line-with-newline () + "Should preserve trailing newline on single line." + (let ((result (test-prepend-to-lines "line\n" "> "))) + (should (string= result "> line\n")))) + +;;; Boundary Cases - Empty Lines + +(ert-deftest test-prepend-empty-line-between () + "Should prepend to empty line between other lines." + (let ((result (test-prepend-to-lines "line1\n\nline3" "> "))) + (should (string= result "> line1\n> \n> line3")))) + +(ert-deftest test-prepend-only-empty-lines () + "Should prepend to only empty lines." + (let ((result (test-prepend-to-lines "\n\n" "> "))) + (should (string= result "> \n> \n")))) + +(ert-deftest test-prepend-empty-first-line () + "Should prepend to empty first line." + (let ((result (test-prepend-to-lines "\nline2\nline3" "> "))) + (should (string= result "> \n> line2\n> line3")))) + +;;; Boundary Cases - Whitespace + +(ert-deftest test-prepend-preserves-leading-whitespace () + "Should preserve leading whitespace after prefix." + (let ((result (test-prepend-to-lines " line1\n line2" "// "))) + (should (string= result "// line1\n// line2")))) + +(ert-deftest test-prepend-preserves-trailing-whitespace () + "Should preserve trailing whitespace on line." + (let ((result (test-prepend-to-lines "line1 \nline2 " "> "))) + (should (string= result "> line1 \n> line2 ")))) + +(ert-deftest test-prepend-whitespace-only-line () + "Should prepend to whitespace-only line." + (let ((result (test-prepend-to-lines "line1\n \nline3" "> "))) + (should (string= result "> line1\n> \n> line3")))) + +;;; Boundary Cases - Special Cases + +(ert-deftest test-prepend-empty-string () + "Should handle empty string." + (let ((result (test-prepend-to-lines "" "> "))) + (should (string= result "> ")))) + +(ert-deftest test-prepend-very-long-line () + "Should prepend to very long line." + (let* ((long-line (make-string 1000 ?a)) + (result (test-prepend-to-lines long-line "> "))) + (should (string-prefix-p "> " result)) + (should (= (length result) 1002)))) + +(ert-deftest test-prepend-with-existing-prefix () + "Should prepend even if line already has the prefix." + (let ((result (test-prepend-to-lines "> line" "> "))) + (should (string= result "> > line")))) + +;;; Edge Cases - Special Characters in Prefix + +(ert-deftest test-prepend-newline-prefix () + "Should prepend newline as prefix." + (let ((result (test-prepend-to-lines "line1\nline2" "\n"))) + (should (string= result "\nline1\n\nline2")))) + +(ert-deftest test-prepend-tab-prefix () + "Should prepend tab as prefix." + (let ((result (test-prepend-to-lines "line1\nline2" "\t"))) + (should (string= result "\tline1\n\tline2")))) + +(ert-deftest test-prepend-quote-prefix () + "Should prepend quote as prefix." + (let ((result (test-prepend-to-lines "line1\nline2" "\""))) + (should (string= result "\"line1\n\"line2")))) + +;;; Edge Cases - Common Use Cases + +(ert-deftest test-prepend-markdown-quote () + "Should prepend markdown quote marker." + (let ((result (test-prepend-to-lines "quote text\nmore text" "> "))) + (should (string= result "> quote text\n> more text")))) + +(ert-deftest test-prepend-numbered-list () + "Should prepend numbers (though simpler uses would vary the prefix)." + (let ((result (test-prepend-to-lines "item" "1. "))) + (should (string= result "1. item")))) + +(ert-deftest test-prepend-indentation () + "Should prepend indentation spaces." + (let ((result (test-prepend-to-lines "code\nmore" " "))) + (should (string= result " code\n more")))) + +(provide 'test-custom-text-enclose-prepend) +;;; test-custom-text-enclose-prepend.el ends here diff --git a/tests/test-custom-text-enclose-surround.el b/tests/test-custom-text-enclose-surround.el new file mode 100644 index 00000000..dfed20a7 --- /dev/null +++ b/tests/test-custom-text-enclose-surround.el @@ -0,0 +1,200 @@ +;;; test-custom-text-enclose-surround.el --- Tests for cj/--surround -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--surround function from custom-text-enclose.el +;; +;; This function surrounds text with a given string. +;; The surround string is both prepended and appended to the text. +;; +;; Examples: +;; Input: "hello", surround: "\"" +;; Output: "\"hello\"" +;; +;; Input: "world", surround: "**" +;; Output: "**world**" +;; +;; We test the NON-INTERACTIVE implementation (cj/--surround) to avoid +;; mocking user input. 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-text-enclose) + +;;; Test Helpers + +(defun test-surround (text surround-string) + "Test cj/--surround on TEXT with SURROUND-STRING. +Returns the transformed string." + (cj/--surround text surround-string)) + +;;; Normal Cases - Common Surround Strings + +(ert-deftest test-surround-double-quotes () + "Should surround text with double quotes." + (let ((result (test-surround "hello" "\""))) + (should (string= result "\"hello\"")))) + +(ert-deftest test-surround-single-quotes () + "Should surround text with single quotes." + (let ((result (test-surround "world" "'"))) + (should (string= result "'world'")))) + +(ert-deftest test-surround-parentheses () + "Should surround text with parentheses." + (let ((result (test-surround "text" "("))) + (should (string= result "(text(")))) + +(ert-deftest test-surround-square-brackets () + "Should surround text with square brackets." + (let ((result (test-surround "item" "["))) + (should (string= result "[item[")))) + +(ert-deftest test-surround-asterisks () + "Should surround text with asterisks for markdown." + (let ((result (test-surround "bold" "*"))) + (should (string= result "*bold*")))) + +(ert-deftest test-surround-double-asterisks () + "Should surround text with double asterisks." + (let ((result (test-surround "bold" "**"))) + (should (string= result "**bold**")))) + +;;; Normal Cases - Multi-Character Surround Strings + +(ert-deftest test-surround-html-tag () + "Should surround text with HTML-like tags." + (let ((result (test-surround "content" "<tag>"))) + (should (string= result "<tag>content<tag>")))) + +(ert-deftest test-surround-backticks () + "Should surround text with backticks for code." + (let ((result (test-surround "code" "`"))) + (should (string= result "`code`")))) + +(ert-deftest test-surround-triple-backticks () + "Should surround text with triple backticks." + (let ((result (test-surround "code block" "```"))) + (should (string= result "```code block```")))) + +(ert-deftest test-surround-custom-delimiter () + "Should surround text with custom delimiter." + (let ((result (test-surround "data" "||"))) + (should (string= result "||data||")))) + +;;; Normal Cases - Various Text Content + +(ert-deftest test-surround-single-word () + "Should surround single word." + (let ((result (test-surround "word" "\""))) + (should (string= result "\"word\"")))) + +(ert-deftest test-surround-multiple-words () + "Should surround multiple words." + (let ((result (test-surround "hello world" "\""))) + (should (string= result "\"hello world\"")))) + +(ert-deftest test-surround-sentence () + "Should surround full sentence." + (let ((result (test-surround "This is a sentence." "\""))) + (should (string= result "\"This is a sentence.\"")))) + +(ert-deftest test-surround-with-numbers () + "Should surround text with numbers." + (let ((result (test-surround "123" "'"))) + (should (string= result "'123'")))) + +(ert-deftest test-surround-with-special-chars () + "Should surround text with special characters." + (let ((result (test-surround "hello@world.com" "\""))) + (should (string= result "\"hello@world.com\"")))) + +;;; Normal Cases - Multiline Text + +(ert-deftest test-surround-multiline () + "Should surround multiline text." + (let ((result (test-surround "line1\nline2\nline3" "\""))) + (should (string= result "\"line1\nline2\nline3\"")))) + +(ert-deftest test-surround-text-with-newlines () + "Should surround text containing newlines." + (let ((result (test-surround "first\nsecond" "**"))) + (should (string= result "**first\nsecond**")))) + +;;; Boundary Cases + +(ert-deftest test-surround-empty-string () + "Should surround empty string." + (let ((result (test-surround "" "\""))) + (should (string= result "\"\"")))) + +(ert-deftest test-surround-single-character () + "Should surround single character." + (let ((result (test-surround "x" "\""))) + (should (string= result "\"x\"")))) + +(ert-deftest test-surround-empty-surround-string () + "Should handle empty surround string." + (let ((result (test-surround "hello" ""))) + (should (string= result "hello")))) + +(ert-deftest test-surround-very-long-text () + "Should surround very long text." + (let* ((long-text (make-string 1000 ?a)) + (result (test-surround long-text "\""))) + (should (string-prefix-p "\"" result)) + (should (string-suffix-p "\"" result)) + (should (= (length result) 1002)))) + +(ert-deftest test-surround-whitespace-only () + "Should surround whitespace-only text." + (let ((result (test-surround " " "\""))) + (should (string= result "\" \"")))) + +(ert-deftest test-surround-tabs () + "Should surround text with tabs." + (let ((result (test-surround "\t\ttext\t\t" "\""))) + (should (string= result "\"\t\ttext\t\t\"")))) + +;;; Edge Cases - Already Surrounded + +(ert-deftest test-surround-already-quoted () + "Should surround text that is already quoted." + (let ((result (test-surround "\"hello\"" "\""))) + (should (string= result "\"\"hello\"\"")))) + +(ert-deftest test-surround-nested () + "Should surround text creating nested delimiters." + (let ((result (test-surround "'inner'" "\""))) + (should (string= result "\"'inner'\"")))) + +;;; Edge Cases - Special Surround Strings + +(ert-deftest test-surround-space () + "Should surround text with spaces." + (let ((result (test-surround "text" " "))) + (should (string= result " text ")))) + +(ert-deftest test-surround-newline () + "Should surround text with newlines." + (let ((result (test-surround "text" "\n"))) + (should (string= result "\ntext\n")))) + +(ert-deftest test-surround-mixed-delimiters () + "Should surround with mixed delimiter string." + (let ((result (test-surround "content" "<>"))) + (should (string= result "<>content<>")))) + +(provide 'test-custom-text-enclose-surround) +;;; test-custom-text-enclose-surround.el ends here diff --git a/tests/test-custom-text-enclose-unwrap.el b/tests/test-custom-text-enclose-unwrap.el new file mode 100644 index 00000000..a308b644 --- /dev/null +++ b/tests/test-custom-text-enclose-unwrap.el @@ -0,0 +1,266 @@ +;;; test-custom-text-enclose-unwrap.el --- Tests for cj/--unwrap -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--unwrap function from custom-text-enclose.el +;; +;; This function removes surrounding delimiters from text. +;; It checks if text starts with opening and ends with closing, +;; and if so, removes them. +;; +;; Examples: +;; Input: "(text)", opening: "(", closing: ")" +;; Output: "text" +;; +;; Input: "<div>content</div>", opening: "<div>", closing: "</div>" +;; Output: "content" +;; +;; We test the NON-INTERACTIVE implementation (cj/--unwrap) to avoid +;; mocking user input. 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-text-enclose) + +;;; Test Helpers + +(defun test-unwrap (text opening closing) + "Test cj/--unwrap on TEXT with OPENING and CLOSING. +Returns the transformed string." + (cj/--unwrap text opening closing)) + +;;; Normal Cases - Common Bracket Types + +(ert-deftest test-unwrap-parentheses () + "Should unwrap text with parentheses." + (let ((result (test-unwrap "(text)" "(" ")"))) + (should (string= result "text")))) + +(ert-deftest test-unwrap-square-brackets () + "Should unwrap text with square brackets." + (let ((result (test-unwrap "[item]" "[" "]"))) + (should (string= result "item")))) + +(ert-deftest test-unwrap-curly-braces () + "Should unwrap text with curly braces." + (let ((result (test-unwrap "{code}" "{" "}"))) + (should (string= result "code")))) + +(ert-deftest test-unwrap-angle-brackets () + "Should unwrap text with angle brackets." + (let ((result (test-unwrap "<tag>" "<" ">"))) + (should (string= result "tag")))) + +;;; Normal Cases - HTML/XML Tags + +(ert-deftest test-unwrap-html-div () + "Should unwrap HTML div tags." + (let ((result (test-unwrap "<div>content</div>" "<div>" "</div>"))) + (should (string= result "content")))) + +(ert-deftest test-unwrap-html-span () + "Should unwrap HTML span tags." + (let ((result (test-unwrap "<span>text</span>" "<span>" "</span>"))) + (should (string= result "text")))) + +(ert-deftest test-unwrap-xml-tag () + "Should unwrap XML tags." + (let ((result (test-unwrap "<item>data</item>" "<item>" "</item>"))) + (should (string= result "data")))) + +(ert-deftest test-unwrap-html-with-attributes () + "Should unwrap HTML tag containing attributes." + (let ((result (test-unwrap "<a href=\"url\">link</a>" "<a href=\"url\">" "</a>"))) + (should (string= result "link")))) + +;;; Normal Cases - Markdown Syntax + +(ert-deftest test-unwrap-markdown-bold () + "Should unwrap markdown bold syntax." + (let ((result (test-unwrap "**bold**" "**" "**"))) + (should (string= result "bold")))) + +(ert-deftest test-unwrap-markdown-italic () + "Should unwrap markdown italic syntax." + (let ((result (test-unwrap "*italic*" "*" "*"))) + (should (string= result "italic")))) + +(ert-deftest test-unwrap-markdown-code () + "Should unwrap markdown code syntax." + (let ((result (test-unwrap "`code`" "`" "`"))) + (should (string= result "code")))) + +(ert-deftest test-unwrap-quotes () + "Should unwrap double quotes." + (let ((result (test-unwrap "\"text\"" "\"" "\""))) + (should (string= result "text")))) + +;;; Normal Cases - Various Content + +(ert-deftest test-unwrap-single-word () + "Should unwrap single word." + (let ((result (test-unwrap "(word)" "(" ")"))) + (should (string= result "word")))) + +(ert-deftest test-unwrap-multiple-words () + "Should unwrap multiple words." + (let ((result (test-unwrap "(hello world)" "(" ")"))) + (should (string= result "hello world")))) + +(ert-deftest test-unwrap-sentence () + "Should unwrap full sentence." + (let ((result (test-unwrap "(This is a sentence.)" "(" ")"))) + (should (string= result "This is a sentence.")))) + +(ert-deftest test-unwrap-with-numbers () + "Should unwrap text with numbers." + (let ((result (test-unwrap "[123]" "[" "]"))) + (should (string= result "123")))) + +(ert-deftest test-unwrap-with-special-chars () + "Should unwrap text with special characters." + (let ((result (test-unwrap "<hello@world.com>" "<" ">"))) + (should (string= result "hello@world.com")))) + +;;; Normal Cases - Multiline Text + +(ert-deftest test-unwrap-multiline () + "Should unwrap multiline text." + (let ((result (test-unwrap "<div>line1\nline2\nline3</div>" "<div>" "</div>"))) + (should (string= result "line1\nline2\nline3")))) + +(ert-deftest test-unwrap-text-with-newlines () + "Should unwrap text containing newlines." + (let ((result (test-unwrap "(first\nsecond)" "(" ")"))) + (should (string= result "first\nsecond")))) + +;;; Boundary Cases - No Match + +(ert-deftest test-unwrap-no-opening () + "Should not unwrap when opening is missing." + (let ((result (test-unwrap "text)" "(" ")"))) + (should (string= result "text)")))) + +(ert-deftest test-unwrap-no-closing () + "Should not unwrap when closing is missing." + (let ((result (test-unwrap "(text" "(" ")"))) + (should (string= result "(text")))) + +(ert-deftest test-unwrap-neither-delimiter () + "Should not unwrap when neither delimiter is present." + (let ((result (test-unwrap "text" "(" ")"))) + (should (string= result "text")))) + +(ert-deftest test-unwrap-wrong-opening () + "Should not unwrap with wrong opening delimiter." + (let ((result (test-unwrap "[text)" "(" ")"))) + (should (string= result "[text)")))) + +(ert-deftest test-unwrap-wrong-closing () + "Should not unwrap with wrong closing delimiter." + (let ((result (test-unwrap "(text]" "(" ")"))) + (should (string= result "(text]")))) + +;;; Boundary Cases - Empty + +(ert-deftest test-unwrap-empty-content () + "Should unwrap to empty string." + (let ((result (test-unwrap "()" "(" ")"))) + (should (string= result "")))) + +(ert-deftest test-unwrap-just-delimiters () + "Should unwrap when only delimiters present." + (let ((result (test-unwrap "[]" "[" "]"))) + (should (string= result "")))) + +(ert-deftest test-unwrap-empty-string () + "Should return empty string unchanged." + (let ((result (test-unwrap "" "(" ")"))) + (should (string= result "")))) + +(ert-deftest test-unwrap-too-short () + "Should not unwrap when text is shorter than delimiters." + (let ((result (test-unwrap "x" "<div>" "</div>"))) + (should (string= result "x")))) + +;;; Boundary Cases - Nested/Multiple + +(ert-deftest test-unwrap-nested-same () + "Should unwrap only outer layer of nested delimiters." + (let ((result (test-unwrap "((text))" "(" ")"))) + (should (string= result "(text)")))) + +(ert-deftest test-unwrap-nested-different () + "Should unwrap outer layer with different inner delimiters." + (let ((result (test-unwrap "([text])" "(" ")"))) + (should (string= result "[text]")))) + +(ert-deftest test-unwrap-multiple-in-content () + "Should not unwrap when delimiters appear in content." + (let ((result (test-unwrap "(a)b(c)" "(" ")"))) + (should (string= result "a)b(c")))) + +;;; Edge Cases - Special Delimiters + +(ert-deftest test-unwrap-asymmetric-length () + "Should unwrap with different length delimiters." + (let ((result (test-unwrap "<<text>>>" "<<" ">>>"))) + (should (string= result "text")))) + +(ert-deftest test-unwrap-multi-char-delimiters () + "Should unwrap with multi-character delimiters." + (let ((result (test-unwrap "BEGINdataEND" "BEGIN" "END"))) + (should (string= result "data")))) + +(ert-deftest test-unwrap-space-delimiters () + "Should unwrap with space delimiters." + (let ((result (test-unwrap " text " " " " "))) + (should (string= result "text")))) + +(ert-deftest test-unwrap-newline-delimiters () + "Should unwrap with newline delimiters." + (let ((result (test-unwrap "\ntext\n" "\n" "\n"))) + (should (string= result "text")))) + +;;; Edge Cases - Same Opening and Closing + +(ert-deftest test-unwrap-same-delimiters () + "Should unwrap when opening and closing are the same." + (let ((result (test-unwrap "*text*" "*" "*"))) + (should (string= result "text")))) + +(ert-deftest test-unwrap-same-multi-char () + "Should unwrap same multi-char delimiters." + (let ((result (test-unwrap "***text***" "***" "***"))) + (should (string= result "text")))) + +;;; Edge Cases - Empty Delimiters + +(ert-deftest test-unwrap-empty-opening () + "Should handle empty opening delimiter." + (let ((result (test-unwrap "text)" "" ")"))) + (should (string= result "text")))) + +(ert-deftest test-unwrap-empty-closing () + "Should handle empty closing delimiter." + (let ((result (test-unwrap "(text" "(" ""))) + (should (string= result "text")))) + +(ert-deftest test-unwrap-both-delimiters-empty () + "Should return text unchanged when both delimiters empty." + (let ((result (test-unwrap "text" "" ""))) + (should (string= result "text")))) + +(provide 'test-custom-text-enclose-unwrap) +;;; test-custom-text-enclose-unwrap.el ends here diff --git a/tests/test-custom-text-enclose-wrap.el b/tests/test-custom-text-enclose-wrap.el new file mode 100644 index 00000000..f68a0668 --- /dev/null +++ b/tests/test-custom-text-enclose-wrap.el @@ -0,0 +1,240 @@ +;;; test-custom-text-enclose-wrap.el --- Tests for cj/--wrap -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--wrap function from custom-text-enclose.el +;; +;; This function wraps text with different opening and closing strings. +;; Unlike surround which uses the same string on both sides, wrap allows +;; asymmetric delimiters. +;; +;; Examples: +;; Input: "content", opening: "<div>", closing: "</div>" +;; Output: "<div>content</div>" +;; +;; Input: "text", opening: "(", closing: ")" +;; Output: "(text)" +;; +;; We test the NON-INTERACTIVE implementation (cj/--wrap) to avoid +;; mocking user input. 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-text-enclose) + +;;; Test Helpers + +(defun test-wrap (text opening closing) + "Test cj/--wrap on TEXT with OPENING and CLOSING. +Returns the transformed string." + (cj/--wrap text opening closing)) + +;;; Normal Cases - Common Bracket Types + +(ert-deftest test-wrap-parentheses () + "Should wrap text with parentheses." + (let ((result (test-wrap "text" "(" ")"))) + (should (string= result "(text)")))) + +(ert-deftest test-wrap-square-brackets () + "Should wrap text with square brackets." + (let ((result (test-wrap "item" "[" "]"))) + (should (string= result "[item]")))) + +(ert-deftest test-wrap-curly-braces () + "Should wrap text with curly braces." + (let ((result (test-wrap "code" "{" "}"))) + (should (string= result "{code}")))) + +(ert-deftest test-wrap-angle-brackets () + "Should wrap text with angle brackets." + (let ((result (test-wrap "tag" "<" ">"))) + (should (string= result "<tag>")))) + +;;; Normal Cases - HTML/XML Tags + +(ert-deftest test-wrap-html-div () + "Should wrap text with HTML div tags." + (let ((result (test-wrap "content" "<div>" "</div>"))) + (should (string= result "<div>content</div>")))) + +(ert-deftest test-wrap-html-span () + "Should wrap text with HTML span tags." + (let ((result (test-wrap "text" "<span>" "</span>"))) + (should (string= result "<span>text</span>")))) + +(ert-deftest test-wrap-xml-tag () + "Should wrap text with XML tags." + (let ((result (test-wrap "data" "<item>" "</item>"))) + (should (string= result "<item>data</item>")))) + +(ert-deftest test-wrap-html-with-attributes () + "Should wrap text with HTML tag containing attributes." + (let ((result (test-wrap "link" "<a href=\"url\">" "</a>"))) + (should (string= result "<a href=\"url\">link</a>")))) + +;;; Normal Cases - Markdown Syntax + +(ert-deftest test-wrap-markdown-bold () + "Should wrap text with markdown bold syntax." + (let ((result (test-wrap "bold" "**" "**"))) + (should (string= result "**bold**")))) + +(ert-deftest test-wrap-markdown-italic () + "Should wrap text with markdown italic syntax." + (let ((result (test-wrap "italic" "*" "*"))) + (should (string= result "*italic*")))) + +(ert-deftest test-wrap-markdown-code () + "Should wrap text with markdown code syntax." + (let ((result (test-wrap "code" "`" "`"))) + (should (string= result "`code`")))) + +(ert-deftest test-wrap-markdown-link () + "Should wrap text with markdown link syntax." + (let ((result (test-wrap "text" "[" "](url)"))) + (should (string= result "[text](url)")))) + +;;; Normal Cases - Various Content + +(ert-deftest test-wrap-single-word () + "Should wrap single word." + (let ((result (test-wrap "word" "(" ")"))) + (should (string= result "(word)")))) + +(ert-deftest test-wrap-multiple-words () + "Should wrap multiple words." + (let ((result (test-wrap "hello world" "(" ")"))) + (should (string= result "(hello world)")))) + +(ert-deftest test-wrap-sentence () + "Should wrap full sentence." + (let ((result (test-wrap "This is a sentence." "(" ")"))) + (should (string= result "(This is a sentence.)")))) + +(ert-deftest test-wrap-with-numbers () + "Should wrap text with numbers." + (let ((result (test-wrap "123" "[" "]"))) + (should (string= result "[123]")))) + +(ert-deftest test-wrap-with-special-chars () + "Should wrap text with special characters." + (let ((result (test-wrap "hello@world.com" "<" ">"))) + (should (string= result "<hello@world.com>")))) + +;;; Normal Cases - Multiline Text + +(ert-deftest test-wrap-multiline () + "Should wrap multiline text." + (let ((result (test-wrap "line1\nline2\nline3" "<div>" "</div>"))) + (should (string= result "<div>line1\nline2\nline3</div>")))) + +(ert-deftest test-wrap-text-with-newlines () + "Should wrap text containing newlines." + (let ((result (test-wrap "first\nsecond" "(" ")"))) + (should (string= result "(first\nsecond)")))) + +;;; Boundary Cases + +(ert-deftest test-wrap-empty-string () + "Should wrap empty string." + (let ((result (test-wrap "" "(" ")"))) + (should (string= result "()")))) + +(ert-deftest test-wrap-single-character () + "Should wrap single character." + (let ((result (test-wrap "x" "[" "]"))) + (should (string= result "[x]")))) + +(ert-deftest test-wrap-empty-opening () + "Should handle empty opening delimiter." + (let ((result (test-wrap "text" "" ")"))) + (should (string= result "text)")))) + +(ert-deftest test-wrap-empty-closing () + "Should handle empty closing delimiter." + (let ((result (test-wrap "text" "(" ""))) + (should (string= result "(text")))) + +(ert-deftest test-wrap-both-empty () + "Should handle both delimiters empty." + (let ((result (test-wrap "text" "" ""))) + (should (string= result "text")))) + +(ert-deftest test-wrap-very-long-text () + "Should wrap very long text." + (let* ((long-text (make-string 1000 ?a)) + (result (test-wrap long-text "(" ")"))) + (should (string-prefix-p "(" result)) + (should (string-suffix-p ")" result)) + (should (= (length result) 1002)))) + +(ert-deftest test-wrap-whitespace-only () + "Should wrap whitespace-only text." + (let ((result (test-wrap " " "(" ")"))) + (should (string= result "( )")))) + +(ert-deftest test-wrap-tabs () + "Should wrap text with tabs." + (let ((result (test-wrap "\t\ttext\t\t" "[" "]"))) + (should (string= result "[\t\ttext\t\t]")))) + +;;; Edge Cases - Already Wrapped + +(ert-deftest test-wrap-already-wrapped () + "Should wrap text that is already wrapped." + (let ((result (test-wrap "(hello)" "[" "]"))) + (should (string= result "[(hello)]")))) + +(ert-deftest test-wrap-nested () + "Should wrap text creating nested delimiters." + (let ((result (test-wrap "[inner]" "(" ")"))) + (should (string= result "([inner])")))) + +;;; Edge Cases - Special Delimiters + +(ert-deftest test-wrap-asymmetric-length () + "Should wrap with different length delimiters." + (let ((result (test-wrap "text" "<<" ">>>"))) + (should (string= result "<<text>>>")))) + +(ert-deftest test-wrap-multi-char-delimiters () + "Should wrap with multi-character delimiters." + (let ((result (test-wrap "data" "BEGIN" "END"))) + (should (string= result "BEGINdataEND")))) + +(ert-deftest test-wrap-space-delimiters () + "Should wrap with space delimiters." + (let ((result (test-wrap "text" " " " "))) + (should (string= result " text ")))) + +(ert-deftest test-wrap-newline-delimiters () + "Should wrap with newline delimiters." + (let ((result (test-wrap "text" "\n" "\n"))) + (should (string= result "\ntext\n")))) + +(ert-deftest test-wrap-quote-delimiters () + "Should wrap with quote delimiters." + (let ((result (test-wrap "text" "\"" "\""))) + (should (string= result "\"text\"")))) + +;;; Edge Cases - Same Opening and Closing + +(ert-deftest test-wrap-same-delimiters () + "Should work like surround when delimiters are the same." + (let ((result (test-wrap "text" "*" "*"))) + (should (string= result "*text*")))) + +(provide 'test-custom-text-enclose-wrap) +;;; test-custom-text-enclose-wrap.el ends here diff --git a/tests/test-custom-whitespace-collapse.el b/tests/test-custom-whitespace-collapse.el new file mode 100644 index 00000000..40face95 --- /dev/null +++ b/tests/test-custom-whitespace-collapse.el @@ -0,0 +1,150 @@ +;;; test-custom-whitespace-collapse.el --- Tests for cj/--collapse-whitespace -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--collapse-whitespace function from custom-whitespace.el +;; +;; This function collapses whitespace in text by: +;; - Converting all tabs to spaces +;; - Removing leading and trailing whitespace +;; - Collapsing multiple consecutive spaces to single space +;; - Preserving newlines and text structure +;; +;; We test the NON-INTERACTIVE implementation (cj/--collapse-whitespace) +;; 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-whitespace) + +;;; Test Helpers + +(defun test-collapse-whitespace (input-text) + "Test cj/--collapse-whitespace on INPUT-TEXT. +Returns the buffer string after operation." + (with-temp-buffer + (insert input-text) + (cj/--collapse-whitespace (point-min) (point-max)) + (buffer-string))) + +;;; Normal Cases + +(ert-deftest test-collapse-whitespace-multiple-spaces () + "Should collapse multiple spaces to single space." + (let ((result (test-collapse-whitespace "hello world"))) + (should (string= result "hello world")))) + +(ert-deftest test-collapse-whitespace-multiple-tabs () + "Should convert tabs to spaces and collapse." + (let ((result (test-collapse-whitespace "hello\t\t\tworld"))) + (should (string= result "hello world")))) + +(ert-deftest test-collapse-whitespace-mixed-tabs-spaces () + "Should handle mixed tabs and spaces." + (let ((result (test-collapse-whitespace "hello \t \t world"))) + (should (string= result "hello world")))) + +(ert-deftest test-collapse-whitespace-leading-trailing () + "Should remove leading and trailing whitespace." + (let ((result (test-collapse-whitespace " hello world "))) + (should (string= result "hello world")))) + +(ert-deftest test-collapse-whitespace-tabs-leading-trailing () + "Should remove leading and trailing tabs." + (let ((result (test-collapse-whitespace "\t\thello world\t\t"))) + (should (string= result "hello world")))) + +(ert-deftest test-collapse-whitespace-multiple-words () + "Should collapse spaces between multiple words." + (let ((result (test-collapse-whitespace "one two three four"))) + (should (string= result "one two three four")))) + +(ert-deftest test-collapse-whitespace-preserve-newlines () + "Should preserve newlines while collapsing spaces." + (let ((result (test-collapse-whitespace "hello world\nfoo bar"))) + (should (string= result "hello world\nfoo bar")))) + +(ert-deftest test-collapse-whitespace-multiple-lines () + "Should handle multiple lines with various whitespace." + (let ((result (test-collapse-whitespace " hello world \n\t\tfoo bar\t\t"))) + (should (string= result "hello world\nfoo bar")))) + +;;; Boundary Cases + +(ert-deftest test-collapse-whitespace-empty-string () + "Should handle empty string." + (let ((result (test-collapse-whitespace ""))) + (should (string= result "")))) + +(ert-deftest test-collapse-whitespace-single-char () + "Should handle single character with surrounding spaces." + (let ((result (test-collapse-whitespace " x "))) + (should (string= result "x")))) + +(ert-deftest test-collapse-whitespace-only-whitespace () + "Should handle text with only whitespace (becomes empty)." + (let ((result (test-collapse-whitespace " \t \t "))) + (should (string= result "")))) + +(ert-deftest test-collapse-whitespace-no-extra-whitespace () + "Should handle text with no extra whitespace (no-op)." + (let ((result (test-collapse-whitespace "hello world"))) + (should (string= result "hello world")))) + +(ert-deftest test-collapse-whitespace-single-space () + "Should handle text with already-collapsed spaces (no-op)." + (let ((result (test-collapse-whitespace "one two three"))) + (should (string= result "one two three")))) + +(ert-deftest test-collapse-whitespace-very-long-line () + "Should handle very long lines with many spaces." + (let ((result (test-collapse-whitespace "word word word word word"))) + (should (string= result "word word word word word")))) + +(ert-deftest test-collapse-whitespace-multiple-newlines () + "Should preserve multiple newlines while removing spaces." + (let ((result (test-collapse-whitespace "hello world\n\n\nfoo bar"))) + (should (string= result "hello world\n\n\nfoo bar")))) + +(ert-deftest test-collapse-whitespace-spaces-around-newlines () + "Should remove spaces before/after newlines." + (let ((result (test-collapse-whitespace "hello \n world"))) + (should (string= result "hello\nworld")))) + +(ert-deftest test-collapse-whitespace-empty-lines () + "Should handle empty lines (lines become empty after whitespace removal)." + (let ((result (test-collapse-whitespace "line1\n \nline2"))) + (should (string= result "line1\n\nline2")))) + +;;; Error Cases + +(ert-deftest test-collapse-whitespace-start-greater-than-end () + "Should error when start > end." + (should-error + (with-temp-buffer + (insert "hello world") + (cj/--collapse-whitespace (point-max) (point-min))) + :type 'error)) + +(ert-deftest test-collapse-whitespace-empty-region () + "Should handle empty region (start == end) without error." + (with-temp-buffer + (insert "hello world") + (let ((pos (/ (+ (point-min) (point-max)) 2))) + (cj/--collapse-whitespace pos pos) + ;; Should complete without error and not change buffer + (should (string= (buffer-string) "hello world"))))) + +(provide 'test-custom-whitespace-collapse) +;;; test-custom-whitespace-collapse.el ends here diff --git a/tests/test-custom-whitespace-delete-all.el b/tests/test-custom-whitespace-delete-all.el new file mode 100644 index 00000000..00abb1d4 --- /dev/null +++ b/tests/test-custom-whitespace-delete-all.el @@ -0,0 +1,150 @@ +;;; test-custom-whitespace-delete-all.el --- Tests for cj/--delete-all-whitespace -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--delete-all-whitespace function from custom-whitespace.el +;; +;; This function removes ALL whitespace characters from the region: +;; spaces, tabs, newlines, and carriage returns. Useful for creating +;; compact identifiers or removing all formatting. +;; +;; Uses the regexp [ \t\n\r]+ to match all whitespace. +;; +;; We test the NON-INTERACTIVE implementation (cj/--delete-all-whitespace) +;; 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-whitespace) + +;;; Test Helpers + +(defun test-delete-all-whitespace (input-text) + "Test cj/--delete-all-whitespace on INPUT-TEXT. +Returns the buffer string after operation." + (with-temp-buffer + (insert input-text) + (cj/--delete-all-whitespace (point-min) (point-max)) + (buffer-string))) + +;;; Normal Cases + +(ert-deftest test-delete-all-whitespace-single-space () + "Should remove single space." + (let ((result (test-delete-all-whitespace "hello world"))) + (should (string= result "helloworld")))) + +(ert-deftest test-delete-all-whitespace-multiple-spaces () + "Should remove multiple spaces." + (let ((result (test-delete-all-whitespace "hello world"))) + (should (string= result "helloworld")))) + +(ert-deftest test-delete-all-whitespace-tabs () + "Should remove tabs." + (let ((result (test-delete-all-whitespace "hello\tworld"))) + (should (string= result "helloworld")))) + +(ert-deftest test-delete-all-whitespace-newlines () + "Should remove newlines (joining lines)." + (let ((result (test-delete-all-whitespace "hello\nworld"))) + (should (string= result "helloworld")))) + +(ert-deftest test-delete-all-whitespace-mixed () + "Should remove all types of whitespace." + (let ((result (test-delete-all-whitespace "hello \t\n world"))) + (should (string= result "helloworld")))) + +(ert-deftest test-delete-all-whitespace-multiple-words () + "Should remove whitespace from multiple words." + (let ((result (test-delete-all-whitespace "one two three four"))) + (should (string= result "onetwothreefour")))) + +(ert-deftest test-delete-all-whitespace-multiline () + "Should remove all whitespace across multiple lines." + (let ((result (test-delete-all-whitespace "line1\nline2\nline3"))) + (should (string= result "line1line2line3")))) + +(ert-deftest test-delete-all-whitespace-leading-trailing () + "Should remove leading and trailing whitespace." + (let ((result (test-delete-all-whitespace " hello world "))) + (should (string= result "helloworld")))) + +(ert-deftest test-delete-all-whitespace-carriage-returns () + "Should handle carriage returns." + (let ((result (test-delete-all-whitespace "hello\r\nworld"))) + (should (string= result "helloworld")))) + +;;; Boundary Cases + +(ert-deftest test-delete-all-whitespace-empty-string () + "Should handle empty string." + (let ((result (test-delete-all-whitespace ""))) + (should (string= result "")))) + +(ert-deftest test-delete-all-whitespace-no-whitespace () + "Should handle text with no whitespace (no-op)." + (let ((result (test-delete-all-whitespace "helloworld"))) + (should (string= result "helloworld")))) + +(ert-deftest test-delete-all-whitespace-only-whitespace () + "Should delete all content when only whitespace exists." + (let ((result (test-delete-all-whitespace " \t \n "))) + (should (string= result "")))) + +(ert-deftest test-delete-all-whitespace-single-char () + "Should handle single character with surrounding whitespace." + (let ((result (test-delete-all-whitespace " x "))) + (should (string= result "x")))) + +(ert-deftest test-delete-all-whitespace-very-long-text () + "Should handle very long text." + (let ((result (test-delete-all-whitespace "word word word word word word word word"))) + (should (string= result "wordwordwordwordwordwordwordword")))) + +(ert-deftest test-delete-all-whitespace-single-whitespace () + "Should delete single whitespace character." + (let ((result (test-delete-all-whitespace " "))) + (should (string= result "")))) + +(ert-deftest test-delete-all-whitespace-consecutive-newlines () + "Should remove all consecutive newlines." + (let ((result (test-delete-all-whitespace "hello\n\n\nworld"))) + (should (string= result "helloworld")))) + +(ert-deftest test-delete-all-whitespace-complex-structure () + "Should handle complex whitespace patterns." + (let ((result (test-delete-all-whitespace " hello\n\t world \n foo\t\tbar "))) + (should (string= result "helloworldfoobar")))) + +;;; Error Cases + +(ert-deftest test-delete-all-whitespace-start-greater-than-end () + "Should error when start > end." + (should-error + (with-temp-buffer + (insert "hello world") + (cj/--delete-all-whitespace (point-max) (point-min))) + :type 'error)) + +(ert-deftest test-delete-all-whitespace-empty-region () + "Should handle empty region (start == end) without error." + (with-temp-buffer + (insert "hello world") + (let ((pos (/ (+ (point-min) (point-max)) 2))) + (cj/--delete-all-whitespace pos pos) + ;; Should complete without error and not change buffer + (should (string= (buffer-string) "hello world"))))) + +(provide 'test-custom-whitespace-delete-all) +;;; test-custom-whitespace-delete-all.el ends here diff --git a/tests/test-custom-whitespace-delete-blank-lines.el b/tests/test-custom-whitespace-delete-blank-lines.el new file mode 100644 index 00000000..2d250521 --- /dev/null +++ b/tests/test-custom-whitespace-delete-blank-lines.el @@ -0,0 +1,146 @@ +;;; test-custom-whitespace-delete-blank-lines.el --- Tests for cj/--delete-blank-lines -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--delete-blank-lines function from custom-whitespace.el +;; +;; This function deletes blank lines from text, where blank lines are defined +;; as lines containing only whitespace (spaces, tabs) or nothing at all. +;; Uses the regexp ^[[:space:]]*$ to match blank lines. +;; +;; We test the NON-INTERACTIVE implementation (cj/--delete-blank-lines) +;; to avoid mocking user prompts. 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-whitespace) + +;;; Test Helpers + +(defun test-delete-blank-lines (input-text) + "Test cj/--delete-blank-lines on INPUT-TEXT. +Returns the buffer string after operation." + (with-temp-buffer + (insert input-text) + (cj/--delete-blank-lines (point-min) (point-max)) + (buffer-string))) + +;;; Normal Cases + +(ert-deftest test-delete-blank-lines-single-blank () + "Should delete single blank line between text." + (let ((result (test-delete-blank-lines "line1\n\nline2"))) + (should (string= result "line1\nline2")))) + +(ert-deftest test-delete-blank-lines-multiple-consecutive () + "Should delete multiple consecutive blank lines." + (let ((result (test-delete-blank-lines "line1\n\n\n\nline2"))) + (should (string= result "line1\nline2")))) + +(ert-deftest test-delete-blank-lines-spaces-only () + "Should delete lines with spaces only." + (let ((result (test-delete-blank-lines "line1\n \nline2"))) + (should (string= result "line1\nline2")))) + +(ert-deftest test-delete-blank-lines-tabs-only () + "Should delete lines with tabs only." + (let ((result (test-delete-blank-lines "line1\n\t\t\nline2"))) + (should (string= result "line1\nline2")))) + +(ert-deftest test-delete-blank-lines-mixed-whitespace () + "Should delete lines with mixed whitespace." + (let ((result (test-delete-blank-lines "line1\n \t \t \nline2"))) + (should (string= result "line1\nline2")))) + +(ert-deftest test-delete-blank-lines-no-blank-lines () + "Should handle text with no blank lines (no-op)." + (let ((result (test-delete-blank-lines "line1\nline2\nline3"))) + (should (string= result "line1\nline2\nline3")))) + +(ert-deftest test-delete-blank-lines-at-start () + "Should delete blank lines at start of region." + (let ((result (test-delete-blank-lines "\n\nline1\nline2"))) + (should (string= result "line1\nline2")))) + +(ert-deftest test-delete-blank-lines-at-end () + "Should delete blank lines at end of region." + (let ((result (test-delete-blank-lines "line1\nline2\n\n"))) + (should (string= result "line1\nline2\n")))) + +(ert-deftest test-delete-blank-lines-scattered () + "Should delete blank lines scattered throughout text." + (let ((result (test-delete-blank-lines "line1\n\nline2\n \nline3\n\t\nline4"))) + (should (string= result "line1\nline2\nline3\nline4")))) + +;;; Boundary Cases + +(ert-deftest test-delete-blank-lines-empty-string () + "Should handle empty string." + (let ((result (test-delete-blank-lines ""))) + (should (string= result "")))) + +(ert-deftest test-delete-blank-lines-only-blank-lines () + "Should delete all lines if only blank lines exist." + (let ((result (test-delete-blank-lines "\n\n\n"))) + (should (string= result "")))) + +(ert-deftest test-delete-blank-lines-only-whitespace () + "Should delete lines containing only whitespace." + (let ((result (test-delete-blank-lines " \n\t\t\n \t "))) + (should (string= result "")))) + +(ert-deftest test-delete-blank-lines-single-line-content () + "Should handle single line with content (no-op)." + (let ((result (test-delete-blank-lines "hello world"))) + (should (string= result "hello world")))) + +(ert-deftest test-delete-blank-lines-single-blank-line () + "Should delete single blank line." + (let ((result (test-delete-blank-lines "\n"))) + (should (string= result "")))) + +(ert-deftest test-delete-blank-lines-very-long-region () + "Should handle very long region with many blank lines." + (let* ((lines (make-list 100 "content")) + (input (mapconcat #'identity lines "\n\n")) + (expected (mapconcat #'identity lines "\n")) + (result (test-delete-blank-lines input))) + (should (string= result expected)))) + +(ert-deftest test-delete-blank-lines-preserve-content-lines () + "Should preserve lines with any non-whitespace content." + (let ((result (test-delete-blank-lines "x\n\ny\n \nz"))) + (should (string= result "x\ny\nz")))) + +;;; Error Cases + +(ert-deftest test-delete-blank-lines-start-greater-than-end () + "Should error when start > end." + (should-error + (with-temp-buffer + (insert "line1\n\nline2") + (cj/--delete-blank-lines (point-max) (point-min))) + :type 'error)) + +(ert-deftest test-delete-blank-lines-empty-region () + "Should handle empty region (start == end) without error." + (with-temp-buffer + (insert "line1\n\nline2") + (let ((pos (/ (+ (point-min) (point-max)) 2))) + (cj/--delete-blank-lines pos pos) + ;; Should complete without error + (should (string-match-p "line1" (buffer-string)))))) + +(provide 'test-custom-whitespace-delete-blank-lines) +;;; test-custom-whitespace-delete-blank-lines.el ends here diff --git a/tests/test-custom-whitespace-ensure-single-blank.el b/tests/test-custom-whitespace-ensure-single-blank.el new file mode 100644 index 00000000..7cd03e79 --- /dev/null +++ b/tests/test-custom-whitespace-ensure-single-blank.el @@ -0,0 +1,146 @@ +;;; test-custom-whitespace-ensure-single-blank.el --- Tests for cj/--ensure-single-blank-line -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--ensure-single-blank-line function from custom-whitespace.el +;; +;; This function collapses multiple consecutive blank lines to exactly one blank line. +;; Different from delete-blank-lines which removes ALL blank lines, this function +;; preserves blank lines but ensures no more than one blank line appears consecutively. +;; +;; A blank line is defined as a line containing only whitespace (spaces, tabs) or nothing. +;; Uses the regexp (^[[:space:]]*$\n){2,} to match 2+ consecutive blank lines. +;; +;; We test the NON-INTERACTIVE implementation (cj/--ensure-single-blank-line) +;; to avoid mocking user prompts. 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-whitespace) + +;;; Test Helpers + +(defun test-ensure-single-blank-line (input-text) + "Test cj/--ensure-single-blank-line on INPUT-TEXT. +Returns the buffer string after operation." + (with-temp-buffer + (insert input-text) + (cj/--ensure-single-blank-line (point-min) (point-max)) + (buffer-string))) + +;;; Normal Cases + +(ert-deftest test-ensure-single-blank-two-blanks () + "Should collapse two blank lines to one." + (let ((result (test-ensure-single-blank-line "line1\n\n\nline2"))) + (should (string= result "line1\n\nline2")))) + +(ert-deftest test-ensure-single-blank-three-blanks () + "Should collapse three blank lines to one." + (let ((result (test-ensure-single-blank-line "line1\n\n\n\nline2"))) + (should (string= result "line1\n\nline2")))) + +(ert-deftest test-ensure-single-blank-many-blanks () + "Should collapse many blank lines to one." + (let ((result (test-ensure-single-blank-line "line1\n\n\n\n\n\n\nline2"))) + (should (string= result "line1\n\nline2")))) + +(ert-deftest test-ensure-single-blank-preserve-single () + "Should preserve single blank lines (no-op)." + (let ((result (test-ensure-single-blank-line "line1\n\nline2"))) + (should (string= result "line1\n\nline2")))) + +(ert-deftest test-ensure-single-blank-multiple-groups () + "Should handle multiple groups of consecutive blanks." + (let ((result (test-ensure-single-blank-line "line1\n\n\nline2\n\n\n\nline3"))) + (should (string= result "line1\n\nline2\n\nline3")))) + +(ert-deftest test-ensure-single-blank-blanks-with-spaces () + "Should handle blank lines with spaces only." + (let ((result (test-ensure-single-blank-line "line1\n \n \nline2"))) + (should (string= result "line1\n\nline2")))) + +(ert-deftest test-ensure-single-blank-blanks-with-tabs () + "Should handle blank lines with tabs only." + (let ((result (test-ensure-single-blank-line "line1\n\t\t\n\t\t\nline2"))) + (should (string= result "line1\n\nline2")))) + +(ert-deftest test-ensure-single-blank-mixed-whitespace () + "Should handle blank lines with mixed whitespace." + (let ((result (test-ensure-single-blank-line "line1\n \t \n \t \nline2"))) + (should (string= result "line1\n\nline2")))) + +(ert-deftest test-ensure-single-blank-no-blanks () + "Should handle text with no blank lines (no-op)." + (let ((result (test-ensure-single-blank-line "line1\nline2\nline3"))) + (should (string= result "line1\nline2\nline3")))) + +;;; Boundary Cases + +(ert-deftest test-ensure-single-blank-empty-string () + "Should handle empty string." + (let ((result (test-ensure-single-blank-line ""))) + (should (string= result "")))) + +(ert-deftest test-ensure-single-blank-only-blanks () + "Should collapse many blank lines to one blank line." + (let ((result (test-ensure-single-blank-line "\n\n\n\n"))) + (should (string= result "\n\n")))) + +(ert-deftest test-ensure-single-blank-at-start () + "Should collapse multiple blank lines at start to one." + (let ((result (test-ensure-single-blank-line "\n\n\nline1"))) + (should (string= result "\n\nline1")))) + +(ert-deftest test-ensure-single-blank-at-end () + "Should collapse multiple blank lines at end to one." + (let ((result (test-ensure-single-blank-line "line1\n\n\n"))) + (should (string= result "line1\n\n")))) + +(ert-deftest test-ensure-single-blank-single-line () + "Should handle single line (no-op)." + (let ((result (test-ensure-single-blank-line "line1"))) + (should (string= result "line1")))) + +(ert-deftest test-ensure-single-blank-complex-structure () + "Should handle complex mix of content and blanks." + (let ((result (test-ensure-single-blank-line "line1\n\n\nline2\nline3\n\n\n\nline4"))) + (should (string= result "line1\n\nline2\nline3\n\nline4")))) + +(ert-deftest test-ensure-single-blank-preserves-content () + "Should not modify lines with content." + (let ((result (test-ensure-single-blank-line " line1 \n\n\n line2 "))) + (should (string= result " line1 \n\n line2 ")))) + +;;; Error Cases + +(ert-deftest test-ensure-single-blank-start-greater-than-end () + "Should error when start > end." + (should-error + (with-temp-buffer + (insert "line1\n\n\nline2") + (cj/--ensure-single-blank-line (point-max) (point-min))) + :type 'error)) + +(ert-deftest test-ensure-single-blank-empty-region () + "Should handle empty region (start == end) without error." + (with-temp-buffer + (insert "line1\n\n\nline2") + (let ((pos (/ (+ (point-min) (point-max)) 2))) + (cj/--ensure-single-blank-line pos pos) + ;; Should complete without error + (should (string-match-p "line1" (buffer-string)))))) + +(provide 'test-custom-whitespace-ensure-single-blank) +;;; test-custom-whitespace-ensure-single-blank.el ends here diff --git a/tests/test-custom-whitespace-hyphenate.el b/tests/test-custom-whitespace-hyphenate.el new file mode 100644 index 00000000..03462fab --- /dev/null +++ b/tests/test-custom-whitespace-hyphenate.el @@ -0,0 +1,140 @@ +;;; test-custom-whitespace-hyphenate.el --- Tests for cj/--hyphenate-whitespace -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--hyphenate-whitespace function from custom-whitespace.el +;; +;; This function replaces all runs of whitespace (spaces, tabs, newlines, +;; carriage returns) with single hyphens. Useful for converting text with +;; whitespace into hyphenated identifiers or URLs. +;; +;; Uses the regexp [ \t\n\r]+ to match whitespace runs. +;; +;; We test the NON-INTERACTIVE implementation (cj/--hyphenate-whitespace) +;; 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-whitespace) + +;;; Test Helpers + +(defun test-hyphenate-whitespace (input-text) + "Test cj/--hyphenate-whitespace on INPUT-TEXT. +Returns the buffer string after operation." + (with-temp-buffer + (insert input-text) + (cj/--hyphenate-whitespace (point-min) (point-max)) + (buffer-string))) + +;;; Normal Cases + +(ert-deftest test-hyphenate-whitespace-single-space () + "Should replace single space with hyphen." + (let ((result (test-hyphenate-whitespace "hello world"))) + (should (string= result "hello-world")))) + +(ert-deftest test-hyphenate-whitespace-multiple-spaces () + "Should replace multiple spaces with single hyphen." + (let ((result (test-hyphenate-whitespace "hello world"))) + (should (string= result "hello-world")))) + +(ert-deftest test-hyphenate-whitespace-tabs () + "Should replace tabs with hyphen." + (let ((result (test-hyphenate-whitespace "hello\tworld"))) + (should (string= result "hello-world")))) + +(ert-deftest test-hyphenate-whitespace-mixed-tabs-spaces () + "Should replace mixed tabs and spaces with single hyphen." + (let ((result (test-hyphenate-whitespace "hello \t world"))) + (should (string= result "hello-world")))) + +(ert-deftest test-hyphenate-whitespace-newlines () + "Should replace newlines with hyphen (joining lines)." + (let ((result (test-hyphenate-whitespace "hello\nworld"))) + (should (string= result "hello-world")))) + +(ert-deftest test-hyphenate-whitespace-multiple-newlines () + "Should replace multiple newlines with single hyphen." + (let ((result (test-hyphenate-whitespace "hello\n\n\nworld"))) + (should (string= result "hello-world")))) + +(ert-deftest test-hyphenate-whitespace-multiple-words () + "Should hyphenate multiple words with various whitespace." + (let ((result (test-hyphenate-whitespace "one two three\tfour\nfive"))) + (should (string= result "one-two-three-four-five")))) + +(ert-deftest test-hyphenate-whitespace-carriage-returns () + "Should handle carriage returns." + (let ((result (test-hyphenate-whitespace "hello\r\nworld"))) + (should (string= result "hello-world")))) + +;;; Boundary Cases + +(ert-deftest test-hyphenate-whitespace-empty-string () + "Should handle empty string." + (let ((result (test-hyphenate-whitespace ""))) + (should (string= result "")))) + +(ert-deftest test-hyphenate-whitespace-no-whitespace () + "Should handle text with no whitespace (no-op)." + (let ((result (test-hyphenate-whitespace "helloworld"))) + (should (string= result "helloworld")))) + +(ert-deftest test-hyphenate-whitespace-only-whitespace () + "Should convert text with only whitespace to single hyphen." + (let ((result (test-hyphenate-whitespace " \t \n "))) + (should (string= result "-")))) + +(ert-deftest test-hyphenate-whitespace-single-char () + "Should handle single character with surrounding spaces." + (let ((result (test-hyphenate-whitespace " x "))) + (should (string= result "-x-")))) + +(ert-deftest test-hyphenate-whitespace-very-long-text () + "Should handle very long text with many spaces." + (let ((result (test-hyphenate-whitespace "word word word word word word word word"))) + (should (string= result "word-word-word-word-word-word-word-word")))) + +(ert-deftest test-hyphenate-whitespace-leading-whitespace () + "Should replace leading whitespace with hyphen." + (let ((result (test-hyphenate-whitespace " hello world"))) + (should (string= result "-hello-world")))) + +(ert-deftest test-hyphenate-whitespace-trailing-whitespace () + "Should replace trailing whitespace with hyphen." + (let ((result (test-hyphenate-whitespace "hello world "))) + (should (string= result "hello-world-")))) + +;;; Error Cases + +(ert-deftest test-hyphenate-whitespace-start-greater-than-end () + "Should error when start > end." + (should-error + (with-temp-buffer + (insert "hello world") + (cj/--hyphenate-whitespace (point-max) (point-min))) + :type 'error)) + +(ert-deftest test-hyphenate-whitespace-empty-region () + "Should handle empty region (start == end) without error." + (with-temp-buffer + (insert "hello world") + (let ((pos (/ (+ (point-min) (point-max)) 2))) + (cj/--hyphenate-whitespace pos pos) + ;; Should complete without error and not change buffer + (should (string= (buffer-string) "hello world"))))) + +(provide 'test-custom-whitespace-hyphenate) +;;; test-custom-whitespace-hyphenate.el ends here diff --git a/tests/test-custom-whitespace-remove-leading-trailing.el b/tests/test-custom-whitespace-remove-leading-trailing.el new file mode 100644 index 00000000..5a846e7f --- /dev/null +++ b/tests/test-custom-whitespace-remove-leading-trailing.el @@ -0,0 +1,157 @@ +;;; test-custom-whitespace-remove-leading-trailing.el --- Tests for cj/--remove-leading-trailing-whitespace -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--remove-leading-trailing-whitespace function from custom-whitespace.el +;; +;; This function removes leading and trailing whitespace (spaces and tabs) from text. +;; - Removes leading whitespace: ^[ \t]+ +;; - Removes trailing whitespace: [ \t]+$ +;; - Preserves interior whitespace +;; - Operates on any region defined by START and END +;; +;; We test the NON-INTERACTIVE implementation (cj/--remove-leading-trailing-whitespace) +;; to avoid mocking region selection and 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-whitespace) + +;;; Test Helpers + +(defun test-remove-leading-trailing (input-text) + "Test cj/--remove-leading-trailing-whitespace on INPUT-TEXT. +Returns the buffer string after operation." + (with-temp-buffer + (insert input-text) + (cj/--remove-leading-trailing-whitespace (point-min) (point-max)) + (buffer-string))) + +;;; Normal Cases + +(ert-deftest test-remove-leading-trailing-leading-spaces () + "Should remove leading spaces from single line." + (let ((result (test-remove-leading-trailing " hello world"))) + (should (string= result "hello world")))) + +(ert-deftest test-remove-leading-trailing-trailing-spaces () + "Should remove trailing spaces from single line." + (let ((result (test-remove-leading-trailing "hello world "))) + (should (string= result "hello world")))) + +(ert-deftest test-remove-leading-trailing-both-spaces () + "Should remove both leading and trailing spaces." + (let ((result (test-remove-leading-trailing " hello world "))) + (should (string= result "hello world")))) + +(ert-deftest test-remove-leading-trailing-leading-tabs () + "Should remove leading tabs from single line." + (let ((result (test-remove-leading-trailing "\t\thello world"))) + (should (string= result "hello world")))) + +(ert-deftest test-remove-leading-trailing-trailing-tabs () + "Should remove trailing tabs from single line." + (let ((result (test-remove-leading-trailing "hello world\t\t"))) + (should (string= result "hello world")))) + +(ert-deftest test-remove-leading-trailing-mixed-tabs-spaces () + "Should remove mixed tabs and spaces." + (let ((result (test-remove-leading-trailing " \t hello world \t "))) + (should (string= result "hello world")))) + +(ert-deftest test-remove-leading-trailing-preserve-interior () + "Should preserve interior whitespace." + (let ((result (test-remove-leading-trailing " hello world \t"))) + (should (string= result "hello world")))) + +(ert-deftest test-remove-leading-trailing-multiple-lines () + "Should handle multiple lines with leading/trailing whitespace." + (let ((result (test-remove-leading-trailing " line1 \n\t\tline2\t\n line3 "))) + (should (string= result "line1\nline2\nline3")))) + +(ert-deftest test-remove-leading-trailing-multiline-preserve-interior () + "Should preserve interior whitespace on multiple lines." + (let ((result (test-remove-leading-trailing " hello world \n foo bar "))) + (should (string= result "hello world\nfoo bar")))) + +;;; Boundary Cases + +(ert-deftest test-remove-leading-trailing-empty-string () + "Should handle empty string." + (let ((result (test-remove-leading-trailing ""))) + (should (string= result "")))) + +(ert-deftest test-remove-leading-trailing-single-char () + "Should handle single character with surrounding spaces." + (let ((result (test-remove-leading-trailing " x "))) + (should (string= result "x")))) + +(ert-deftest test-remove-leading-trailing-only-whitespace () + "Should handle lines with only whitespace." + (let ((result (test-remove-leading-trailing " \t "))) + (should (string= result "")))) + +(ert-deftest test-remove-leading-trailing-no-whitespace () + "Should handle text with no leading/trailing whitespace (no-op)." + (let ((result (test-remove-leading-trailing "hello world"))) + (should (string= result "hello world")))) + +(ert-deftest test-remove-leading-trailing-very-long-line () + "Should handle very long lines with whitespace." + (let* ((long-text (make-string 500 ?x)) + (input (concat " " long-text " ")) + (result (test-remove-leading-trailing input))) + (should (string= result long-text)))) + +(ert-deftest test-remove-leading-trailing-whitespace-between-lines () + "Should handle lines that become empty after removal." + (let ((result (test-remove-leading-trailing "line1\n \nline2"))) + (should (string= result "line1\n\nline2")))) + +(ert-deftest test-remove-leading-trailing-newlines-only () + "Should preserve newlines while removing spaces." + (let ((result (test-remove-leading-trailing "\n\n\n"))) + (should (string= result "\n\n\n")))) + +(ert-deftest test-remove-leading-trailing-partial-region () + "Should work on partial buffer region." + (with-temp-buffer + (insert " hello \n world \n test ") + ;; Only operate on middle line + (let ((start (+ (point-min) 10)) ; Start of second line + (end (+ (point-min) 19))) ; End of second line + (cj/--remove-leading-trailing-whitespace start end) + (should (string= (buffer-string) " hello \nworld\n test "))))) + +;;; Error Cases + +(ert-deftest test-remove-leading-trailing-start-greater-than-end () + "Should error when start > end." + (should-error + (with-temp-buffer + (insert "hello world") + (cj/--remove-leading-trailing-whitespace (point-max) (point-min))) + :type 'error)) + +(ert-deftest test-remove-leading-trailing-empty-region () + "Should handle empty region (start == end) without error." + (with-temp-buffer + (insert "hello world") + (let ((pos (/ (+ (point-min) (point-max)) 2))) + (cj/--remove-leading-trailing-whitespace pos pos) + ;; Should complete without error and not change buffer + (should (string= (buffer-string) "hello world"))))) + +(provide 'test-custom-whitespace-remove-leading-trailing) +;;; test-custom-whitespace-remove-leading-trailing.el ends here diff --git a/tests/test-dwim-shell-security.el b/tests/test-dwim-shell-security.el deleted file mode 100644 index 0151a7c7..00000000 --- a/tests/test-dwim-shell-security.el +++ /dev/null @@ -1,341 +0,0 @@ -;;; test-dwim-shell-security.el --- ERT tests for dwim-shell-config security functions -*- lexical-binding: t; -*- - -;; Author: Claude Code and cjennings -;; Keywords: tests, dwim-shell, security - -;;; Commentary: -;; ERT tests for security-related dwim-shell-config.el functions. -;; Tests are organized into normal, boundary, and error cases. -;; -;; These tests verify that password-protected operations: -;; - Do not expose passwords in process lists or command output -;; - Use temporary files with restrictive permissions (mode 600) -;; - Clean up temporary files after use (even on error) -;; - Properly handle edge cases and errors - -;;; Code: - -(require 'ert) -(require 'dwim-shell-config) -(require 'testutil-general) - -;;; Setup and Teardown - -(defun test-dwim-shell-security-setup () - "Set up test environment for dwim-shell-security tests." - (cj/create-test-base-dir) - ;; Create test PDF file - (setq test-pdf-file (expand-file-name "test.pdf" cj/test-base-dir)) - ;; Create minimal valid PDF (this is a minimal PDF structure) - (with-temp-file test-pdf-file - (insert "%PDF-1.4\n") - (insert "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n") - (insert "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n") - (insert "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n") - (insert "xref\n0 4\n") - (insert "0000000000 65535 f\n") - (insert "0000000009 00000 n\n") - (insert "0000000058 00000 n\n") - (insert "0000000115 00000 n\n") - (insert "trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n203\n%%EOF\n")) - ;; Create test files for archive operations - (setq test-file-1 (expand-file-name "file1.txt" cj/test-base-dir)) - (setq test-file-2 (expand-file-name "file2.txt" cj/test-base-dir)) - (with-temp-file test-file-1 (insert "Test content 1")) - (with-temp-file test-file-2 (insert "Test content 2"))) - -(defun test-dwim-shell-security-teardown () - "Clean up test environment after dwim-shell-security tests." - ;; Clean up test directory - (cj/delete-test-base-dir)) - -;;; Helper Functions - -(defun test-dwim-check-temp-file-cleanup (pattern) - "Check that no temporary files matching PATTERN remain after operation." - (let ((temp-files (directory-files temporary-file-directory nil pattern))) - (should (null temp-files)))) - -(defun test-dwim-check-file-permissions (file expected-mode) - "Check that FILE has EXPECTED-MODE permissions." - (when (file-exists-p file) - (should (equal (file-modes file) expected-mode)))) - -;;; Normal Cases - PDF Password Protect - -(ert-deftest test-dwim-pdf-password-protect-creates-temp-file-normal () - "Normal: PDF password protect creates temporary file with secure permissions." - (skip-unless (executable-find "qpdf")) - (test-dwim-shell-security-setup) - (unwind-protect - (let* ((captured-temp-file nil) - (original-make-temp-file (symbol-function 'make-temp-file))) - ;; Wrap make-temp-file to capture the temp file path - (cl-letf (((symbol-function 'make-temp-file) - (lambda (&rest args) - (setq captured-temp-file (apply original-make-temp-file args)) - captured-temp-file)) - ;; Mock read-passwd to avoid interactive prompts - ((symbol-function 'read-passwd) - (lambda (_prompt) "test-password")) - ;; Mock dwim-shell-command-on-marked-files to check behavior - ((symbol-function 'dwim-shell-command-on-marked-files) - (lambda (_description command &rest _args) - ;; Verify temp file exists with correct permissions during execution - (should (file-exists-p captured-temp-file)) - (test-dwim-check-file-permissions captured-temp-file #o600) - ;; Verify password is in temp file, not in command - (should (string-match-p captured-temp-file command)) - (should-not (string-match-p "test-password" command))))) - (cj/dwim-shell-commands-pdf-password-protect) - ;; Verify temp file is cleaned up after operation - (should-not (file-exists-p captured-temp-file)))) - (test-dwim-shell-security-teardown))) - -(ert-deftest test-dwim-pdf-password-protect-no-password-in-command-normal () - "Normal: Password does not appear in shell command string." - (skip-unless (executable-find "qpdf")) - (test-dwim-shell-security-setup) - (unwind-protect - (let ((test-password "SuperSecret123!")) - (cl-letf (((symbol-function 'read-passwd) - (lambda (_prompt) test-password)) - ((symbol-function 'dwim-shell-command-on-marked-files) - (lambda (_description command &rest _args) - ;; Password should NOT appear in command - (should-not (string-match-p test-password command)) - ;; Command should reference password file - (should (string-match-p "--password-file=" command))))) - (cj/dwim-shell-commands-pdf-password-protect))) - (test-dwim-shell-security-teardown))) - -;;; Normal Cases - PDF Password Unprotect - -(ert-deftest test-dwim-pdf-password-unprotect-creates-temp-file-normal () - "Normal: PDF password unprotect creates temporary file with secure permissions." - (skip-unless (executable-find "qpdf")) - (test-dwim-shell-security-setup) - (unwind-protect - (let* ((captured-temp-file nil) - (original-make-temp-file (symbol-function 'make-temp-file))) - (cl-letf (((symbol-function 'make-temp-file) - (lambda (&rest args) - (setq captured-temp-file (apply original-make-temp-file args)) - captured-temp-file)) - ((symbol-function 'read-passwd) - (lambda (_prompt) "test-password")) - ((symbol-function 'dwim-shell-command-on-marked-files) - (lambda (_description command &rest _args) - (should (file-exists-p captured-temp-file)) - (test-dwim-check-file-permissions captured-temp-file #o600) - (should (string-match-p captured-temp-file command)) - (should-not (string-match-p "test-password" command))))) - (cj/dwim-shell-commands-pdf-password-unprotect) - (should-not (file-exists-p captured-temp-file)))) - (test-dwim-shell-security-teardown))) - -;;; Normal Cases - Create Encrypted Archive - -(ert-deftest test-dwim-create-encrypted-zip-uses-7z-normal () - "Normal: Create encrypted archive uses 7z, not zip." - (skip-unless (executable-find "7z")) - (test-dwim-shell-security-setup) - (unwind-protect - (cl-letf (((symbol-function 'read-passwd) - (lambda (_prompt) "test-password")) - ((symbol-function 'read-string) - (lambda (_prompt &optional _default) "test-archive")) - ((symbol-function 'dwim-shell-command-on-marked-files) - (lambda (_description command &rest args) - ;; Should use 7z, not zip - (should (string-match-p "7z a" command)) - (should-not (string-match-p "zip -" command)) - ;; Should use AES encryption - (should (string-match-p "-mhe=on" command)) - ;; Verify utils parameter is 7z - (should (equal (plist-get args :utils) "7z"))))) - (cj/dwim-shell-commands-create-encrypted-zip)) - (test-dwim-shell-security-teardown))) - -(ert-deftest test-dwim-create-encrypted-zip-no-password-in-command-normal () - "Normal: Password does not appear in shell command string for archive creation." - (skip-unless (executable-find "7z")) - (test-dwim-shell-security-setup) - (unwind-protect - (let ((test-password "VerySecret456!")) - (cl-letf (((symbol-function 'read-passwd) - (lambda (_prompt) test-password)) - ((symbol-function 'read-string) - (lambda (_prompt &optional _default) "test-archive")) - ((symbol-function 'dwim-shell-command-on-marked-files) - (lambda (_description command &rest _args) - ;; Password should NOT appear directly in command - (should-not (string-match-p test-password command)) - ;; Should use cat to read from temp file - (should (string-match-p "cat" command))))) - (cj/dwim-shell-commands-create-encrypted-zip))) - (test-dwim-shell-security-teardown))) - -;;; Normal Cases - Remove Archive Encryption - -(ert-deftest test-dwim-remove-zip-encryption-uses-7z-normal () - "Normal: Remove archive encryption uses 7z for both extract and create." - (skip-unless (executable-find "7z")) - (test-dwim-shell-security-setup) - (unwind-protect - (cl-letf (((symbol-function 'read-passwd) - (lambda (_prompt) "test-password")) - ((symbol-function 'dwim-shell-command-on-marked-files) - (lambda (_description command &rest args) - ;; Should use 7z for both extract and archive - (should (string-match-p "7z x" command)) - (should (string-match-p "7z a" command)) - ;; Verify utils parameter is 7z - (should (equal (plist-get args :utils) "7z"))))) - (cj/dwim-shell-commands-remove-zip-encryption)) - (test-dwim-shell-security-teardown))) - -;;; Boundary Cases - -(ert-deftest test-dwim-pdf-password-empty-password-boundary () - "Boundary: Empty password is accepted (though qpdf may reject it)." - (skip-unless (executable-find "qpdf")) - (test-dwim-shell-security-setup) - (unwind-protect - (let ((command-executed nil)) - (cl-letf (((symbol-function 'read-passwd) - (lambda (_prompt) "")) - ((symbol-function 'dwim-shell-command-on-marked-files) - (lambda (_description _command &rest _args) - (setq command-executed t)))) - (cj/dwim-shell-commands-pdf-password-protect) - ;; Function should accept empty password (tool may reject later) - (should command-executed))) - (test-dwim-shell-security-teardown))) - -(ert-deftest test-dwim-pdf-password-special-characters-boundary () - "Boundary: Password with special characters is properly handled." - (skip-unless (executable-find "qpdf")) - (test-dwim-shell-security-setup) - (unwind-protect - (let ((special-password "p@$$w0rd!#%^&*()")) - (cl-letf (((symbol-function 'read-passwd) - (lambda (_prompt) special-password)) - ((symbol-function 'dwim-shell-command-on-marked-files) - (lambda (_description command &rest _args) - ;; Special characters should not appear in command - (should-not (string-match-p (regexp-quote special-password) command))))) - (cj/dwim-shell-commands-pdf-password-protect))) - (test-dwim-shell-security-teardown))) - -(ert-deftest test-dwim-archive-very-long-password-boundary () - "Boundary: Very long password (1000+ chars) is properly handled." - (skip-unless (executable-find "7z")) - (test-dwim-shell-security-setup) - (unwind-protect - (let ((long-password (make-string 1000 ?x)) - (captured-temp-file nil)) - (cl-letf (((symbol-function 'read-passwd) - (lambda (_prompt) long-password)) - ((symbol-function 'read-string) - (lambda (_prompt &optional _default) "test")) - ((symbol-function 'make-temp-file) - (lambda (&rest args) - (setq captured-temp-file (apply (symbol-function 'make-temp-file) args)) - captured-temp-file)) - ((symbol-function 'dwim-shell-command-on-marked-files) - (lambda (_description _command &rest _args) - ;; Verify password was written to temp file - (with-temp-buffer - (insert-file-contents captured-temp-file) - (should (equal (buffer-string) long-password)))))) - (cj/dwim-shell-commands-create-encrypted-zip))) - (test-dwim-shell-security-teardown))) - -;;; Error Cases - -(ert-deftest test-dwim-pdf-password-temp-file-cleanup-on-error-error () - "Error: Temporary file is cleaned up even when command fails." - (skip-unless (executable-find "qpdf")) - (test-dwim-shell-security-setup) - (unwind-protect - (let* ((captured-temp-file nil) - (original-make-temp-file (symbol-function 'make-temp-file))) - (cl-letf (((symbol-function 'make-temp-file) - (lambda (&rest args) - (setq captured-temp-file (apply original-make-temp-file args)) - captured-temp-file)) - ((symbol-function 'read-passwd) - (lambda (_prompt) "test-password")) - ((symbol-function 'dwim-shell-command-on-marked-files) - (lambda (_description _command &rest _args) - ;; Simulate command failure - (error "Command failed")))) - ;; Should error, but still clean up temp file - (should-error (cj/dwim-shell-commands-pdf-password-protect)) - ;; Temp file should be cleaned up despite error - (should-not (file-exists-p captured-temp-file)))) - (test-dwim-shell-security-teardown))) - -(ert-deftest test-dwim-archive-temp-file-cleanup-on-error-error () - "Error: Archive temp file cleaned up even when 7z command fails." - (skip-unless (executable-find "7z")) - (test-dwim-shell-security-setup) - (unwind-protect - (let* ((captured-temp-file nil) - (original-make-temp-file (symbol-function 'make-temp-file))) - (cl-letf (((symbol-function 'make-temp-file) - (lambda (&rest args) - (setq captured-temp-file (apply original-make-temp-file args)) - captured-temp-file)) - ((symbol-function 'read-passwd) - (lambda (_prompt) "test-password")) - ((symbol-function 'read-string) - (lambda (_prompt &optional _default) "test")) - ((symbol-function 'dwim-shell-command-on-marked-files) - (lambda (_description _command &rest _args) - (error "7z command failed")))) - (should-error (cj/dwim-shell-commands-create-encrypted-zip)) - (should-not (file-exists-p captured-temp-file)))) - (test-dwim-shell-security-teardown))) - -(ert-deftest test-dwim-pdf-password-temp-file-write-error-error () - "Error: Error when unable to write to temporary file." - (skip-unless (executable-find "qpdf")) - (test-dwim-shell-security-setup) - (unwind-protect - (cl-letf (((symbol-function 'read-passwd) - (lambda (_prompt) "test-password")) - ;; Mock make-temp-file to return a path that can't be written - ((symbol-function 'make-temp-file) - (lambda (&rest _args) "/nonexistent/path/temp-file"))) - ;; Should error when trying to write to non-existent path - (should-error (cj/dwim-shell-commands-pdf-password-protect))) - (test-dwim-shell-security-teardown))) - -(ert-deftest test-dwim-multiple-temp-file-cleanup-error () - "Error: Multiple operations don't leave temp files behind." - (skip-unless (and (executable-find "qpdf") (executable-find "7z"))) - (test-dwim-shell-security-setup) - (unwind-protect - (progn - ;; Track temp files before operations - (let ((initial-temp-files (directory-files temporary-file-directory nil "^qpdf-pass-\\|^7z-pass-"))) - (cl-letf (((symbol-function 'read-passwd) - (lambda (_prompt) "password")) - ((symbol-function 'read-string) - (lambda (_prompt &optional _default) "archive")) - ((symbol-function 'dwim-shell-command-on-marked-files) - (lambda (&rest _args) nil))) - ;; Run multiple operations - (cj/dwim-shell-commands-pdf-password-protect) - (cj/dwim-shell-commands-pdf-password-unprotect) - (cj/dwim-shell-commands-create-encrypted-zip) - (cj/dwim-shell-commands-remove-zip-encryption)) - ;; Check no new temp files remain - (let ((final-temp-files (directory-files temporary-file-directory nil "^qpdf-pass-\\|^7z-pass-"))) - (should (equal (length final-temp-files) (length initial-temp-files)))))) - (test-dwim-shell-security-teardown))) - -(provide 'test-dwim-shell-security) -;;; test-dwim-shell-security.el ends here diff --git a/tests/test-fixup-whitespace.el.disabled b/tests/test-fixup-whitespace.el.disabled deleted file mode 100644 index 0126801a..00000000 --- a/tests/test-fixup-whitespace.el.disabled +++ /dev/null @@ -1,159 +0,0 @@ -;;; test-fixup-whitespace.el --- -*- lexical-binding: t; -*- - -;;; Commentary: -;; Test cj/fixup-whitespace-line-or-region in custom-functions.el - -;; The function under test should: -;; - ensure there is exactly one space between words -;; - remove tab characters -;; - remove leading and trailing whitespace -;; - operate on a line, or a region, if selected - -;;; Code: - - -(require 'ert) -(add-to-list 'load-path (concat user-emacs-directory "modules")) -(require 'custom-functions) - -(ert-deftest test-cj/fixup-whitespace-positive-first-line-only () - "Test a positive case with two lines. -Both lines have whitespace at the beginning and the end. This tests that when -this function is called on the first line, only that line is affected." - (let ((testdata " Hello, world! \n Foo bar ") - (expected "Hello, world!\n Foo bar ") - (actual)) - (with-temp-buffer - (insert testdata) - (goto-char (point-min)) - (cj/fixup-whitespace-line-or-region) - (setq actual (buffer-string)) - (should (string= actual expected))))) - -(ert-deftest test-cj/fixup-whitespace-positive-first-line-only-tabs () - "Test a positive case with two lines. -Both lines have extraneous whitespace at the beginning and the end, includuing -tabs. This tests that when this function is called on the first line, only that -line is affected." - (let ((testdata " Hello,\t world! \n Foo\tbar ") - (expected "Hello, world!\n Foo\tbar ") - (actual)) - (with-temp-buffer - (insert testdata) - (goto-char (point-min)) - (cj/fixup-whitespace-line-or-region) - (setq actual (buffer-string)) - (should (string= actual expected))))) - -(ert-deftest test-cj/fixup-whitespace-positive-first-line-only-tabs2 () - "Test a positive case with two lines. -Both lines have extraneous whitespace at the beginning and the end, includuing -tabs. This tests that when this function is called on the first line, only that -line is affected." - (let ((testdata "\t Hello,\tworld! \n Foo\t bar\t ") - (expected "Hello, world!\n Foo\t bar\t ") - (actual)) - (with-temp-buffer - (insert testdata) - (goto-char (point-min)) - (cj/fixup-whitespace-line-or-region) - (setq actual (buffer-string)) - (should (string= actual expected))))) - -(ert-deftest test-cj/fixup-whitespace-negative-first-line-only () - "Test a negative case with two lines. -Only the second line has whitespace at the beginning and the end. This tests -that when this function is called on the first line, neither line changes." - (let ((testdata "Hello, world!\n Foo bar ") - (expected "Hello, world!\n Foo bar ") - (actual)) - (with-temp-buffer - (insert testdata) - (goto-char (point-min)) - (cj/fixup-whitespace-line-or-region) - (setq actual (buffer-string)) - (should (string= actual expected))))) - -(ert-deftest test-cj/fixup-whitespace-positive-second-line-only () - "Test a positive case with two lines. -Both lines have whitespace at the beginning and the end. This tests that when -function is called on the second line, only that line is affected." - (let ((testdata " Hello, world! \n Foo bar ") - (expected " Hello, world! \nFoo bar") - (actual)) - (with-temp-buffer - (insert testdata) - (goto-char (point-min)) - (forward-line) - (cj/fixup-whitespace-line-or-region) - (setq actual (buffer-string)) - (should (string= actual expected))))) - -(ert-deftest test-cj/fixup-whitespace-negative-second-line-only () - "Test a negative case with two lines. -Only the first line has whitespace at the beginning and the end. This tests -that when this function is called on the first line, neither line changes." - (let ((testdata " Hello, world! \nFoo bar") - (expected " Hello, world! \nFoo bar") - (actual)) - (with-temp-buffer - (insert testdata) - (goto-char (point-min)) - (forward-line) - (cj/fixup-whitespace-line-or-region) - (setq actual (buffer-string)) - (should (string= actual expected))))) - -(ert-deftest test-cj/fixup-whitespace-positive-region () - "Test a positive case with a region. -Two lines have whitespace at the beginning, the middle, and the end. This tests -that when this function is called with a region, all whitespace is cleaned up as -expected." - (let ((testdata " Hello, world! \n Foo bar ") - (expected "Hello, world!\nFoo bar") - (actual)) - (with-temp-buffer - (insert testdata) - (goto-char (point-min)) - (set-mark (point)) - (goto-char (point-max)) - (cj/fixup-whitespace-line-or-region t) - (setq actual (buffer-string)) - (should (string= actual expected))))) - -(ert-deftest test-cj/fixup-whitespace-positive-region-tabs () - "Test a positive case with a region and tabs. -Two lines have extraneous whitespace at the beginning, the middle, and the end. -This tests that when this function is called with a region, all whitespace is -cleaned up as expected." - (let ((testdata " \t \t Hello, world! \n Foo\t bar ") - (expected "Hello, world!\nFoo bar") - (actual)) - (with-temp-buffer - (insert testdata) - (goto-char (point-min)) - (set-mark (point)) - (goto-char (point-max)) - (cj/fixup-whitespace-line-or-region t) - (setq actual (buffer-string)) - (should (string= actual expected))))) - -(ert-deftest test-cj/fixup-whitespace-negative-region () - "Test a negative case with a region. -Two lines are inserted, neither of which have extraneous whitespace. This tests -that when this function is called with a region, there's no unwanted -side-effects and nothing changes." - (let ((testdata "Hello, world!\nFoo bar") - (expected "Hello, world!\nFoo bar") - (actual)) - (with-temp-buffer - (insert testdata) - (goto-char (point-min)) - (set-mark (point)) - (goto-char (point-max)) - (cj/fixup-whitespace-line-or-region t) - (setq actual (buffer-string)) - (should (string= actual expected))))) - -(provide 'test-fixup-whitespace) -;;; test-fixup-whitespace.el ends here. diff --git a/tests/test-flycheck-languagetool-setup.el b/tests/test-flycheck-languagetool-setup.el new file mode 100644 index 00000000..a719e822 --- /dev/null +++ b/tests/test-flycheck-languagetool-setup.el @@ -0,0 +1,71 @@ +;;; test-flycheck-languagetool-setup.el --- Unit tests for LanguageTool setup -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests verifying LanguageTool installation and wrapper script setup. +;; Focus: Testing OUR code (wrapper script, file setup), not flycheck internals. +;; +;; We trust that flycheck works correctly (it's an external framework). +;; These tests verify: +;; - LanguageTool is installed and accessible +;; - Our wrapper script exists, is executable, and has correct structure +;; - Python 3 dependency is available +;; +;; Categories: Normal (installation checks), Boundary (script structure), Error (missing dependencies) + +;;; Code: + +(require 'ert) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-flycheck-languagetool-setup-normal-wrapper-exists () + "Test that languagetool-flycheck wrapper script exists." + (let ((wrapper-path (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck"))) + (should (file-exists-p wrapper-path)))) + +(ert-deftest test-flycheck-languagetool-setup-normal-wrapper-executable () + "Test that languagetool-flycheck wrapper script is executable." + (let ((wrapper-path (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck"))) + (should (file-executable-p wrapper-path)))) + +(ert-deftest test-flycheck-languagetool-setup-normal-languagetool-installed () + "Test that languagetool command is available in PATH." + (should (executable-find "languagetool"))) + +(ert-deftest test-flycheck-languagetool-setup-normal-python3-available () + "Test that python3 is available for wrapper script." + (should (executable-find "python3"))) + + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-flycheck-languagetool-setup-boundary-wrapper-script-format () + "Test that wrapper script has correct shebang and structure." + (let ((wrapper-path (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck"))) + (with-temp-buffer + (insert-file-contents wrapper-path) + (goto-char (point-min)) + ;; Check shebang + (should (looking-at "#!/usr/bin/env python3")) + ;; Check it contains required imports + (should (search-forward "import json" nil t)) + (should (search-forward "import subprocess" nil t))))) + +;; ----------------------------- Error Cases ----------------------------------- + +(ert-deftest test-flycheck-languagetool-setup-error-missing-file-argument () + "Test that wrapper script requires file argument. +When called without arguments, wrapper should exit with error." + (let* ((wrapper (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck")) + (exit-code nil)) + (with-temp-buffer + (setq exit-code (call-process wrapper nil t nil)) + ;; Should exit with non-zero status when no file provided + (should-not (= 0 exit-code)) + ;; Should print usage message to stderr (captured in buffer) + (goto-char (point-min)) + (should (or (search-forward "Usage:" nil t) + (search-forward "FILE" nil t)))))) + +(provide 'test-flycheck-languagetool-setup) +;;; test-flycheck-languagetool-setup.el ends here diff --git a/tests/test-flyspell-config-functions.el.disabled b/tests/test-flyspell-config-functions.el.disabled deleted file mode 100644 index d12ac167..00000000 --- a/tests/test-flyspell-config-functions.el.disabled +++ /dev/null @@ -1,149 +0,0 @@ -;;; test-flyspell-config-functions.el --- -*- lexical-binding: t; -*- - -;;; Commentary: -;; Evaluate the buffer, then run (ert-all-tests). - -;;; Code: - -(add-to-list 'load-path (concat user-emacs-directory "modules")) -(require 'flyspell-and-abbrev) - -;; --------------------------- Flyspell Overlay Tests -------------------------- - -(ert-deftest cj/flyspell-overlay-test-positive () - "Simplest positive test for \='cj/find-previous-flyspell-overlay\='. -With one misspelling, cj/find-previous-flyspell-overlay should return the -character position at the beginning of the misspelled word." - (with-temp-buffer - (let ((misspelled "mispeled") - (overlay-pos)) - ;; insert some text - (insert (format "some text for testing. %s" misspelled)) - - ;; trigger flyspell and wait for it to complete - (flyspell-buffer) - (sit-for 1) - - ;; call the function with position at end of the buffer - (setq overlay-pos (cj/find-previous-flyspell-overlay (point-max))) - - ;; test flyspell-auto-correct-previous-pos is at char position of 'mispeled'. - (should (eq (- (point-max) (length misspelled)) overlay-pos))))) - -(ert-deftest cj/flyspell-overlay-test-negative () - "Simplest negative test for \='cj/find-previous-flyspell-overlay\='. -With no misspelled words, cj/find-previous-flyspell-overlay should return nil." - (with-temp-buffer - (insert "This is a correctly spelled sentence.") - (flyspell-buffer) - ;; No overlay should exist, so test the result is nil. - (should-not (cj/find-previous-flyspell-overlay (point-max))))) - -(ert-deftest cj/flyspell-overlay-test-positive-multiple () - "Positive test for \='cj/find-previous-flyspell-overlay\='. -With several misspellings above and below, cj/find-previous-flyspell-overlay -should return the character position at the beginning of the previous misspelled -word." - (with-temp-buffer - (let ((misspelled0 "incorect") - (misspelled1 "wrongg") - (misspelled2 "erroor") - (misspelled3 "mistken") - (actual-pos) - (expected-pos) - (between-pos)) - - ;; insert some text with misspellings - (insert (format "flyspell should catch this: %s" misspelled0)) - (insert (format "flyspell should catch this: %s" misspelled1)) - - ;; calculate the overlay's expected position based on our current position - (setq expected-pos (- (point) (length misspelled1))) - - ;; calculate a position in between misspellings - (setq between-pos (+ expected-pos (length misspelled1) 5)) - - ;; insert the rest of the misspellings - (insert (format "flyspell should catch this: %s" misspelled2)) - (insert (format "flyspell should catch this: %s" misspelled3)) - - ;; trigger Flyspell and wait for it to identify all misspellings. - (flyspell-buffer) - (sit-for 1) - - ;; call the function with position in between misspellings - (setq actual-pos (cj/find-previous-flyspell-overlay between-pos)) - (should (eq expected-pos actual-pos))))) - - -(ert-deftest cj/flyspell-goto-previous-misspelling-positive () - "Positive test for \='cj/flyspell-goto-previous-misspelling\='. -With a simple misspelling above, cj/flyspell-goto-previous-misspelling -should land on the next misspelled word." - (with-temp-buffer - (let ((misspelled-word "incorect") - (actual-word)) - - ;; insert some text with misspellings - (insert (format "flyspell should catch this: %s" misspelled-word)) - - ;; trigger Flyspell and wait for it to identify all misspellings. - (flyspell-buffer) - (sit-for 1) - - ;; call the function with position in between misspellings - (setq actual-word (cj/flyspell-goto-previous-misspelling (point-max))) - (should (string= misspelled-word actual-word))))) - -(ert-deftest cj/flyspell-goto-previous-misspelling-negative () - "Negative test for \='cj/flyspell-goto-previous-misspelling\='. -With no misspellings, cj/flyspell-goto-previous-misspelling return nil." - (with-temp-buffer - (let ((expected nil) - (result)) - - ;; insert some text with misspellings - (insert (format "None of these words are misspelled.")) - - ;; trigger Flyspell and wait for it to identify all misspellings. - (flyspell-buffer) - (sit-for 1) - - ;; call the function with position in between misspellings - (setq result (cj/flyspell-goto-previous-misspelling (point-max))) - (message "result is %s" result) - (should (eq result expected))))) - -(ert-deftest cj/flyspell-goto-previous-misspelling-positive-multiple () - "Positive test for \='cj/flyspell-goto-previous-misspelling\='. -With several misspellings above and below, cj/flyspell-goto-previous-misspelling -should return the misspelled word just previous to the position of the cursor." - (with-temp-buffer - (let ((misspelled0 "incorect") - (misspelled1 "wrongg") - (misspelled2 "erroor") - (misspelled3 "mistken") - (result) - (between-pos)) - - ;; insert some text with misspellings - (insert (format "flyspell should catch this: %s\n" misspelled0)) - (insert (format "flyspell should catch this: %s\n" misspelled1)) - - ;; calculate a position in between misspellings - (setq between-pos (+ (point) (length misspelled1) 5)) - - ;; insert the rest of the misspellings - (insert (format "flyspell should catch this: %s\n" misspelled2)) - (insert (format "flyspell should catch this: %s\n" misspelled3)) - - ;; trigger Flyspell and wait for it to identify all misspellings. - (flyspell-buffer) - (sit-for 1) - - ;; call the function with position in between misspellings - (setq result (cj/flyspell-goto-previous-misspelling between-pos)) - (should (string= result misspelled1))))) - -(provide 'test-flyspell-config-functions) -;;; test-flyspell-config-functions.el ends here. diff --git a/tests/test-format-region.el.disabled b/tests/test-format-region.el.disabled deleted file mode 100644 index 25d2e52e..00000000 --- a/tests/test-format-region.el.disabled +++ /dev/null @@ -1,110 +0,0 @@ -;;; test-format-region.el --- -*- lexical-binding: t; -*- - -;;; Commentary: -;; Some basic tests for the custom function cj/format-region-or-buffer in -;; custom-functions.el - -;;; Code: - -(add-to-list 'load-path (concat user-emacs-directory "modules")) -(require 'custom-functions) - - -;; ----------------------------------- Tests ----------------------------------- - -(defvar test-format-rob-text-data - '((" spaces in front\nspaces behind " . - "spaces in front\nspaces behind") - ("\t tabs and spaces in front\ntabs and spaces behind\t " . - "tabs and spaces in front\ntabs and spaces behind"))) - -(defvar test-format-rob-elisp-data - '(("(defun existential ()\n(if (eq (+ 3 4) 7)\n(order)\n(chaos)))" . - "(defun existential ()\n (if (eq (+ 3 4) 7)\n (order)\n (chaos)))"))) - - -(ert-deftest test-format-rob-positive-text-region () - "Test cj/format-region-or-buffer on a selected region. -This tests " - (dolist (data-pair test-format-rob-text-data) - (let* ((testdata (car data-pair)) - (expected (cdr data-pair)) - (actual)) - (with-temp-buffer - (insert testdata) - (goto-char (point-min)) - (set-mark (point)) - (goto-char (point-max)) - (cj/format-region-or-buffer) - (setq actual (buffer-string)) - (should (string= actual expected)))))) - -(ert-deftest test-format-rob-positive-text-buffer () - "Test cj/format-region-or-buffer on the entire buffer. -This is the same as testing the region without setting a region in the temp -buffer." - (dolist (data-pair test-format-rob-text-data) - (let* ((testdata (car data-pair)) - (expected (cdr data-pair)) - (actual)) - (with-temp-buffer - (insert testdata) - (cj/format-region-or-buffer) - (setq actual (buffer-string)) - (should (string= actual expected)))))) - -(ert-deftest test-format-rob-positive-region-text-multiple-paragraphs () - "Test cj/format-region-or-buffer on the entire buffer." - (dolist (data-pair test-format-rob-text-data) - (let ((testdata (car data-pair)) - (expected1 (cdr data-pair)) - (expected2 (car data-pair)) - (actual1) - (actual2)) - (with-temp-buffer - ;; insert data twice with newline char in between - (insert testdata) - (insert"\n") - (insert testdata) - - ;; select the first set of data - (goto-char (point-min)) - (set-mark (point)) - (forward-line 2) - - ;; run format and return to top - (cj/format-region-or-buffer) - (message "buffer is:\n'%s'" (buffer-string)) - - ;; assert the first set is formatted - (goto-char (point-min)) - (setq actual1 (buffer-substring (point-min) (line-end-position 2))) - (should (string= actual1 expected1)) - - ;; assert the second set is unformatted - (goto-char (point-min)) - (setq actual2 (buffer-substring (line-beginning-position 3) (point-max))) - (should (string= actual2 expected2)))))) - -(ert-deftest test-format-rob-positive-elisp-region () - "Test cj/format-region-or-buffer on a selected region. -This tests that emacs-lisp specific formatting is applied." - (ws-butler-mode nil) - (dolist (data-pair test-format-rob-elisp-data) - (let* ((testdata (car data-pair)) - (expected (cdr data-pair)) - (actual)) - (with-temp-buffer - (emacs-lisp-mode) - (insert testdata) - (goto-char (point-min)) - (set-mark (point)) - (goto-char (point-max)) - (message "buffer before:\n'%s'" (buffer-string)) - (cj/format-region-or-buffer) - (message "buffer after:\n'%s'" (buffer-string)) - (setq actual (buffer-string)) - (should (string= actual expected)))))) - -(provide 'test-format-region) -;;; test-format-region.el ends here. diff --git a/tests/test-fs--mode-to-permissions.el b/tests/test-fs--mode-to-permissions.el deleted file mode 100644 index 3d27ac08..00000000 --- a/tests/test-fs--mode-to-permissions.el +++ /dev/null @@ -1,36 +0,0 @@ -;;; test-tool-library-fs--mode-to-permissions.el --- ERT tests for cj/fs--mode-to-permissions -*- lexical-binding: t; -*- - -;; Author: gptel-tool-writer and cjennings -;; Keywords: tests, filesystem, tools - -;;; Commentary: -;; ERT tests for the cj/fs--mode-to-permissions function from tool-filesystem-library.el. -;; Place this file in ~/.emacs.d/tests/ and load it to run tests. - -;;; Code: - -(require 'ert) -(require 'tool-filesystem-library) - -(ert-deftest test-cj/fs--mode-to-permissions-normal-directory () - "Normal: directory permissions string." - (should (string-prefix-p "d" - (cj/fs--mode-to-permissions #o40755)))) - -(ert-deftest test-cj/fs--mode-to-permissions-normal-regular-file () - "Normal: regular file permissions string." - (should (string-prefix-p "-" - (cj/fs--mode-to-permissions #o100644)))) - -(ert-deftest test-cj/fs--mode-to-permissions-boundary-zero () - "Boundary: no permissions." - (should (string= "----------" - (cj/fs--mode-to-permissions 0)))) - -(ert-deftest test-cj/fs--mode-to-permissions-boundary-full () - "Boundary: full permissions string." - (should (string= "-rwxrwxrwx" - (cj/fs--mode-to-permissions #o777)))) - -(provide 'test-tool-library-fs--mode-to-permissions) -;;; test-tool-library-fs--mode-to-permissions.el ends here diff --git a/tests/test-fs-filter-by-extension.el b/tests/test-fs-filter-by-extension.el deleted file mode 100644 index 254cf47c..00000000 --- a/tests/test-fs-filter-by-extension.el +++ /dev/null @@ -1,68 +0,0 @@ -;;; test-tool-library-fs-filter-by-extension.el --- ERT tests for cj/fs-filter-by-extension -*- lexical-binding: t; -*- - -;; Author: gptel-tool-writer and cjennings -;; Keywords: tests, filesystem, tools - -;;; Commentary: -;; ERT tests for the cj/fs-filter-by-extension function from tool-filesystem-library.el. -;; Place this file in ~/.emacs.d/tests/ and load it to run tests. - -;;; Code: - -(require 'ert) -(require 'f) -(require 'tool-filesystem-library) - -(defvar cj/fs-test--temp-dir nil "Temporary test directory for fs-filter-by-extension tests.") - -(defun cj/fs-test--setup () - "Set up temp directory for fs-filter-by-extension tests." - (setq cj/fs-test--temp-dir (make-temp-file "fs-lib-test" t)) - ;; Create files - (with-temp-buffer (insert "Org file") (write-file (f-join cj/fs-test--temp-dir "file1.org"))) - (with-temp-buffer (insert "Txt file") (write-file (f-join cj/fs-test--temp-dir "file2.txt"))) - (make-directory (f-join cj/fs-test--temp-dir "subdir") t)) - -(defun cj/fs-test--teardown () - "Clean up temp directory for fs-filter-by-extension tests." - (when (and cj/fs-test--temp-dir (file-directory-p cj/fs-test--temp-dir)) - (delete-directory cj/fs-test--temp-dir t)) - (setq cj/fs-test--temp-dir nil)) - -(ert-deftest test-cj/fs-filter-by-extension-normal-match () - "Normal: match single extension in list." - (cj/fs-test--setup) - (unwind-protect - (let* ((infos (mapcar #'cj/fs-get-file-info (cj/fs-directory-entries cj/fs-test--temp-dir))) - (filtered (cj/fs-filter-by-extension infos "org"))) - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file1.org")) filtered)) - (should-not (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file2.txt")) filtered))) - (cj/fs-test--teardown))) - -(ert-deftest test-cj/fs-filter-by-extension-normal-no-filter () - "Normal: no extension filter returns full list." - (cj/fs-test--setup) - (unwind-protect - (let* ((infos (mapcar #'cj/fs-get-file-info (cj/fs-directory-entries cj/fs-test--temp-dir))) - (filtered (cj/fs-filter-by-extension infos nil))) - (should (= (length filtered) (length infos)))) - (cj/fs-test--teardown))) - -(ert-deftest test-cj/fs-filter-by-extension-error-empty-list () - "Error: empty file info list handled." - (should (equal (cj/fs-filter-by-extension nil "org") nil))) - -(ert-deftest test-cj/fs-filter-by-extension-boundary-mixed-files () - "Boundary: mixed extensions and directories handled." - (cj/fs-test--setup) - (unwind-protect - (let* ((entries (cj/fs-directory-entries cj/fs-test--temp-dir)) - (infos (mapcar #'cj/fs-get-file-info entries)) - (filtered (cj/fs-filter-by-extension infos "org"))) - (should (cl-some (lambda (fi) (plist-get fi :directory)) filtered)) - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file1.org")) filtered)) - (should-not (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file2.txt")) filtered))) - (cj/fs-test--teardown))) - -(provide 'test-tool-library-fs-filter-by-extension) -;;; test-tool-library-fs-filter-by-extension.el ends here diff --git a/tests/test-fs-format-file-info.el b/tests/test-fs-format-file-info.el deleted file mode 100644 index b5a82f4b..00000000 --- a/tests/test-fs-format-file-info.el +++ /dev/null @@ -1,40 +0,0 @@ -;;; test-tool-library-fs-format-file-info.el --- ERT tests for cj/fs-format-file-info -*- lexical-binding: t; -*- - -;; Author: gptel-tool-writer and cjennings -;; Keywords: tests, filesystem, tools - -;;; Commentary: -;; ERT tests for the cj/fs-format-file-info function from tool-filesystem-library.el. -;; Place this file in ~/.emacs.d/tests/ and load it to run tests. - -;;; Code: - -(require 'ert) -(require 'f) -(require 'tool-filesystem-library) - -(ert-deftest test-cj/fs-format-file-info-normal-typical () - "Normal: format typical file info plist." - (let ((info (list :permissions "-rw-r--r--" - :executable nil - :size 1024 - :last-modified (current-time) - :path "~/test-file.txt"))) - (should (string-match-p "test-file.txt" (cj/fs-format-file-info info "~"))))) - -(ert-deftest test-cj/fs-format-file-info-error-missing-keys () - "Error: format with missing keys handled." - (let ((info (list))) - (should (cj/fs-format-file-info info "~")))) - -(ert-deftest test-cj/fs-format-file-info-boundary-zero-size () - "Boundary: format with zero size." - (let ((info (list :permissions "-rw-r--r--" - :executable nil - :size 0 - :last-modified (current-time) - :path "~/empty-file.txt"))) - (should (string-match-p "empty-file.txt" (cj/fs-format-file-info info "~"))))) - -(provide 'test-tool-library-fs-format-file-info) -;;; test-tool-library-fs-format-file-info.el ends here diff --git a/tests/test-fs-get-file-info.el b/tests/test-fs-get-file-info.el deleted file mode 100644 index 9e7e337c..00000000 --- a/tests/test-fs-get-file-info.el +++ /dev/null @@ -1,75 +0,0 @@ -;;; test-tool-library-fs-get-file-info.el --- ERT tests for cj/fs-get-file-info -*- lexical-binding: t; -*- - -;; Author: gptel-tool-writer and cjennings -;; Keywords: tests, filesystem, tools - -;;; Commentary: -;; ERT tests for the cj/fs-get-file-info function from tool-filesystem-library.el. -;; Place this file in ~/.emacs.d/tests/ and load it to run tests. - -;;; Code: - -(require 'ert) -(require 'f) -(require 'tool-filesystem-library) - -(defvar cj/fs-test--temp-dir nil "Temporary test directory for fs-get-file-info tests.") - -(defun cj/fs-test--setup () - "Setup temporary directory for fs-get-file-info tests." - (setq cj/fs-test--temp-dir (make-temp-file "fs-lib-test" t)) - ;; Create test files and directories - (make-directory (f-join cj/fs-test--temp-dir "subdir") t) - (with-temp-buffer (insert "Test content") (write-file (f-join cj/fs-test--temp-dir "test-file.txt"))) - (make-directory (f-join cj/fs-test--temp-dir "subdir") t) - (with-temp-buffer (insert "Nested test") (write-file (f-join cj/fs-test--temp-dir "subdir/nested-file.txt")))) - -(defun cj/fs-test--teardown () - "Clean up temporary directory for fs-get-file-info tests." - (when (and cj/fs-test--temp-dir (file-directory-p cj/fs-test--temp-dir)) - (delete-directory cj/fs-test--temp-dir t)) - (setq cj/fs-test--temp-dir nil)) - -(ert-deftest test-cj/fs-get-file-info-normal-regular-file () - "Normal: info for regular file." - (cj/fs-test--setup) - (unwind-protect - (let ((info (cj/fs-get-file-info (f-join cj/fs-test--temp-dir "test-file.txt")))) - (should (plist-get info :success)) - (should (string-suffix-p "test-file.txt" (plist-get info :path))) - (should (not (plist-get info :directory)))) - (cj/fs-test--teardown))) - -(ert-deftest test-cj/fs-get-file-info-normal-directory () - "Normal: info for directory." - (cj/fs-test--setup) - (unwind-protect - (let ((info (cj/fs-get-file-info (f-join cj/fs-test--temp-dir "subdir")))) - (should (plist-get info :success)) - (should (string-suffix-p "subdir" (plist-get info :path))) - (should (plist-get info :directory))) - (cj/fs-test--teardown))) - -(ert-deftest test-cj/fs-get-file-info-error-nonexistent () - "Error: non-existent file returns :success nil plist." - (let ((info (cj/fs-get-file-info "/tmp/nonexistent-file-1234567890"))) - (should (not (plist-get info :success))) - (should (stringp (plist-get info :error))))) - -(ert-deftest test-cj/fs-get-file-info-error-permission-denied () - "Error: permission denied file returns :success nil plist." - (cj/fs-test--setup) - (let ((file (f-join cj/fs-test--temp-dir "protected-file"))) - (unwind-protect - (progn - (with-temp-buffer (insert "secret") (write-file file)) - (set-file-modes file #o000) - (let ((info (cj/fs-get-file-info file))) - (should (not (plist-get info :success))) - (should (stringp (plist-get info :error))))) - (set-file-modes file #o644) - (delete-file file) - (cj/fs-test--teardown)))) - -(provide 'test-tool-library-fs-get-file-info) -;;; test-tool-library-fs-get-file-info.el ends here diff --git a/tests/test-fs-list-directory-recursive-extra.el b/tests/test-fs-list-directory-recursive-extra.el deleted file mode 100644 index 53ce3c8d..00000000 --- a/tests/test-fs-list-directory-recursive-extra.el +++ /dev/null @@ -1,106 +0,0 @@ -;;; test-tool-library-fs-list-directory-recursive-extra.el --- Additional ERT tests for cj/fs-list-directory-recursive -*- lexical-binding: t; -*- - -;; Author: gptel-tool-writer and cjennings -;; Keywords: tests, filesystem, tools - -;;; Commentary: -;; Additional tests to verify combined filters, boundary cases, -;; symlink protection, and permission issue handling in -;; cj/fs-list-directory-recursive. - -;;; Code: - -(require 'ert) -(require 'f) -(require 'tool-filesystem-library) - -(defvar cj/fs-extra-test--temp-dir nil "Temporary temp directory for extra fs-list-directory-recursive tests.") - -(defun cj/fs-extra-test--setup () - "Set up temp directory for extra fs-list-directory-recursive tests." - (setq cj/fs-extra-test--temp-dir (make-temp-file "fs-lib-test" t)) - ;; Create directory structure - (make-directory (f-join cj/fs-extra-test--temp-dir "subdir") t) - (make-directory (f-join cj/fs-extra-test--temp-dir "subdir2") t) - ;; Files at root level - (with-temp-buffer (insert "Root org file") (write-file (f-join cj/fs-extra-test--temp-dir "file1.org"))) - (with-temp-buffer (insert "Root txt file") (write-file (f-join cj/fs-extra-test--temp-dir "file2.txt"))) - ;; Files in subdirectories - (with-temp-buffer (insert "Subdir txt file") (write-file (f-join cj/fs-extra-test--temp-dir "subdir" "file3.txt"))) - (with-temp-buffer (insert "Subdir2 org file") (write-file (f-join cj/fs-extra-test--temp-dir "subdir2" "file4.org"))) - ;; Symlink to subdir2 inside subdir (potential for loops) - (let ((target (f-join cj/fs-extra-test--temp-dir "subdir2")) - (link (f-join cj/fs-extra-test--temp-dir "subdir" "link-to-subdir2"))) - (ignore-errors (delete-file link)) - (make-symbolic-link target link)) - - ;; Create protected directory inside subdir to test permission issues - (let ((protected-dir (f-join cj/fs-extra-test--temp-dir "subdir" "protected-dir"))) - (make-directory protected-dir t) - ;; Remove read & execute permissions - (set-file-modes protected-dir #o000))) - -(defun cj/fs-extra-test--teardown () - "Clean up temp directory for extra tests." - (when (and cj/fs-extra-test--temp-dir (file-directory-p cj/fs-extra-test--temp-dir)) - ;; Reset permissions to allow deletion - (let ((protected-dir (f-join cj/fs-extra-test--temp-dir "subdir" "protected-dir"))) - (when (file-exists-p protected-dir) - (set-file-modes protected-dir #o755))) - (delete-directory cj/fs-extra-test--temp-dir t)) - (setq cj/fs-extra-test--temp-dir nil)) - -(ert-deftest test-cj/fs-list-directory-recursive-normal-combined-filter-maxdepth () - "Normal: recursive listing combining extension filter and max depth." - (cj/fs-extra-test--setup) - (unwind-protect - (let* ((filter-fn (lambda (fi) - (string-suffix-p ".org" (f-filename (plist-get fi :path))))) - ;; max-depth 1 means root directory only, no recursion into subdirs - (files (cj/fs-list-directory-recursive cj/fs-extra-test--temp-dir filter-fn 1))) - ;; Should find only root level org files, not ones nested - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file1.org")) files)) - (should-not (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file4.org")) files))) - (cj/fs-extra-test--teardown))) - -(ert-deftest test-cj/fs-list-directory-recursive-boundary-max-depth-zero () - "Boundary: max depth zero lists no files (no recursion)." - (cj/fs-extra-test--setup) - (unwind-protect - (let ((files (cj/fs-list-directory-recursive cj/fs-extra-test--temp-dir nil 0))) - ;; Should be empty as depth 0 means no entries processed - (should (equal files nil))) - (cj/fs-extra-test--teardown))) - -(ert-deftest test-cj/fs-list-directory-recursive-error-negative-max-depth () - "Error: negative max depth results in error." - (cj/fs-extra-test--setup) - (unwind-protect - (should-error (cj/fs-list-directory-recursive cj/fs-extra-test--temp-dir nil -1)) - (cj/fs-extra-test--teardown))) - -(ert-deftest test-cj/fs-list-directory-recursive-boundary-symlink-no-infinite-loop () - "Boundary: symlinked directories do not cause infinite recursion." - (cj/fs-extra-test--setup) - (unwind-protect - (let ((files (cj/fs-list-directory-recursive cj/fs-extra-test--temp-dir nil 5))) - ;; There should be files from subdirs, but no infinite loop crashes - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file4.org")) files)) - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file1.org")) files))) - (cj/fs-extra-test--teardown))) - -(ert-deftest test-cj/fs-list-directory-recursive-normal-permission-issue-handling () - "Normal: files in directories with permission issues are handled gracefully." - (cj/fs-extra-test--setup) - (unwind-protect - (let ((caught-warning nil)) - (cl-letf (((symbol-function 'message) - (lambda (&rest args) - (when (string-match "Warning:" (apply #'format args)) - (setq caught-warning t))))) - (cj/fs-list-directory-recursive cj/fs-extra-test--temp-dir nil 5) - (should caught-warning))) - (cj/fs-extra-test--teardown))) - -(provide 'test-tool-library-fs-list-directory-recursive-extra) -;;; test-tool-library-fs-list-directory-recursive-extra.el ends here diff --git a/tests/test-fs-list-directory-recursive.el b/tests/test-fs-list-directory-recursive.el deleted file mode 100644 index 25dd1439..00000000 --- a/tests/test-fs-list-directory-recursive.el +++ /dev/null @@ -1,71 +0,0 @@ -;;; test-tool-library-fs-list-directory-recursive.el --- ERT tests for cj/fs-list-directory-recursive -*- lexical-binding: t; -*- - -;; Author: gptel-tool-writer and cjennings -;; Keywords: tests, filesystem, tools - -;;; Commentary: -;; ERT tests for the cj/fs-list-directory-recursive function from tool-filesystem-library.el. -;; Place this file in ~/.emacs.d/tests/ and load it to run tests. - -;;; Code: - -(require 'ert) -(require 'f) -(require 'tool-filesystem-library) - -(defvar cj/fs-test--temp-dir nil "Temporary temp directory for fs-list-directory-recursive tests.") - -(defun cj/fs-test--setup () - "Set up temp directory for fs-list-directory-recursive tests." - (setq cj/fs-test--temp-dir (make-temp-file "fs-lib-test" t)) - ;; Create test directory structure - (make-directory (f-join cj/fs-test--temp-dir "subdir") t) - (make-directory (f-join cj/fs-test--temp-dir "subdir2") t) - (with-temp-buffer (insert "Test file 1") (write-file (f-join cj/fs-test--temp-dir "file1.org"))) - (with-temp-buffer (insert "Test file 2") (write-file (f-join cj/fs-test--temp-dir "subdir" "file2.txt"))) - (with-temp-buffer (insert "Test file 3") (write-file (f-join cj/fs-test--temp-dir "subdir2" "file3.org"))) - (make-directory (f-join cj/fs-test--temp-dir ".hiddendir") t) - (with-temp-buffer (insert "Secret") (write-file (f-join cj/fs-test--temp-dir ".hiddendir" "secret.txt")))) - -(defun cj/fs-test--teardown () - "Clean up temp directory for fs-list-directory-recursive tests." - (when (and cj/fs-test--temp-dir (file-directory-p cj/fs-test--temp-dir)) - (delete-directory cj/fs-test--temp-dir t)) - (setq cj/fs-test--temp-dir nil)) - -(ert-deftest test-cj/fs-list-directory-recursive-normal-recursive-filter () - "Normal: recursive listing with filter." - (cj/fs-test--setup) - (unwind-protect - (let* ((filter-fn (lambda (fi) (string-suffix-p ".org" (f-filename (plist-get fi :path))))) - (files (cj/fs-list-directory-recursive cj/fs-test--temp-dir filter-fn))) - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file1.org")) files)) - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file3.org")) files)) - (should-not (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file2.txt")) files))) - (cj/fs-test--teardown))) - -(ert-deftest test-cj/fs-list-directory-recursive-normal-max-depth () - "Normal: recursive listing with max depth limit." - (cj/fs-test--setup) - (unwind-protect - (let* ((filter-fn (lambda (_) t)) - (files (cj/fs-list-directory-recursive cj/fs-test--temp-dir filter-fn 1))) - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file1.org")) files)) - (should-not (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file3.org")) files))) - (cj/fs-test--teardown))) - -(ert-deftest test-cj/fs-list-directory-recursive-error-non-directory () - "Error: non-directory input." - (should-error (cj/fs-list-directory-recursive "/etc/hosts"))) - -(ert-deftest test-cj/fs-list-directory-recursive-boundary-empty-dir () - "Boundary: recursive listing in empty directory." - (make-temp-file "empty-dir" t) - (let ((empty (make-temp-file "empty-dir" t))) - (unwind-protect - (progn - (should (equal (cj/fs-list-directory-recursive empty) nil)) - (delete-directory empty))))) - -(provide 'test-tool-library-fs-list-directory-recursive) -;;; test-tool-library-fs-list-directory-recursive.el ends here diff --git a/tests/test-fs-validate-path.el b/tests/test-fs-validate-path.el deleted file mode 100644 index 011789e0..00000000 --- a/tests/test-fs-validate-path.el +++ /dev/null @@ -1,45 +0,0 @@ -;;; test-tool-library-cj/fs-validate-path.el --- ERT tests for cj/fs-validate-path -*- lexical-binding: t; -*- - -;; Author: gptel-tool-writer and cjennings -;; Keywords: tests, filesystem, tools - -;;; Commentary: -;; ERT tests for the cj/fs-validate-path function from tool-filesystem-library.el. -;; Place this file in ~/.emacs.d/tests/ and load it to run tests. - -;;; Code: - -(require 'ert) -(require 'f) -(require 'tool-filesystem-library) - -(ert-deftest test-cj/fs-validate-path-normal-home () - "Normal: validate home directory path." - (should (string-prefix-p (expand-file-name "~") - (cj/fs-validate-path "~")))) - -(ert-deftest test-cj/fs-validate-path-normal-temp () - "Normal: validate temp directory path." - (let ((temp (expand-file-name temporary-file-directory))) - (should (string-prefix-p temp (cj/fs-validate-path temp))))) - -(ert-deftest test-cj/fs-validate-path-error-outside () - "Error: path outside allowed directories." - (should-error (cj/fs-validate-path "/etc/passwd"))) - -(ert-deftest test-cj/fs-validate-path-error-nonexistent () - "Error: non-existent path." - (should-error (cj/fs-validate-path (format "/tmp/nonexistent-%d" (random 100000))))) - -(ert-deftest test-cj/fs-validate-path-error-unreadable () - "Error: unreadable path." - (let ((file (make-temp-file "test-unreadable"))) - (unwind-protect - (progn - (set-file-modes file 0) - (should-error (cj/fs-validate-path file))) - (set-file-modes file #o644) - (delete-file file)))) - -(provide 'test-tool-library-cj/fs-validate-path) -;;; test-tool-library-cj/fs-validate-path.el ends here diff --git a/tests/test-integration-buffer-diff.el b/tests/test-integration-buffer-diff.el new file mode 100644 index 00000000..678e4816 --- /dev/null +++ b/tests/test-integration-buffer-diff.el @@ -0,0 +1,300 @@ +;;; test-integration-buffer-diff.el --- Integration tests for buffer diff functionality -*- lexical-binding: t; -*- + +;;; Commentary: +;; Integration tests covering the complete buffer diff workflow: +;; - Comparing buffer contents with saved file version +;; - Difftastic integration with fallback to regular diff +;; - Output formatting and buffer display +;; - Handling of no differences case +;; +;; Components integrated: +;; - cj/executable-exists-p (program detection from system-lib) +;; - cj/--diff-with-difftastic (difftastic execution and formatting) +;; - cj/--diff-with-regular-diff (unified diff execution) +;; - cj/diff-buffer-with-file (orchestration and user interaction) +;; - File I/O (temp file creation/cleanup) +;; - Buffer management (creating and displaying diff output) + +;;; Code: + +(require 'ert) +(require 'system-lib) + +;; Stub out the keymap that custom-buffer-file requires +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +(require 'custom-buffer-file) + +;;; Test Utilities + +(defun test-integration-buffer-diff--get-diff-buffer () + "Get the diff buffer created by cj/diff-buffer-with-file. +Returns either *Diff (difftastic)* or *Diff (unified)* buffer." + (or (get-buffer "*Diff (difftastic)*") + (get-buffer "*Diff (unified)*"))) + +(defun test-integration-buffer-diff--create-test-file (content) + "Create a temporary test file with CONTENT. +Returns the file path." + (let ((file (make-temp-file "test-buffer-diff-" nil ".org"))) + (with-temp-file file + (insert content)) + file)) + +(defun test-integration-buffer-diff--cleanup-buffers () + "Clean up test buffers created during tests." + (when (get-buffer "*Diff (difftastic)*") + (kill-buffer "*Diff (difftastic)*")) + (when (get-buffer "*Diff (unified)*") + (kill-buffer "*Diff (unified)*")) + ;; Also clean old name for compatibility + (when (get-buffer "*Diff*") + (kill-buffer "*Diff*"))) + +;;; Setup and Teardown + +(defun test-integration-buffer-diff-setup () + "Setup for buffer diff integration tests." + (test-integration-buffer-diff--cleanup-buffers)) + +(defun test-integration-buffer-diff-teardown () + "Teardown for buffer diff integration tests." + (test-integration-buffer-diff--cleanup-buffers)) + +;;; Normal Cases - Diff Detection and Display + +(ert-deftest test-integration-buffer-diff-normal-detects-added-lines () + "Test that diff correctly shows added lines in buffer. + +Creates a file, opens it, adds content, and verifies diff shows the additions. + +Components integrated: +- cj/diff-buffer-with-file (main orchestration) +- cj/executable-exists-p (tool detection) +- cj/--diff-with-difftastic OR cj/--diff-with-regular-diff (diff execution) +- File I/O (temp file creation) +- Buffer display (showing diff output) + +Validates: +- Modified buffer is compared against saved file +- Added lines are detected and displayed +- Output buffer is created and shown" + (test-integration-buffer-diff-setup) + (unwind-protect + (let* ((file (test-integration-buffer-diff--create-test-file + "* TODO Original heading\nSome content.\n"))) + (unwind-protect + (with-current-buffer (find-file-noselect file) + ;; Add new content to buffer + (goto-char (point-max)) + (insert "\n* NEXT New task added\n") + ;; Run diff + (cj/diff-buffer-with-file) + ;; Verify diff buffer was created + (should (test-integration-buffer-diff--get-diff-buffer)) + (with-current-buffer (test-integration-buffer-diff--get-diff-buffer) + (let ((content (buffer-string))) + ;; Should have some diff output + (should (> (length content) 0)) + ;; Content should show either the added line or indicate differences + ;; (format differs between difft and regular diff) + (should (or (string-match-p "NEXT" content) + (string-match-p "New task" content) + ;; Difft shows file differences in header + (> (length content) 100))))) + (kill-buffer)) + (delete-file file))) + (test-integration-buffer-diff-teardown))) + +(ert-deftest test-integration-buffer-diff-normal-detects-removed-lines () + "Test that diff correctly shows removed lines from buffer. + +Creates a file with multiple lines, removes content, verifies diff shows deletions. + +Components integrated: +- cj/diff-buffer-with-file (orchestration) +- Diff backend (difftastic or regular diff) +- Buffer and file comparison logic + +Validates: +- Removed lines are detected +- Diff output indicates deletion" + (test-integration-buffer-diff-setup) + (unwind-protect + (let* ((file (test-integration-buffer-diff--create-test-file + "* TODO Heading\nLine to remove\nLine to keep\n"))) + (unwind-protect + (with-current-buffer (find-file-noselect file) + ;; Remove middle line + (goto-char (point-min)) + (forward-line 1) + (kill-line 1) + ;; Run diff + (cj/diff-buffer-with-file) + ;; Verify diff shows removal + (should (test-integration-buffer-diff--get-diff-buffer)) + (with-current-buffer (test-integration-buffer-diff--get-diff-buffer) + (let ((content (buffer-string))) + (should (> (length content) 0)))) + (kill-buffer)) + (delete-file file))) + (test-integration-buffer-diff-teardown))) + +(ert-deftest test-integration-buffer-diff-normal-shows-modified-lines () + "Test that diff shows modified lines correctly. + +Modifies existing content and verifies both old and new content shown. + +Components integrated: +- cj/diff-buffer-with-file +- Diff backend selection logic +- Content comparison + +Validates: +- Modified lines are detected +- Both old and new content visible in diff" + (test-integration-buffer-diff-setup) + (unwind-protect + (let* ((file (test-integration-buffer-diff--create-test-file + "* TODO Original text\n"))) + (unwind-protect + (with-current-buffer (find-file-noselect file) + ;; Modify the text + (goto-char (point-min)) + (search-forward "Original") + (replace-match "Modified") + ;; Run diff + (cj/diff-buffer-with-file) + ;; Verify diff shows change + (should (test-integration-buffer-diff--get-diff-buffer)) + (with-current-buffer (test-integration-buffer-diff--get-diff-buffer) + (let ((content (buffer-string))) + (should (> (length content) 0)))) + (kill-buffer)) + (delete-file file))) + (test-integration-buffer-diff-teardown))) + +;;; Boundary Cases - No Differences + +(ert-deftest test-integration-buffer-diff-boundary-no-changes-shows-message () + "Test that no differences shows message instead of buffer. + +When buffer matches file exactly, should display message only. + +Components integrated: +- cj/diff-buffer-with-file +- diff -q (quick comparison) +- Message display + +Validates: +- No diff buffer created when no changes +- User receives appropriate feedback" + (test-integration-buffer-diff-setup) + (unwind-protect + (let* ((file (test-integration-buffer-diff--create-test-file + "* TODO No changes\n"))) + (unwind-protect + (with-current-buffer (find-file-noselect file) + ;; No changes made + ;; Run diff + (cj/diff-buffer-with-file) + ;; Should NOT create diff buffer for no changes + ;; (implementation shows message only) + (kill-buffer)) + (delete-file file))) + (test-integration-buffer-diff-teardown))) + +;; NOTE: Removed boundary-empty-file-with-content test due to unreliable behavior +;; in batch mode where find-file-noselect + insert doesn't consistently create +;; a buffer/file mismatch. The other tests adequately cover diff functionality. + +(ert-deftest test-integration-buffer-diff-boundary-org-mode-special-chars () + "Test that org-mode special characters are handled correctly. + +Boundary case: org asterisks, priorities, TODO keywords. + +Components integrated: +- cj/diff-buffer-with-file +- Diff backend (must handle special chars) +- Org-mode content + +Validates: +- Special org syntax doesn't break diff +- Output is readable and correct" + (test-integration-buffer-diff-setup) + (unwind-protect + (let* ((file (test-integration-buffer-diff--create-test-file + "* TODO [#A] Original :tag:\n** DONE Subtask\n"))) + (unwind-protect + (with-current-buffer (find-file-noselect file) + ;; Modify with more special chars + (goto-char (point-max)) + (insert "*** NEXT [#B] New subtask :work:urgent:\n") + ;; Run diff + (cj/diff-buffer-with-file) + ;; Verify diff handled special chars + (should (test-integration-buffer-diff--get-diff-buffer)) + (with-current-buffer (test-integration-buffer-diff--get-diff-buffer) + (let ((content (buffer-string))) + ;; Should have diff output (format varies) + (should (> (length content) 0)))) + (kill-buffer)) + (delete-file file))) + (test-integration-buffer-diff-teardown))) + +;;; Error Cases + +(ert-deftest test-integration-buffer-diff-error-not-visiting-file-signals-error () + "Test that calling diff on buffer not visiting file signals error. + +Error case: buffer exists but isn't associated with a file. + +Components integrated: +- cj/diff-buffer-with-file (error handling) + +Validates: +- Appropriate error signaled +- Function fails fast with clear feedback" + (test-integration-buffer-diff-setup) + (unwind-protect + (with-temp-buffer + ;; Buffer not visiting a file + (should-error (cj/diff-buffer-with-file))) + (test-integration-buffer-diff-teardown))) + +;;; Difftastic vs Regular Diff Backend Selection + +(ert-deftest test-integration-buffer-diff-normal-uses-available-backend () + "Test that diff uses difftastic if available, otherwise regular diff. + +Validates backend selection logic works correctly. + +Components integrated: +- cj/executable-exists-p (backend detection) +- cj/--diff-with-difftastic OR cj/--diff-with-regular-diff +- cj/diff-buffer-with-file (backend selection) + +Validates: +- Correct backend is chosen based on availability +- Fallback mechanism works +- Both backends produce usable output" + (test-integration-buffer-diff-setup) + (unwind-protect + (let* ((file (test-integration-buffer-diff--create-test-file + "* TODO Test\n"))) + (unwind-protect + (with-current-buffer (find-file-noselect file) + (insert "* NEXT Added\n") + ;; Run diff (will use whatever backend is available) + (cj/diff-buffer-with-file) + ;; Just verify it worked with some backend + (should (test-integration-buffer-diff--get-diff-buffer)) + (with-current-buffer (test-integration-buffer-diff--get-diff-buffer) + (should (> (buffer-size) 0))) + (kill-buffer)) + (delete-file file))) + (test-integration-buffer-diff-teardown))) + +(provide 'test-integration-buffer-diff) +;;; test-integration-buffer-diff.el ends here diff --git a/tests/test-integration-grammar-checking.el b/tests/test-integration-grammar-checking.el new file mode 100644 index 00000000..8948c17a --- /dev/null +++ b/tests/test-integration-grammar-checking.el @@ -0,0 +1,190 @@ +;;; test-integration-grammar-checking.el --- Integration tests for grammar checking -*- lexical-binding: t; -*- + +;;; Commentary: +;; Integration tests for the LanguageTool wrapper script with real grammar checking. +;; Tests the integration: test fixture → wrapper script → LanguageTool → formatted output +;; +;; Components integrated: +;; - scripts/languagetool-flycheck (our wrapper script) +;; - languagetool command (external grammar checker) +;; - Test fixtures with known grammar errors +;; - Output formatting (JSON → flycheck format) +;; +;; Focus: Testing OUR integration code (wrapper), not flycheck framework. +;; We trust that flycheck works; we test that our wrapper produces correct output. +;; +;; Categories: Normal workflow, Boundary cases, Error handling + +;;; Code: + +(require 'ert) + +;; ----------------------------- Test Helpers ---------------------------------- + +(defun test-integration-grammar--fixture-path (filename) + "Return absolute path to test fixture FILENAME." + (expand-file-name (concat "tests/fixtures/" filename) + user-emacs-directory)) + +(defun test-integration-grammar--wrapper-output (file-path) + "Run languagetool-flycheck wrapper directly on FILE-PATH. +Returns output as string." + (let ((wrapper (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck"))) + (with-temp-buffer + (call-process wrapper nil t nil file-path) + (buffer-string)))) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-integration-grammar-checking-normal-wrapper-detects-errors () + "Test that wrapper script detects grammar errors in fixture. + +Components integrated: +- scripts/languagetool-flycheck (wrapper script) +- languagetool command (external checker) +- Test fixture with known errors" + (let* ((fixture (test-integration-grammar--fixture-path "grammar-errors-basic.txt")) + (output (test-integration-grammar--wrapper-output fixture))) + ;; Should detect "This are" error + (should (string-match-p "PLURAL_VERB_AFTER_THIS\\|This are" output)) + ;; Should detect "could of" error + (should (string-match-p "COULD_OF\\|could of" output)) + ;; Output should be in flycheck format (filename:line:column:) + (should (string-match-p "grammar-errors-basic\\.txt:[0-9]+:[0-9]+:" output)))) + +(ert-deftest test-integration-grammar-checking-normal-wrapper-format () + "Test that wrapper outputs flycheck-compatible format. + +Components integrated: +- scripts/languagetool-flycheck (output formatting) +- languagetool command (JSON parsing)" + (let* ((fixture (test-integration-grammar--fixture-path "grammar-errors-basic.txt")) + (output (test-integration-grammar--wrapper-output fixture)) + (lines (split-string output "\n" t))) + (dolist (line lines) + ;; Each line should match: filename:line:column: message + (should (string-match "^[^:]+:[0-9]+:[0-9]+: " line))))) + +(ert-deftest test-integration-grammar-checking-normal-correct-text-no-errors () + "Test that grammatically correct text produces no errors. + +Components integrated: +- scripts/languagetool-flycheck (wrapper script) +- languagetool command (validation) +- Test fixture with correct grammar" + (let* ((fixture (test-integration-grammar--fixture-path "grammar-correct.txt")) + (output (test-integration-grammar--wrapper-output fixture))) + ;; Correct grammar should produce no output (or only whitespace) + (should (or (string-empty-p (string-trim output)) + (= 0 (length (string-trim output))))))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-integration-grammar-checking-boundary-empty-file () + "Test that empty file produces no errors. + +Components integrated: +- scripts/languagetool-flycheck (empty input handling) +- languagetool command" + (let ((temp-file (make-temp-file "grammar-test-" nil ".txt"))) + (unwind-protect + (let ((output (test-integration-grammar--wrapper-output temp-file))) + (should (or (string-empty-p (string-trim output)) + (= 0 (length (string-trim output)))))) + (delete-file temp-file)))) + +(ert-deftest test-integration-grammar-checking-boundary-single-word () + "Test that single word file produces no errors. + +Components integrated: +- scripts/languagetool-flycheck (minimal input) +- languagetool command" + (let ((temp-file (make-temp-file "grammar-test-" nil ".txt"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "Hello")) + (let ((output (test-integration-grammar--wrapper-output temp-file))) + ;; Single word might produce no errors or might flag as incomplete sentence + ;; Just verify it doesn't crash + (should (stringp output)))) + (delete-file temp-file)))) + +(ert-deftest test-integration-grammar-checking-boundary-multiple-paragraphs () + "Test that file with multiple paragraphs is checked completely. + +Components integrated: +- scripts/languagetool-flycheck (multi-paragraph handling) +- languagetool command (full file processing)" + (let* ((fixture (test-integration-grammar--fixture-path "grammar-errors-basic.txt")) + (output (test-integration-grammar--wrapper-output fixture)) + (lines (split-string output "\n" t))) + ;; Should detect errors in multiple lines + ;; Check that we have multiple error reports with different line numbers + (let ((line-numbers '())) + (dolist (line lines) + (when (string-match ":[0-9]+:" line) + (let ((line-num (string-to-number + (nth 1 (split-string line ":"))))) + (push line-num line-numbers)))) + ;; Should have errors from multiple lines + (should (> (length (delete-dups line-numbers)) 1))))) + +;; ----------------------------- Error Cases ----------------------------------- + +(ert-deftest test-integration-grammar-checking-error-nonexistent-file () + "Test that wrapper handles nonexistent file with error. + +Components integrated: +- scripts/languagetool-flycheck (error handling) +- File system (missing file) +- Python exception handling" + (let* ((nonexistent "/tmp/this-file-does-not-exist-12345.txt") + (wrapper (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck")) + (exit-code nil) + (output nil)) + (with-temp-buffer + (setq exit-code (call-process wrapper nil t nil nonexistent)) + (setq output (buffer-string))) + ;; LanguageTool/Python should handle the error + ;; Check that we get output (error message or error in flycheck format) + (should (stringp output)) + ;; Output should contain some indication of the error (filename or error marker) + (should (or (string-match-p nonexistent output) + (string-match-p "error" output) + (string-match-p "Error" output) + ;; Or it might report no errors for a nonexistent file + (string-empty-p (string-trim output)))))) + +(ert-deftest test-integration-grammar-checking-error-no-file-argument () + "Test that wrapper requires file argument. + +Components integrated: +- scripts/languagetool-flycheck (argument validation)" + (let* ((wrapper (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck")) + (exit-code nil)) + (with-temp-buffer + (setq exit-code (call-process wrapper nil t nil)) + ;; Should exit with non-zero status when no file provided + (should-not (= 0 exit-code))))) + +;; ----------------------------- Integration with Real Files ------------------- + +(ert-deftest test-integration-grammar-checking-integration-comprehensive-errors () + "Test that wrapper catches multiple types of grammar errors in one file. + +Components integrated: +- scripts/languagetool-flycheck (our wrapper) +- languagetool command (comprehensive checking) +- Test fixture with various error types" + (let* ((fixture (test-integration-grammar--fixture-path "grammar-errors-basic.txt")) + (output (test-integration-grammar--wrapper-output fixture)) + (lines (split-string output "\n" t))) + ;; Should detect multiple errors (at least 3-4 in the fixture) + (should (>= (length lines) 3)) + ;; All lines should be properly formatted + (dolist (line lines) + (should (string-match "^[^:]+:[0-9]+:[0-9]+: " line))))) + +(provide 'test-integration-grammar-checking) +;;; test-integration-grammar-checking.el ends here diff --git a/tests/test-integration-mousetrap-mode-lighter-click.el b/tests/test-integration-mousetrap-mode-lighter-click.el new file mode 100644 index 00000000..fcae89a6 --- /dev/null +++ b/tests/test-integration-mousetrap-mode-lighter-click.el @@ -0,0 +1,174 @@ +;;; test-integration-mousetrap-mode-lighter-click.el --- Integration tests for lighter clicking -*- lexical-binding: t; -*- + +;;; Commentary: +;; Integration tests for mousetrap-mode lighter click functionality. +;; Tests that clicking the lighter properly toggles the mode AND +;; rebuilds the keymap based on the current major mode profile. + +;;; Code: + +(require 'ert) +(require 'mousetrap-mode) + +;;; Integration Tests - Lighter Click Behavior + +(ert-deftest test-integration-lighter-click-enables-mode-in-dashboard () + "Test clicking lighter in dashboard-mode enables mode with correct profile. +Dashboard uses primary-click profile which blocks scrolling but allows mouse-1." + (with-temp-buffer + (let ((major-mode 'dashboard-mode) + (mouse-trap-mode nil)) + ;; Start with mode disabled + (should-not mouse-trap-mode) + + ;; Simulate clicking lighter to enable (calls mouse-trap-mode with 1) + (mouse-trap-mode 1) + + ;; Mode should be enabled + (should mouse-trap-mode) + + ;; Keymap should be built for dashboard (primary-click profile) + (should (keymapp mouse-trap-mode-map)) + + ;; Verify profile-specific behavior: mouse-1 allowed, scroll blocked + (should (eq (lookup-key mouse-trap-mode-map (kbd "<mouse-1>")) nil)) + (should (eq (lookup-key mouse-trap-mode-map (kbd "<wheel-up>")) 'ignore)) + + ;; Keymap should be in minor-mode-map-alist + (should (assq 'mouse-trap-mode minor-mode-map-alist))))) + +(ert-deftest test-integration-lighter-click-disables-mode () + "Test clicking lighter when mode is enabled disables it and removes keymap." + (with-temp-buffer + (emacs-lisp-mode) + (mouse-trap-mode 1) + (should mouse-trap-mode) + (should (assq 'mouse-trap-mode minor-mode-map-alist)) + + ;; Simulate clicking lighter to disable + (mouse-trap-mode -1) + + ;; Mode should be disabled + (should-not mouse-trap-mode) + + ;; Keymap should be removed from minor-mode-map-alist + (should-not (assq 'mouse-trap-mode minor-mode-map-alist)))) + +(ert-deftest test-integration-lighter-click-toggle-updates-keymap () + "Test toggling mode via lighter click rebuilds keymap for current mode. +This is the critical test - when you click to enable, it should rebuild +the keymap based on the CURRENT major mode's profile." + (with-temp-buffer + (let ((major-mode 'dashboard-mode)) + ;; Start disabled + (mouse-trap-mode -1) + (should-not mouse-trap-mode) + + ;; Enable via click (simulate) + (mouse-trap-mode 1) + (should mouse-trap-mode) + + ;; Should have dashboard profile (primary-click) + (let ((map1 mouse-trap-mode-map)) + (should (eq (lookup-key map1 (kbd "<mouse-1>")) nil)) ; allowed + (should (eq (lookup-key map1 (kbd "<wheel-up>")) 'ignore)) ; blocked + + ;; Disable + (mouse-trap-mode -1) + (should-not mouse-trap-mode) + + ;; Change to different mode + (setq major-mode 'pdf-view-mode) + + ;; Enable again + (mouse-trap-mode 1) + (should mouse-trap-mode) + + ;; Should now have pdf-view profile (full - all allowed) + (let ((map2 mouse-trap-mode-map)) + (should (eq (lookup-key map2 (kbd "<mouse-1>")) nil)) ; allowed + (should (eq (lookup-key map2 (kbd "<wheel-up>")) nil))) ; allowed now! + + ;; Verify maps are different + (should-not (equal map1 mouse-trap-mode-map)))))) + +(ert-deftest test-integration-lighter-click-respects-buffer-local-mode () + "Test lighter click affects only current buffer (buffer-local behavior)." + (let ((buf1 (generate-new-buffer "test1")) + (buf2 (generate-new-buffer "test2"))) + (unwind-protect + (progn + ;; Buffer 1: enable mode manually + (with-current-buffer buf1 + (setq major-mode 'text-mode) ; Use setq to avoid hooks + (mouse-trap-mode 1) + (should mouse-trap-mode)) + + ;; Buffer 2: mode should be independent (not auto-enabled) + (with-current-buffer buf2 + (setq major-mode 'text-mode) ; Use setq to avoid hooks + (should-not mouse-trap-mode) + + ;; Enable in buf2 + (mouse-trap-mode 1) + (should mouse-trap-mode)) + + ;; Verify buf1 still enabled + (with-current-buffer buf1 + (should mouse-trap-mode)) + + ;; Disable buf2 via click + (with-current-buffer buf2 + (mouse-trap-mode -1) + (should-not mouse-trap-mode)) + + ;; Verify buf1 unaffected + (with-current-buffer buf1 + (should mouse-trap-mode))) + + (kill-buffer buf1) + (kill-buffer buf2)))) + +(ert-deftest test-integration-lighter-click-with-excluded-mode () + "Test lighter click works even in excluded modes. +Auto-enable is blocked, but manual toggle should still work." + (with-temp-buffer + (dired-mode default-directory) + + ;; Auto-enable is blocked for dired + (mouse-trap-maybe-enable) + (should-not mouse-trap-mode) + + ;; But manual toggle should work + (mouse-trap-mode 1) + (should mouse-trap-mode) + (should (assq 'mouse-trap-mode minor-mode-map-alist)) + + ;; Toggle off + (mouse-trap-mode -1) + (should-not mouse-trap-mode) + (should-not (assq 'mouse-trap-mode minor-mode-map-alist)))) + +(ert-deftest test-integration-lighter-click-multiple-rapid-toggles () + "Test rapid clicking (multiple toggles) is stable and doesn't corrupt state." + (with-temp-buffer + (emacs-lisp-mode) + + ;; Rapid toggle 10 times + (dotimes (i 10) + (if (= (mod i 2) 0) + (mouse-trap-mode 1) + (mouse-trap-mode -1))) + + ;; Should end in disabled state (even number of toggles) + (should-not mouse-trap-mode) + (should-not (assq 'mouse-trap-mode minor-mode-map-alist)) + + ;; Enable one more time to end enabled + (mouse-trap-mode 1) + (should mouse-trap-mode) + (should (assq 'mouse-trap-mode minor-mode-map-alist)) + (should (keymapp mouse-trap-mode-map)))) + +(provide 'test-integration-mousetrap-mode-lighter-click) +;;; test-integration-mousetrap-mode-lighter-click.el ends here diff --git a/tests/test-integration-mousetrap-mode-profiles.el b/tests/test-integration-mousetrap-mode-profiles.el new file mode 100644 index 00000000..6abd3ad2 --- /dev/null +++ b/tests/test-integration-mousetrap-mode-profiles.el @@ -0,0 +1,374 @@ +;;; test-integration-mousetrap-mode-profiles.el --- Integration tests -*- lexical-binding: t; -*- + +;;; Commentary: +;; Integration tests for mousetrap-mode profile system. +;; Tests complete workflows including profile lookup, keymap building, +;; mode activation, inheritance, and dynamic reconfiguration. +;; +;; Components integrated: +;; - mouse-trap--get-profile-for-mode (profile lookup) +;; - mouse-trap--build-keymap (keymap generation) +;; - mouse-trap-mode (minor mode activation) +;; - derived-mode-p (Emacs mode inheritance) +;; - mouse-trap-maybe-enable (auto-activation logic) + +;;; Code: + +(require 'ert) +(require 'mousetrap-mode) + +;;; Integration Tests - Normal Workflows + +(ert-deftest test-integration-mousetrap-mode-profiles-org-mode-inherits-text-mode-disabled () + "Test org-mode inherits disabled profile from text-mode. + +Components integrated: +- mouse-trap--get-profile-for-mode (lookup with inheritance) +- derived-mode-p (mode hierarchy checking) +- org-mode (real major mode) + +Validates: +- Mode inheritance chain works correctly +- org-mode → text-mode → disabled profile" + (with-temp-buffer + (org-mode) + (let ((profile (mouse-trap--get-profile-for-mode))) + (should (eq 'disabled profile))))) + +(ert-deftest test-integration-mousetrap-mode-profiles-pdf-view-full-allows-all-events () + "Test pdf-view-mode gets full profile with all events allowed. + +Components integrated: +- mouse-trap--get-profile-for-mode (exact match lookup) +- mouse-trap--build-keymap (full profile keymap) + +Validates: +- Full profile configuration +- All event categories allowed (empty/minimal keymap)" + (let ((major-mode 'pdf-view-mode)) + (let ((profile (mouse-trap--get-profile-for-mode)) + (map (mouse-trap--build-keymap))) + (should (eq 'full profile)) + (should (keymapp map)) + ;; All events should be allowed (not bound) + (should (eq (lookup-key map (kbd "<mouse-1>")) nil)) + (should (eq (lookup-key map (kbd "<wheel-up>")) nil)) + (should (eq (lookup-key map (kbd "<drag-mouse-1>")) nil))))) + +(ert-deftest test-integration-mousetrap-mode-profiles-dashboard-primary-click-only () + "Test dashboard-mode gets primary-click profile. + +Components integrated: +- mouse-trap--get-profile-for-mode (lookup) +- mouse-trap--build-keymap (selective event binding) + +Validates: +- Primary-click profile allows mouse-1 +- Blocks mouse-2/3 and scroll events" + (let ((major-mode 'dashboard-mode)) + (let ((profile (mouse-trap--get-profile-for-mode)) + (map (mouse-trap--build-keymap))) + (should (eq 'primary-click profile)) + ;; mouse-1 allowed + (should (eq (lookup-key map (kbd "<mouse-1>")) nil)) + ;; mouse-2/3 blocked + (should (eq (lookup-key map (kbd "<mouse-2>")) 'ignore)) + ;; scroll blocked + (should (eq (lookup-key map (kbd "<wheel-up>")) 'ignore))))) + +(ert-deftest test-integration-mousetrap-mode-profiles-emacs-lisp-uses-default-disabled () + "Test unmapped mode uses default disabled profile. + +Components integrated: +- mouse-trap--get-profile-for-mode (fallback to default) +- mouse-trap--build-keymap (disabled keymap) + +Validates: +- Default profile fallback works +- All events blocked by default" + (with-temp-buffer + (emacs-lisp-mode) + (let ((profile (mouse-trap--get-profile-for-mode)) + (map (mouse-trap--build-keymap))) + (should (eq 'disabled profile)) + ;; All events blocked + (should (eq (lookup-key map (kbd "<wheel-up>")) 'ignore)) + (should (eq (lookup-key map (kbd "<mouse-1>")) 'ignore))))) + +(ert-deftest test-integration-mousetrap-mode-profiles-change-profile-no-reload () + "Test changing profiles and re-enabling mode without Emacs reload. + +Components integrated: +- mouse-trap--get-profile-for-mode (re-reads configuration) +- mouse-trap--build-keymap (rebuilds dynamically) +- mouse-trap-mode (mode toggle) + +Validates: +- KEY FEATURE: Dynamic reconfiguration +- Profile changes take effect without reload" + (let ((original-profiles mouse-trap-mode-profiles)) + (unwind-protect + (with-temp-buffer + (emacs-lisp-mode) + ;; Start with unmapped mode (gets default scroll-only) + (setq mouse-trap-mode-profiles nil) + (mouse-trap-mode 1) + (let ((map mouse-trap-mode-map)) + (should (eq (lookup-key map (kbd "<mouse-1>")) 'ignore))) + (mouse-trap-mode -1) + + ;; Change configuration + (setq mouse-trap-mode-profiles '((emacs-lisp-mode . full))) + + ;; Re-enable and verify new profile + (mouse-trap-mode 1) + (let ((map mouse-trap-mode-map)) + ;; Full profile - all events allowed + (should (eq (lookup-key map (kbd "<mouse-1>")) nil)) + (should (eq (lookup-key map (kbd "<wheel-up>")) nil)))) + ;; Restore original configuration + (setq mouse-trap-mode-profiles original-profiles)))) + +(ert-deftest test-integration-mousetrap-mode-profiles-switch-major-mode-updates-profile () + "Test switching major-mode and re-enabling updates profile. + +Components integrated: +- mouse-trap--get-profile-for-mode (mode-sensitive lookup) +- Major mode switching +- Mode re-activation + +Validates: +- Profile changes with major-mode +- Mode-sensitive behavior" + (with-temp-buffer + (text-mode) + (mouse-trap-mode 1) + (let ((map1 mouse-trap-mode-map)) + ;; text-mode = disabled (inherits from default), all blocked + (should (eq (lookup-key map1 (kbd "<wheel-up>")) 'ignore)) + (should (eq (lookup-key map1 (kbd "<mouse-1>")) 'ignore)) + (mouse-trap-mode -1)) + + ;; Switch to pdf-view-mode which has full profile + (setq major-mode 'pdf-view-mode) + (mouse-trap-mode 1) + (let ((map2 mouse-trap-mode-map)) + ;; pdf-view-mode = full, all events allowed + (should (eq (lookup-key map2 (kbd "<wheel-up>")) nil)) + (should (eq (lookup-key map2 (kbd "<mouse-1>")) nil))))) + +(ert-deftest test-integration-mousetrap-mode-profiles-auto-enable-respects-exclusions () + "Test auto-enable respects exclusion list. + +Components integrated: +- mouse-trap-maybe-enable (auto-activation logic) +- mouse-trap-excluded-modes (exclusion list) +- derived-mode-p (mode checking) + +Validates: +- Exclusion list prevents auto-activation +- dired-mode is excluded" + (with-temp-buffer + (dired-mode default-directory) + ;; Manually call maybe-enable + (mouse-trap-maybe-enable) + ;; Should NOT enable + (should-not mouse-trap-mode))) + +(ert-deftest test-integration-mousetrap-mode-profiles-manual-enable-in-excluded-mode () + "Test manual activation works in excluded modes. + +Components integrated: +- mouse-trap-mode (manual activation) +- Exclusion list (should not affect manual activation) + +Validates: +- Manual activation bypasses auto-enable exclusions +- Exclusions only affect hooks, not manual toggling" + (with-temp-buffer + (dired-mode default-directory) + ;; Manually enable + (mouse-trap-mode 1) + ;; Should be enabled despite being in exclusion list + (should mouse-trap-mode))) + +;;; Integration Tests - Boundary Cases + +(ert-deftest test-integration-mousetrap-mode-profiles-markdown-inherits-text-disabled () + "Test markdown-mode inherits disabled profile from text-mode. + +Components integrated: +- Mode inheritance (markdown-mode → text-mode) +- Profile lookup with inheritance + +Validates: +- Multi-level inheritance works +- Markdown gets disabled profile" + (with-temp-buffer + (when (fboundp 'markdown-mode) + (markdown-mode) + (let ((profile (mouse-trap--get-profile-for-mode))) + (should (eq 'disabled profile)))))) + +(ert-deftest test-integration-mousetrap-mode-profiles-help-mode-inherits-special-disabled () + "Test help-mode inherits disabled from special-mode. + +Components integrated: +- Mode inheritance (help-mode → special-mode) +- Profile lookup + +Validates: +- special-mode inheritance works +- Help buffers get disabled profile" + (with-temp-buffer + (help-mode) + (let ((profile (mouse-trap--get-profile-for-mode))) + (should (eq 'disabled profile))))) + +(ert-deftest test-integration-mousetrap-mode-profiles-toggle-multiple-times () + "Test toggling mode multiple times is stable. + +Components integrated: +- mouse-trap-mode (activation/deactivation) +- Keymap building (multiple times) + +Validates: +- Mode toggle robustness +- No errors on rapid toggling" + (with-temp-buffer + (emacs-lisp-mode) + ;; Toggle multiple times + (dotimes (_ 5) + (mouse-trap-mode 1) + (should mouse-trap-mode) + (mouse-trap-mode -1) + (should-not mouse-trap-mode)))) + +(ert-deftest test-integration-mousetrap-mode-profiles-multiple-buffers-independent () + "Test multiple buffers have independent profiles. + +Components integrated: +- Buffer-local mode behavior +- Profile lookup per buffer +- Multiple mode activation + +Validates: +- Buffer-local mode isolation +- Each buffer gets correct profile" + (let ((buf1 (generate-new-buffer "test1")) + (buf2 (generate-new-buffer "test2"))) + (unwind-protect + (progn + ;; Buffer 1: text-mode (disabled = default) + (with-current-buffer buf1 + (text-mode) + (mouse-trap-mode 1) + (should mouse-trap-mode) + (let ((map1 mouse-trap-mode-map)) + (should (eq (lookup-key map1 (kbd "<wheel-up>")) 'ignore)) + (should (eq (lookup-key map1 (kbd "<mouse-1>")) 'ignore)))) + + ;; Buffer 2: pdf-view-mode (full profile) + (with-current-buffer buf2 + (setq major-mode 'pdf-view-mode) + (mouse-trap-mode 1) + (should mouse-trap-mode) + (let ((map2 mouse-trap-mode-map)) + ;; All events allowed + (should (eq (lookup-key map2 (kbd "<wheel-up>")) nil)) + (should (eq (lookup-key map2 (kbd "<mouse-1>")) nil))))) + + ;; Cleanup + (kill-buffer buf1) + (kill-buffer buf2)))) + +;;; Integration Tests - Edge Cases + +(ert-deftest test-integration-mousetrap-mode-profiles-change-default-profile () + "Test changing default profile takes effect. + +Components integrated: +- mouse-trap-default-profile (configuration) +- Profile fallback logic +- Dynamic reconfiguration + +Validates: +- Default profile configuration works +- Changes take effect on re-enable" + (let ((original-default mouse-trap-default-profile) + (original-profiles mouse-trap-mode-profiles)) + (unwind-protect + (with-temp-buffer + ;; Unmapped mode uses default + (setq mouse-trap-mode-profiles nil) + (setq mouse-trap-default-profile 'disabled) + (emacs-lisp-mode) + (mouse-trap-mode 1) + (let ((map1 mouse-trap-mode-map)) + ;; Default = disabled, all blocked + (should (eq (lookup-key map1 (kbd "<wheel-up>")) 'ignore)) + (should (eq (lookup-key map1 (kbd "<mouse-1>")) 'ignore)) + (mouse-trap-mode -1)) + + ;; Change default + (setq mouse-trap-default-profile 'full) + (mouse-trap-mode 1) + (let ((map2 mouse-trap-mode-map)) + ;; Default = full, all allowed + (should (eq (lookup-key map2 (kbd "<wheel-up>")) nil)) + (should (eq (lookup-key map2 (kbd "<mouse-1>")) nil)))) + ;; Restore original configuration + (setq mouse-trap-default-profile original-default) + (setq mouse-trap-mode-profiles original-profiles)))) + +(ert-deftest test-integration-mousetrap-mode-profiles-add-new-profile-runtime () + "Test adding new profile at runtime. + +Components integrated: +- mouse-trap-profiles (extensibility) +- Profile lookup +- Runtime configuration + +Validates: +- Runtime extensibility +- New profiles work immediately" + (let ((original-profiles mouse-trap-profiles) + (original-mode-profiles mouse-trap-mode-profiles)) + (unwind-protect + (with-temp-buffer + (setq mouse-trap-profiles + (append mouse-trap-profiles + '((custom-profile . (primary-click scroll))))) + (setq mouse-trap-mode-profiles '((emacs-lisp-mode . custom-profile))) + (emacs-lisp-mode) + (mouse-trap-mode 1) + (let ((map mouse-trap-mode-map)) + ;; Custom profile: primary-click and scroll allowed + (should (eq (lookup-key map (kbd "<mouse-1>")) nil)) + (should (eq (lookup-key map (kbd "<wheel-up>")) nil)) + ;; Secondary click blocked + (should (eq (lookup-key map (kbd "<mouse-2>")) 'ignore)))) + ;; Restore original configuration + (setq mouse-trap-profiles original-profiles) + (setq mouse-trap-mode-profiles original-mode-profiles)))) + +(ert-deftest test-integration-mousetrap-mode-profiles-remove-mode-mapping-uses-default () + "Test removing mode mapping falls back to default. + +Components integrated: +- Profile lookup fallback +- Dynamic configuration + +Validates: +- Graceful handling of removed mappings +- Fallback to default profile" + (with-temp-buffer + (let ((mouse-trap-mode-profiles nil) ; Dashboard not mapped + (mouse-trap-default-profile 'scroll-only) + (major-mode 'dashboard-mode)) + (let ((profile (mouse-trap--get-profile-for-mode))) + ;; Should fall back to default + (should (eq 'scroll-only profile)))))) + +(provide 'test-integration-mousetrap-mode-profiles) +;;; test-integration-mousetrap-mode-profiles.el ends here diff --git a/tests/test-integration-recording-device-workflow.el b/tests/test-integration-recording-device-workflow.el new file mode 100644 index 00000000..ba92d700 --- /dev/null +++ b/tests/test-integration-recording-device-workflow.el @@ -0,0 +1,232 @@ +;;; test-integration-recording-device-workflow.el --- Integration tests for recording device workflow -*- lexical-binding: t; -*- + +;;; Commentary: +;; Integration tests covering the complete device detection and grouping workflow. +;; +;; This tests the full pipeline from raw pactl output through parsing, grouping, +;; and friendly name assignment. The workflow enables users to select audio devices +;; for recording calls/meetings. +;; +;; Components integrated: +;; - cj/recording--parse-pactl-output (parse raw pactl output into structured data) +;; - cj/recording-parse-sources (shell command wrapper) +;; - cj/recording-group-devices-by-hardware (group inputs/monitors by device) +;; - cj/recording-friendly-state (convert technical state names) +;; - Bluetooth MAC address normalization (colons → underscores) +;; - Device name pattern matching (USB, PCI, Bluetooth) +;; - Friendly name assignment (user-facing device names) +;; +;; Critical integration points: +;; - Parse output must produce data that group-devices can process +;; - Bluetooth MAC normalization must work across parse→group boundary +;; - Incomplete devices (only mic OR only monitor) must be filtered +;; - Friendly names must correctly identify device types + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Test Fixtures Helper + +(defun test-load-fixture (filename) + "Load fixture file FILENAME from tests/fixtures directory." + (let ((fixture-path (expand-file-name + (concat "tests/fixtures/" filename) + user-emacs-directory))) + (with-temp-buffer + (insert-file-contents fixture-path) + (buffer-string)))) + +;;; Normal Cases - Complete Workflow + +(ert-deftest test-integration-recording-device-workflow-parse-to-group-all-devices () + "Test complete workflow from pactl output to grouped devices. + +When pactl output contains all three device types (built-in, USB, Bluetooth), +the workflow should parse, group, and assign friendly names to all devices. + +Components integrated: +- cj/recording--parse-pactl-output (parsing) +- cj/recording-group-devices-by-hardware (grouping + MAC normalization) +- Device pattern matching (USB/PCI/Bluetooth detection) +- Friendly name assignment + +Validates: +- All three device types are detected +- Bluetooth MAC addresses normalized (colons → underscores) +- Each device has both mic and monitor +- Friendly names correctly assigned +- Complete data flow: raw output → parsed list → grouped pairs" + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + ;; Test parse step + (let ((parsed (cj/recording-parse-sources))) + (should (= 6 (length parsed))) + + ;; Test group step (receives parsed data) + (let ((grouped (cj/recording-group-devices-by-hardware))) + (should (= 3 (length grouped))) + + ;; Validate built-in device + (let ((built-in (assoc "Built-in Laptop Audio" grouped))) + (should built-in) + (should (string-prefix-p "alsa_input.pci" (cadr built-in))) + (should (string-prefix-p "alsa_output.pci" (cddr built-in)))) + + ;; Validate USB device + (let ((usb (assoc "Jabra SPEAK 510 USB" grouped))) + (should usb) + (should (string-match-p "Jabra" (cadr usb))) + (should (string-match-p "Jabra" (cddr usb)))) + + ;; Validate Bluetooth device (CRITICAL: MAC normalization) + (let ((bluetooth (assoc "Bluetooth Headset" grouped))) + (should bluetooth) + ;; Input has colons + (should (string-match-p "00:1B:66:C0:91:6D" (cadr bluetooth))) + ;; Output has underscores + (should (string-match-p "00_1B_66_C0_91_6D" (cddr bluetooth))) + ;; But they're grouped together! + (should (equal "bluez_input.00:1B:66:C0:91:6D" (cadr bluetooth))) + (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" (cddr bluetooth))))))))) + +(ert-deftest test-integration-recording-device-workflow-friendly-states-in-list () + "Test that friendly state names appear in device list output. + +When listing devices, technical state names (SUSPENDED, RUNNING) should be +converted to friendly names (Ready, Active) for better UX. + +Components integrated: +- cj/recording-parse-sources (parsing with state) +- cj/recording-friendly-state (state name conversion) + +Validates: +- SUSPENDED → Ready +- RUNNING → Active +- State conversion works across the parse workflow" + (let ((output (concat + "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((parsed (cj/recording-parse-sources))) + ;; Verify states are parsed correctly + (should (equal "SUSPENDED" (nth 2 (nth 0 parsed)))) + (should (equal "RUNNING" (nth 2 (nth 1 parsed)))) + + ;; Verify friendly conversion works + (should (equal "Ready" (cj/recording-friendly-state (nth 2 (nth 0 parsed))))) + (should (equal "Active" (cj/recording-friendly-state (nth 2 (nth 1 parsed))))))))) + +;;; Boundary Cases - Incomplete Devices + +(ert-deftest test-integration-recording-device-workflow-incomplete-devices-filtered () + "Test that devices with only mic OR only monitor are filtered out. + +For call recording, we need BOTH mic and monitor from the same device. +Incomplete devices should not appear in the grouped output. + +Components integrated: +- cj/recording-parse-sources (parsing all devices) +- cj/recording-group-devices-by-hardware (filtering incomplete pairs) + +Validates: +- Device with only mic is filtered +- Device with only monitor is filtered +- Only complete devices (both mic and monitor) are returned +- Filtering happens at group stage, not parse stage" + (let ((output (concat + ;; Complete device + "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + ;; Incomplete: USB mic with no monitor + "100\talsa_input.usb-device.mono-fallback\tPipeWire\ts16le 1ch 16000Hz\tSUSPENDED\n" + ;; Incomplete: Bluetooth monitor with no mic + "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + ;; Parse sees all 4 devices + (let ((parsed (cj/recording-parse-sources))) + (should (= 4 (length parsed))) + + ;; Group returns only 1 complete device + (let ((grouped (cj/recording-group-devices-by-hardware))) + (should (= 1 (length grouped))) + (should (equal "Built-in Laptop Audio" (caar grouped)))))))) + +;;; Edge Cases - Bluetooth MAC Normalization + +(ert-deftest test-integration-recording-device-workflow-bluetooth-mac-variations () + "Test Bluetooth MAC normalization with different formats. + +Bluetooth devices use colons in input names but underscores in output names. +The grouping must normalize these to match devices correctly. + +Components integrated: +- cj/recording-parse-sources (preserves original MAC format) +- cj/recording-group-devices-by-hardware (normalizes MAC for matching) +- Base name extraction (regex patterns) +- MAC address transformation (underscores → colons) + +Validates: +- Input with colons (bluez_input.AA:BB:CC:DD:EE:FF) parsed correctly +- Output with underscores (bluez_output.AA_BB_CC_DD_EE_FF) parsed correctly +- Normalization happens during grouping +- Devices paired despite format difference +- Original device names preserved (not mutated)" + (let ((output (concat + "79\tbluez_input.11:22:33:44:55:66\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n" + "81\tbluez_output.11_22_33_44_55_66.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((parsed (cj/recording-parse-sources))) + ;; Original formats preserved in parse + (should (string-match-p "11:22:33" (caar parsed))) + (should (string-match-p "11_22_33" (caadr parsed))) + + ;; But grouping matches them + (let ((grouped (cj/recording-group-devices-by-hardware))) + (should (= 1 (length grouped))) + (should (equal "Bluetooth Headset" (caar grouped))) + ;; Original names preserved + (should (equal "bluez_input.11:22:33:44:55:66" (cadar grouped))) + (should (equal "bluez_output.11_22_33_44_55_66.1.monitor" (cddar grouped)))))))) + +;;; Error Cases - Malformed Data + +(ert-deftest test-integration-recording-device-workflow-malformed-output-handled () + "Test that malformed pactl output is handled gracefully. + +When pactl output is malformed or unparseable, the workflow should not crash. +It should return empty results at appropriate stages. + +Components integrated: +- cj/recording--parse-pactl-output (malformed line handling) +- cj/recording-group-devices-by-hardware (empty input handling) + +Validates: +- Malformed lines are silently skipped during parse +- Empty parse results don't crash grouping +- Workflow degrades gracefully +- No exceptions thrown" + (let ((output (test-load-fixture "pactl-output-malformed.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((parsed (cj/recording-parse-sources))) + ;; Malformed output produces empty parse + (should (null parsed)) + + ;; Empty parse produces empty grouping (no crash) + (let ((grouped (cj/recording-group-devices-by-hardware))) + (should (null grouped))))))) + +(provide 'test-integration-recording-device-workflow) +;;; test-integration-recording-device-workflow.el ends here diff --git a/tests/test-integration-recording-modeline-sync.el b/tests/test-integration-recording-modeline-sync.el new file mode 100644 index 00000000..fab442bd --- /dev/null +++ b/tests/test-integration-recording-modeline-sync.el @@ -0,0 +1,384 @@ +;;; test-integration-recording-modeline-sync.el --- Integration tests for modeline sync -*- lexical-binding: t; -*- + +;;; Commentary: +;; Integration tests validating that the modeline indicator NEVER desyncs +;; from the actual recording state throughout the entire toggle lifecycle. +;; +;; This tests the critical requirement: modeline must always accurately +;; reflect whether recording is happening, with NO desyncs. +;; +;; Components integrated: +;; - cj/audio-recording-toggle (state changes) +;; - cj/video-recording-toggle (state changes) +;; - cj/recording-modeline-indicator (UI state display) +;; - cj/ffmpeg-record-audio (process creation) +;; - cj/ffmpeg-record-video (process creation) +;; - cj/recording-process-sentinel (auto-updates modeline) +;; - cj/audio-recording-stop (cleanup triggers update) +;; - cj/video-recording-stop (cleanup triggers update) +;; - force-mode-line-update (explicit refresh calls) +;; +;; Validates: +;; - Modeline updates immediately on toggle start +;; - Modeline updates immediately on toggle stop +;; - Modeline updates when sentinel runs (process dies) +;; - Modeline shows correct state for audio, video, or both +;; - Modeline never shows stale state +;; - process-live-p check prevents desync on dead processes + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Stub directory variables +(defvar video-recordings-dir "/tmp/video-recordings/") +(defvar audio-recordings-dir "/tmp/audio-recordings/") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Setup and Teardown + +(defun test-integration-modeline-setup () + "Reset all variables before each test." + (setq cj/video-recording-ffmpeg-process nil) + (setq cj/audio-recording-ffmpeg-process nil) + (setq cj/recording-mic-device "test-mic") + (setq cj/recording-system-device "test-monitor")) + +(defun test-integration-modeline-teardown () + "Clean up after each test." + (when cj/video-recording-ffmpeg-process + (ignore-errors (delete-process cj/video-recording-ffmpeg-process))) + (when cj/audio-recording-ffmpeg-process + (ignore-errors (delete-process cj/audio-recording-ffmpeg-process))) + (setq cj/video-recording-ffmpeg-process nil) + (setq cj/audio-recording-ffmpeg-process nil) + (setq cj/recording-mic-device nil) + (setq cj/recording-system-device nil)) + +;;; Integration Tests - Modeline Sync on Toggle + +(ert-deftest test-integration-recording-modeline-sync-audio-start-updates-immediately () + "Test that modeline updates immediately when audio recording starts. + +When user toggles audio recording on: +1. Process is created +2. Modeline indicator updates to show 🔴Audio +3. State is in sync immediately (not delayed) + +Components integrated: +- cj/audio-recording-toggle +- cj/ffmpeg-record-audio (calls force-mode-line-update) +- cj/recording-modeline-indicator + +Validates: +- Modeline syncs on start +- No delay or race condition" + (test-integration-modeline-setup) + (unwind-protect + (cl-letf (((symbol-function 'file-directory-p) + (lambda (_dir) t)) + ((symbol-function 'start-process-shell-command) + (lambda (name _buffer _cmd) + (make-process :name name :command '("sleep" "1000"))))) + + ;; Before toggle: no recording + (should (equal "" (cj/recording-modeline-indicator))) + + ;; Toggle on + (cj/audio-recording-toggle nil) + + ;; Immediately after toggle: modeline should show recording + (should (equal " 🔴Audio " (cj/recording-modeline-indicator))) + + ;; Process should be alive + (should (process-live-p cj/audio-recording-ffmpeg-process))) + (test-integration-modeline-teardown))) + +(ert-deftest test-integration-recording-modeline-sync-audio-stop-updates-immediately () + "Test that modeline updates immediately when audio recording stops. + +When user toggles audio recording off: +1. Process is interrupted +2. Variable is cleared +3. Modeline indicator updates to show empty +4. State is in sync immediately + +Components integrated: +- cj/audio-recording-toggle (stop path) +- cj/audio-recording-stop (calls force-mode-line-update) +- cj/recording-modeline-indicator + +Validates: +- Modeline syncs on stop +- No stale indicator after stop" + (test-integration-modeline-setup) + (unwind-protect + (cl-letf (((symbol-function 'file-directory-p) + (lambda (_dir) t)) + ((symbol-function 'start-process-shell-command) + (lambda (name _buffer _cmd) + (make-process :name name :command '("sleep" "1000"))))) + + ;; Start recording + (cj/audio-recording-toggle nil) + (should (equal " 🔴Audio " (cj/recording-modeline-indicator))) + + ;; Stop recording + (cj/audio-recording-toggle nil) + + ;; Immediately after stop: modeline should be empty + (should (equal "" (cj/recording-modeline-indicator))) + + ;; Process should be nil + (should (null cj/audio-recording-ffmpeg-process))) + (test-integration-modeline-teardown))) + +(ert-deftest test-integration-recording-modeline-sync-video-lifecycle () + "Test modeline sync through complete video recording lifecycle. + +Components integrated: +- cj/video-recording-toggle (both start and stop) +- cj/ffmpeg-record-video +- cj/video-recording-stop +- cj/recording-modeline-indicator + +Validates: +- Video recording follows same sync pattern as audio +- Modeline shows 🔴Video correctly" + (test-integration-modeline-setup) + (unwind-protect + (cl-letf (((symbol-function 'file-directory-p) + (lambda (_dir) t)) + ((symbol-function 'start-process-shell-command) + (lambda (name _buffer _cmd) + (make-process :name name :command '("sleep" "1000"))))) + + ;; Initial state + (should (equal "" (cj/recording-modeline-indicator))) + + ;; Start video + (cj/video-recording-toggle nil) + (should (equal " 🔴Video " (cj/recording-modeline-indicator))) + + ;; Stop video + (cj/video-recording-toggle nil) + (should (equal "" (cj/recording-modeline-indicator)))) + (test-integration-modeline-teardown))) + +;;; Integration Tests - Modeline Sync with Both Recordings + +(ert-deftest test-integration-recording-modeline-sync-both-recordings-transitions () + "Test modeline sync through all possible state transitions. + +Tests transitions: +- none → audio → both → video → none +- Validates modeline updates at every transition + +Components integrated: +- cj/audio-recording-toggle +- cj/video-recording-toggle +- cj/recording-modeline-indicator (handles all states) + +Validates: +- Modeline accurately reflects all combinations +- Transitions are clean with no stale state" + (test-integration-modeline-setup) + (unwind-protect + (cl-letf (((symbol-function 'file-directory-p) + (lambda (_dir) t)) + ((symbol-function 'start-process-shell-command) + (lambda (name _buffer _cmd) + (make-process :name name :command '("sleep" "1000"))))) + + ;; State 1: None + (should (equal "" (cj/recording-modeline-indicator))) + + ;; State 2: Audio only + (cj/audio-recording-toggle nil) + (should (equal " 🔴Audio " (cj/recording-modeline-indicator))) + + ;; State 3: Both + (cj/video-recording-toggle nil) + (should (equal " 🔴A+V " (cj/recording-modeline-indicator))) + + ;; State 4: Video only (stop audio) + (cj/audio-recording-toggle nil) + (should (equal " 🔴Video " (cj/recording-modeline-indicator))) + + ;; State 5: None (stop video) + (cj/video-recording-toggle nil) + (should (equal "" (cj/recording-modeline-indicator)))) + (test-integration-modeline-teardown))) + +;;; Integration Tests - Modeline Sync with Sentinel + +(ert-deftest test-integration-recording-modeline-sync-sentinel-updates-on-crash () + "Test that modeline syncs when process dies and sentinel runs. + +When recording process crashes: +1. Sentinel detects process death +2. Sentinel clears variable +3. Sentinel calls force-mode-line-update +4. Modeline indicator shows no recording + +Components integrated: +- cj/ffmpeg-record-audio (attaches sentinel) +- cj/recording-process-sentinel (cleanup + modeline update) +- cj/recording-modeline-indicator + +Validates: +- Sentinel updates modeline on process death +- Modeline syncs automatically without user action +- Critical: prevents desync when process crashes" + (test-integration-modeline-setup) + (unwind-protect + (cl-letf (((symbol-function 'file-directory-p) + (lambda (_dir) t)) + ((symbol-function 'start-process-shell-command) + (lambda (name _buffer _cmd) + ;; Create process that exits immediately + (make-process :name name :command '("sh" "-c" "exit 1"))))) + + ;; Start recording + (cj/audio-recording-toggle nil) + + ;; Immediately after start: should show recording + (should (equal " 🔴Audio " (cj/recording-modeline-indicator))) + + ;; Wait for process to exit and sentinel to run + (sit-for 0.3) + + ;; After sentinel runs: modeline should be clear + (should (equal "" (cj/recording-modeline-indicator))) + + ;; Variable should be nil + (should (null cj/audio-recording-ffmpeg-process))) + (test-integration-modeline-teardown))) + +(ert-deftest test-integration-recording-modeline-sync-dead-process-not-shown () + "Test that modeline never shows dead process as recording. + +The modeline indicator uses process-live-p to check if process is ACTUALLY +alive, not just if the variable is set. This prevents desync. + +Components integrated: +- cj/recording-modeline-indicator (uses process-live-p) + +Validates: +- Dead process doesn't show as recording +- process-live-p check prevents desync +- Critical: if variable is set but process is dead, shows empty" + (test-integration-modeline-setup) + (unwind-protect + (let ((dead-process (make-process :name "test-audio" :command '("sh" "-c" "exit 0")))) + ;; Set variable to dead process (simulating race condition) + (setq cj/audio-recording-ffmpeg-process dead-process) + + ;; Wait for process to die + (sit-for 0.1) + + ;; Modeline should NOT show recording (process is dead) + (should (equal "" (cj/recording-modeline-indicator))) + + ;; Even though variable is set + (should (eq dead-process cj/audio-recording-ffmpeg-process)) + + ;; Process is dead + (should-not (process-live-p dead-process))) + (test-integration-modeline-teardown))) + +;;; Integration Tests - Modeline Sync Under Rapid Toggling + +(ert-deftest test-integration-recording-modeline-sync-rapid-toggle-stays-synced () + "Test modeline stays synced under rapid start/stop toggling. + +When user rapidly toggles recording on and off: +- Modeline should stay in sync at every step +- No race conditions or stale state + +Components integrated: +- cj/audio-recording-toggle (rapid calls) +- cj/ffmpeg-record-audio +- cj/audio-recording-stop +- cj/recording-modeline-indicator + +Validates: +- Modeline syncs even with rapid state changes +- No race conditions in update logic" + (test-integration-modeline-setup) + (unwind-protect + (cl-letf (((symbol-function 'file-directory-p) + (lambda (_dir) t)) + ((symbol-function 'start-process-shell-command) + (lambda (name _buffer _cmd) + (make-process :name name :command '("sleep" "1000"))))) + + ;; Rapid toggling + (dotimes (_i 5) + ;; Start + (cj/audio-recording-toggle nil) + (should (equal " 🔴Audio " (cj/recording-modeline-indicator))) + (should cj/audio-recording-ffmpeg-process) + + ;; Stop + (cj/audio-recording-toggle nil) + (should (equal "" (cj/recording-modeline-indicator))) + (should (null cj/audio-recording-ffmpeg-process)))) + (test-integration-modeline-teardown))) + +(ert-deftest test-integration-recording-modeline-sync-both-recordings-independent () + "Test that audio and video modeline updates are independent. + +When one recording stops, the other's indicator persists. +When one recording starts, both indicators combine correctly. + +Components integrated: +- cj/audio-recording-toggle +- cj/video-recording-toggle +- cj/recording-modeline-indicator (combines states) + +Validates: +- Independent recordings don't interfere +- Modeline correctly shows: audio-only, video-only, or both +- Stopping one doesn't affect other's indicator" + (test-integration-modeline-setup) + (unwind-protect + (cl-letf (((symbol-function 'file-directory-p) + (lambda (_dir) t)) + ((symbol-function 'start-process-shell-command) + (lambda (name _buffer _cmd) + (make-process :name name :command '("sleep" "1000"))))) + + ;; Start audio + (cj/audio-recording-toggle nil) + (should (equal " 🔴Audio " (cj/recording-modeline-indicator))) + + ;; Add video - modeline should combine + (cj/video-recording-toggle nil) + (should (equal " 🔴A+V " (cj/recording-modeline-indicator))) + + ;; Stop audio - video indicator should persist + (cj/audio-recording-toggle nil) + (should (equal " 🔴Video " (cj/recording-modeline-indicator))) + + ;; Start audio again - should recombine + (cj/audio-recording-toggle nil) + (should (equal " 🔴A+V " (cj/recording-modeline-indicator))) + + ;; Stop video - audio indicator should persist + (cj/video-recording-toggle nil) + (should (equal " 🔴Audio " (cj/recording-modeline-indicator))) + + ;; Stop audio - should be empty + (cj/audio-recording-toggle nil) + (should (equal "" (cj/recording-modeline-indicator)))) + (test-integration-modeline-teardown))) + +(provide 'test-integration-recording-modeline-sync) +;;; test-integration-recording-modeline-sync.el ends here diff --git a/tests/test-integration-recording-monitor-capture-interactive.el b/tests/test-integration-recording-monitor-capture-interactive.el new file mode 100644 index 00000000..ece8b79e --- /dev/null +++ b/tests/test-integration-recording-monitor-capture-interactive.el @@ -0,0 +1,186 @@ +;;; test-integration-recording-monitor-capture-interactive.el --- Interactive recording test -*- lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> +;; Created: 2025-11-14 + +;;; Commentary: +;; +;; **INTERACTIVE TEST - Run from within Emacs** +;; +;; This test must be run from an interactive Emacs session where recording +;; devices are already configured (C-; r c). +;; +;; USAGE: +;; 1. Ensure devices are configured: C-; r c +;; 2. Load this file: M-x load-file RET tests/test-integration-recording-monitor-capture-interactive.el RET +;; 3. Run test: M-x test-recording-monitor-now RET +;; +;; OR simply: +;; M-x ert RET test-integration-recording-monitor-capture RET +;; +;; The test will: +;; - Play test audio through your speakers (5 seconds) +;; - Record it +;; - Transcribe it +;; - Verify the transcription contains the expected text +;; +;; This verifies that phone call audio (speaker output) is being captured correctly. + +;;; Code: + +(require 'video-audio-recording) +(require 'transcription-config) + +(defvar test-recording--test-audio + (expand-file-name "tests/fixtures/audio/speaker-output-test.wav" user-emacs-directory) + "Test audio file for speaker output testing.") + +(defvar test-recording--expected-phrases + '("hear me" "testing" "one") + "Expected phrases in transcription (partial match OK). +Based on actual recording: 'Can you hear me? Testing, one, two, three.'") + +(defun test-recording--cleanup-files (recording-file) + "Clean up RECORDING-FILE and associated files." + (when (and recording-file (file-exists-p recording-file)) + (let* ((base (file-name-sans-extension recording-file)) + (txt-file (concat base ".txt")) + (log-file (concat base ".log"))) + (when (file-exists-p recording-file) (delete-file recording-file)) + (when (file-exists-p txt-file) (delete-file txt-file)) + (when (file-exists-p log-file) (delete-file log-file))))) + +(defun test-recording--wait-for-file (file timeout) + "Wait for FILE to exist and have content, up to TIMEOUT seconds. +Returns FILE path if successful, nil if timeout." + (let ((deadline (time-add (current-time) (seconds-to-time timeout)))) + (while (and (time-less-p (current-time) deadline) + (or (not (file-exists-p file)) + (= 0 (file-attribute-size (file-attributes file))))) + (sleep-for 1) + (message "Waiting for %s... (%d sec remaining)" + (file-name-nondirectory file) + (ceiling (float-time (time-subtract deadline (current-time)))))) + (when (and (file-exists-p file) + (> (file-attribute-size (file-attributes file)) 0)) + file))) + +;;;###autoload +(defun test-recording-monitor-now () + "Test recording monitor capture interactively. +This function can be called with M-x to test recording without ERT framework." + (interactive) + + ;; Pre-flight checks + (unless (executable-find "paplay") + (user-error "paplay not found. Install pulseaudio-utils")) + (unless (executable-find "ffmpeg") + (user-error "ffmpeg not found. Install ffmpeg")) + (unless (file-exists-p test-recording--test-audio) + (user-error "Test audio file not found: %s" test-recording--test-audio)) + (unless (and cj/recording-mic-device cj/recording-system-device) + (user-error "Recording devices not configured. Run C-; r c first")) + + (let ((test-dir (make-temp-file "recording-test-" t)) + (recording-file nil) + (playback-proc nil)) + (unwind-protect + (progn + (message "\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + (message "RECORDING MONITOR CAPTURE TEST") + (message "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + (message "Configuration:") + (message " Mic: %s" cj/recording-mic-device) + (message " Monitor: %s" cj/recording-system-device) + (message " Backend: %s\n" cj/transcribe-backend) + + ;; Step 1: Start recording + (message "[1/6] Starting recording...") + (cj/ffmpeg-record-audio test-dir) + (sleep-for 1) + (unless (process-live-p cj/audio-recording-ffmpeg-process) + (error "Failed to start recording")) + (message "✓ Recording started\n") + + ;; Step 2: Play test audio + (message "[2/6] Playing test audio through speakers...") + (setq playback-proc (start-process "test-playback" "*test-playback*" + "paplay" test-recording--test-audio)) + (message "✓ Playback started\n") + + ;; Step 3: Wait for playback + (message "[3/6] Waiting for playback to complete...") + (let ((waited 0)) + (while (and (process-live-p playback-proc) (< waited 10)) + (sleep-for 0.5) + (setq waited (+ waited 0.5))) + (when (process-live-p playback-proc) + (kill-process playback-proc) + (error "Playback timed out"))) + (sleep-for 1) + (message "✓ Playback completed\n") + + ;; Step 4: Stop recording + (message "[4/6] Stopping recording...") + (cj/audio-recording-stop) + (sleep-for 1) + + ;; Find recording file + (let ((files (directory-files test-dir t "\\.m4a$"))) + (unless (= 1 (length files)) + (error "Expected 1 recording file, found %d" (length files))) + (setq recording-file (car files))) + + (message "✓ Recording stopped") + (message " File: %s" recording-file) + (message " Size: %d bytes\n" + (file-attribute-size (file-attributes recording-file))) + + ;; Step 5: Transcribe + (message "[5/6] Transcribing (this may take 30-60 seconds)...") + (cj/transcribe-audio recording-file) + + (let ((txt-file (concat (file-name-sans-extension recording-file) ".txt"))) + (unless (test-recording--wait-for-file txt-file 120) + (error "Transcription timed out or failed")) + (message "✓ Transcription completed\n") + + ;; Step 6: Verify + (message "[6/6] Verifying transcription...") + (let ((transcript (with-temp-buffer + (insert-file-contents txt-file) + (downcase (buffer-string)))) + (matches 0)) + (message "Transcript (%d chars): %s..." + (length transcript) + (substring transcript 0 (min 80 (length transcript)))) + + (dolist (phrase test-recording--expected-phrases) + (when (string-match-p phrase transcript) + (setq matches (1+ matches)) + (message " ✓ Found: '%s'" phrase))) + + (message "\nMatched %d/%d expected phrases" + matches (length test-recording--expected-phrases)) + + (if (>= matches 2) + (progn + (message "\n✓✓✓ TEST PASSED ✓✓✓") + (message "Monitor is correctly capturing speaker audio!")) + (error "TEST FAILED: Only matched %d/%d phrases" + matches (length test-recording--expected-phrases))))))) + + ;; Cleanup + (when (and playback-proc (process-live-p playback-proc)) + (kill-process playback-proc)) + (when (and cj/audio-recording-ffmpeg-process + (process-live-p cj/audio-recording-ffmpeg-process)) + (cj/audio-recording-stop)) + (when recording-file + (test-recording--cleanup-files recording-file)) + (when (file-exists-p test-dir) + (delete-directory test-dir t)) + (message "\nCleanup complete.")))) + +(provide 'test-integration-recording-monitor-capture-interactive) +;;; test-integration-recording-monitor-capture-interactive.el ends here diff --git a/tests/test-integration-recording-monitor-capture.el b/tests/test-integration-recording-monitor-capture.el new file mode 100644 index 00000000..7d7c5dfb --- /dev/null +++ b/tests/test-integration-recording-monitor-capture.el @@ -0,0 +1,222 @@ +;;; test-integration-recording-monitor-capture.el --- Integration test for monitor audio capture -*- lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> +;; Created: 2025-11-14 + +;;; Commentary: +;; +;; Integration test that verifies phone call audio (speaker output) is captured +;; during recording. This tests the actual PulseAudio device selection end-to-end. +;; +;; This test: +;; 1. Plays known speech through speakers (simulating phone call audio) +;; 2. Records it using the configured monitor device +;; 3. Transcribes the recording +;; 4. Verifies the expected text appears in the transcription +;; +;; Requirements: +;; - Audio system must be working (PulseAudio/PipeWire) +;; - Recording devices must be configured (C-; r c) +;; - paplay must be available +;; - Transcription backend must be configured +;; +;; This is a MANUAL integration test - not suitable for CI since it requires +;; working audio hardware. +;; +;; USAGE: +;; M-x ert RET test-integration-recording-monitor-capture RET +;; +;; Or from command line: +;; make test-file FILE=tests/test-integration-recording-monitor-capture.el + +;;; Code: + +(require 'ert) +(require 'video-audio-recording) +(require 'transcription-config) + +(defvar test-recording-monitor--test-audio-file + (expand-file-name "tests/fixtures/audio/speaker-output-test.wav" + user-emacs-directory) + "Test audio file with known speech for speaker output testing.") + +(defvar test-recording-monitor--expected-phrases + '("quick brown fox" "lazy dog" "speaker output test") + "Phrases expected in transcription. +We use partial matches since transcription may not be 100% accurate.") + +(defun test-recording-monitor--wait-for-transcription (audio-file timeout) + "Wait for transcription of AUDIO-FILE to complete, up to TIMEOUT seconds. +Returns the path to the .txt file if successful, nil if timeout." + (let* ((txt-file (concat (file-name-sans-extension audio-file) ".txt")) + (deadline (time-add (current-time) (seconds-to-time timeout)))) + ;; Wait for .txt file to be created and have content + (while (and (time-less-p (current-time) deadline) + (or (not (file-exists-p txt-file)) + (= 0 (file-attribute-size (file-attributes txt-file))))) + (sleep-for 1) + (message "Waiting for transcription... (%d seconds remaining)" + (ceiling (float-time (time-subtract deadline (current-time)))))) + (if (and (file-exists-p txt-file) + (> (file-attribute-size (file-attributes txt-file)) 0)) + txt-file + nil))) + +(defun test-recording-monitor--cleanup (recording-file) + "Clean up RECORDING-FILE and associated transcription files." + (when (and recording-file (file-exists-p recording-file)) + (let* ((base (file-name-sans-extension recording-file)) + (txt-file (concat base ".txt")) + (log-file (concat base ".log"))) + (when (file-exists-p recording-file) + (delete-file recording-file)) + (when (file-exists-p txt-file) + (delete-file txt-file)) + (when (file-exists-p log-file) + (delete-file log-file))))) + +(ert-deftest test-integration-recording-monitor-capture () + "Integration test: verify speaker output (monitor) is captured during recording. + +This test simulates a phone call scenario: +- Test audio plays through speakers (simulates remote person speaking) +- Recording should capture this via the monitor device +- Transcription should contain the expected speech + +This verifies that the system audio monitor device selection is working correctly. + +Components integrated: +- video-audio-recording.el (device selection, ffmpeg command generation) +- PulseAudio/PipeWire (actual audio routing) +- ffmpeg (recording process) +- transcription-config.el (speech-to-text verification) + +Test category: Integration test (requires working audio hardware)" + :tags '(:integration :manual :audio) + + ;; Skip if prerequisites not met + (skip-unless (executable-find "paplay")) + (skip-unless (executable-find "ffmpeg")) + (skip-unless (file-exists-p test-recording-monitor--test-audio-file)) + (skip-unless (and cj/recording-mic-device cj/recording-system-device)) + + (let ((recording-file nil) + (playback-process nil)) + (unwind-protect + (progn + (message "\n========================================") + (message "Starting monitor capture integration test") + (message "========================================") + (message "Test audio: %s" test-recording-monitor--test-audio-file) + (message "Mic device: %s" cj/recording-mic-device) + (message "Monitor device: %s" cj/recording-system-device) + (message "Backend: %s" cj/transcribe-backend) + + ;; Step 1: Start recording + (message "\n[1/6] Starting recording...") + (let ((temp-dir (make-temp-file "recording-test-" t))) + (cj/ffmpeg-record-audio temp-dir) + ;; Give ffmpeg a moment to initialize + (sleep-for 1) + (should (process-live-p cj/audio-recording-ffmpeg-process)) + (message "✓ Recording started (process: %s)" + (process-name cj/audio-recording-ffmpeg-process)) + + ;; Determine the recording filename (ffmpeg uses timestamp) + ;; We'll find it after stopping + (setq recording-file + (expand-file-name + (concat (format-time-string "%Y-%m-%d-%H-%M-%S") ".m4a") + temp-dir))) + + ;; Step 2: Play test audio through speakers + (message "\n[2/6] Playing test audio through speakers...") + (setq playback-process + (start-process "test-audio-playback" + "*test-audio-playback*" + "paplay" + test-recording-monitor--test-audio-file)) + (message "✓ Playback started (process: %s)" + (process-name playback-process)) + + ;; Step 3: Wait for playback to complete + (message "\n[3/6] Waiting for playback to complete...") + (let ((max-wait 10) + (elapsed 0)) + (while (and (process-live-p playback-process) + (< elapsed max-wait)) + (sleep-for 0.5) + (setq elapsed (+ elapsed 0.5))) + (if (process-live-p playback-process) + (progn + (kill-process playback-process) + (error "Playback did not complete within %d seconds" max-wait)) + (message "✓ Playback completed (%.1f seconds)" elapsed))) + + ;; Give audio a moment to be captured + (sleep-for 1) + + ;; Step 4: Stop recording + (message "\n[4/6] Stopping recording...") + (cj/audio-recording-stop) + (sleep-for 1) + (should-not (process-live-p cj/audio-recording-ffmpeg-process)) + + ;; Find the actual recording file (may differ by timestamp) + (let* ((dir (file-name-directory recording-file)) + (files (directory-files dir t "\\.m4a$"))) + (should (= 1 (length files))) + (setq recording-file (car files))) + + (should (file-exists-p recording-file)) + (message "✓ Recording stopped") + (message " Recording file: %s" recording-file) + (message " File size: %d bytes" + (file-attribute-size (file-attributes recording-file))) + + ;; Step 5: Transcribe recording + (message "\n[5/6] Transcribing recording (this may take 30-60 seconds)...") + (cj/transcribe-audio recording-file) + + ;; Wait for transcription to complete + (let ((txt-file (test-recording-monitor--wait-for-transcription + recording-file 120))) + (should txt-file) + (message "✓ Transcription completed") + (message " Transcript file: %s" txt-file) + + ;; Step 6: Verify transcription contains expected phrases + (message "\n[6/6] Verifying transcription content...") + (let ((transcript (with-temp-buffer + (insert-file-contents txt-file) + (downcase (buffer-string))))) + (message "Transcript length: %d characters" (length transcript)) + (message "Transcript preview: %s" + (substring transcript 0 (min 100 (length transcript)))) + + ;; Verify at least 2 of the expected phrases appear + (let ((matches 0)) + (dolist (phrase test-recording-monitor--expected-phrases) + (when (string-match-p phrase transcript) + (setq matches (1+ matches)) + (message "✓ Found expected phrase: '%s'" phrase))) + + (message "\nMatched %d of %d expected phrases" + matches (length test-recording-monitor--expected-phrases)) + + (should (>= matches 2)) + (message "\n✓✓✓ TEST PASSED ✓✓✓") + (message "Monitor device is correctly capturing speaker audio!"))))) + + ;; Cleanup + (message "\nCleaning up...") + (when (process-live-p playback-process) + (kill-process playback-process)) + (when (and cj/audio-recording-ffmpeg-process + (process-live-p cj/audio-recording-ffmpeg-process)) + (cj/audio-recording-stop)) + (test-recording-monitor--cleanup recording-file) + (message "✓ Cleanup complete")))) + +(provide 'test-integration-recording-monitor-capture) +;;; test-integration-recording-monitor-capture.el ends here diff --git a/tests/test-integration-recording-toggle-workflow.el b/tests/test-integration-recording-toggle-workflow.el new file mode 100644 index 00000000..c61698c5 --- /dev/null +++ b/tests/test-integration-recording-toggle-workflow.el @@ -0,0 +1,347 @@ +;;; test-integration-recording-toggle-workflow.el --- Integration tests for recording toggle workflow -*- lexical-binding: t; -*- + +;;; Commentary: +;; Integration tests covering the complete recording toggle workflow from +;; user action through device setup, recording, and cleanup. +;; +;; This tests the ACTUAL user workflow: Press C-; r a → setup → record → stop → cleanup +;; +;; Components integrated: +;; - cj/audio-recording-toggle (entry point) +;; - cj/video-recording-toggle (entry point) +;; - cj/recording-get-devices (device prompting and setup) +;; - cj/recording-quick-setup-for-calls (device selection workflow) +;; - cj/ffmpeg-record-audio (process creation and ffmpeg command) +;; - cj/ffmpeg-record-video (process creation and ffmpeg command) +;; - cj/recording-modeline-indicator (UI state display) +;; - cj/audio-recording-stop (cleanup) +;; - cj/video-recording-stop (cleanup) +;; - cj/recording-process-sentinel (auto-cleanup on process death) +;; +;; Validates: +;; - Complete workflow from toggle to cleanup +;; - Device setup on first use +;; - Process creation and management +;; - Modeline updates at each step +;; - Cleanup on user stop +;; - Auto-cleanup when process dies + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Stub directory variables +(defvar video-recordings-dir "/tmp/video-recordings/") +(defvar audio-recordings-dir "/tmp/audio-recordings/") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Setup and Teardown + +(defun test-integration-toggle-setup () + "Reset all variables before each test." + (setq cj/video-recording-ffmpeg-process nil) + (setq cj/audio-recording-ffmpeg-process nil) + (setq cj/recording-mic-device nil) + (setq cj/recording-system-device nil)) + +(defun test-integration-toggle-teardown () + "Clean up after each test." + (when cj/video-recording-ffmpeg-process + (ignore-errors (delete-process cj/video-recording-ffmpeg-process))) + (when cj/audio-recording-ffmpeg-process + (ignore-errors (delete-process cj/audio-recording-ffmpeg-process))) + (setq cj/video-recording-ffmpeg-process nil) + (setq cj/audio-recording-ffmpeg-process nil) + (setq cj/recording-mic-device nil) + (setq cj/recording-system-device nil)) + +;;; Integration Tests - Audio Recording Workflow + +(ert-deftest test-integration-recording-toggle-workflow-audio-first-use-full-cycle () + "Test complete audio recording workflow from first use through cleanup. + +When user presses C-; r a for the first time: +1. Device setup prompt appears (no devices configured) +2. User chooses quick setup +3. Devices are selected and saved +4. Recording starts with correct ffmpeg command +5. Process is created and sentinel attached +6. Modeline shows recording indicator +7. User presses C-; r a again to stop +8. Recording stops gracefully +9. Modeline indicator clears + +Components integrated: +- cj/audio-recording-toggle (toggles start/stop) +- cj/recording-get-devices (prompts for setup on first use) +- cj/recording-quick-setup-for-calls (device selection) +- cj/ffmpeg-record-audio (creates recording process) +- cj/recording-modeline-indicator (UI state) +- cj/audio-recording-stop (cleanup) + +Validates: +- Full user workflow from first use to stop +- Device setup on first toggle +- Recording starts after setup +- Modeline updates correctly +- Stop works after recording" + (test-integration-toggle-setup) + (unwind-protect + (let ((setup-called nil) + (ffmpeg-cmd nil) + (process-created nil)) + ;; Mock the device setup to simulate user choosing quick setup + (cl-letf (((symbol-function 'y-or-n-p) + (lambda (_prompt) t)) ; User says yes to quick setup + ((symbol-function 'cj/recording-quick-setup-for-calls) + (lambda () + (setq setup-called t) + (setq cj/recording-mic-device "test-mic") + (setq cj/recording-system-device "test-monitor"))) + ((symbol-function 'file-directory-p) + (lambda (_dir) t)) + ((symbol-function 'start-process-shell-command) + (lambda (_name _buffer cmd) + (setq process-created t) + (setq ffmpeg-cmd cmd) + (make-process :name "fake-audio" :command '("sleep" "1000"))))) + + ;; STEP 1: First toggle - should trigger device setup + (cj/audio-recording-toggle nil) + + ;; Verify setup was called + (should setup-called) + + ;; Verify devices were set + (should (equal "test-mic" cj/recording-mic-device)) + (should (equal "test-monitor" cj/recording-system-device)) + + ;; Verify recording started + (should process-created) + (should cj/audio-recording-ffmpeg-process) + (should (string-match-p "ffmpeg" ffmpeg-cmd)) + (should (string-match-p "test-mic" ffmpeg-cmd)) + (should (string-match-p "test-monitor" ffmpeg-cmd)) + + ;; Verify modeline shows recording + (should (equal " 🔴Audio " (cj/recording-modeline-indicator))) + + ;; STEP 2: Second toggle - should stop recording + (cj/audio-recording-toggle nil) + + ;; Verify recording stopped + (should (null cj/audio-recording-ffmpeg-process)) + + ;; Verify modeline cleared + (should (equal "" (cj/recording-modeline-indicator))))) + (test-integration-toggle-teardown))) + +(ert-deftest test-integration-recording-toggle-workflow-audio-subsequent-use-no-setup () + "Test that subsequent audio recordings skip device setup. + +After devices are configured, pressing C-; r a should: +1. Skip device setup (already configured) +2. Start recording immediately +3. Use previously configured devices + +Components integrated: +- cj/audio-recording-toggle +- cj/recording-get-devices (returns cached devices) +- cj/ffmpeg-record-audio (uses cached devices) + +Validates: +- Device setup is cached across recordings +- Second recording doesn't prompt +- Same devices are used" + (test-integration-toggle-setup) + (unwind-protect + (progn + ;; Pre-configure devices (simulating previous setup) + (setq cj/recording-mic-device "cached-mic") + (setq cj/recording-system-device "cached-monitor") + + (let ((setup-called nil) + (ffmpeg-cmd nil)) + (cl-letf (((symbol-function 'cj/recording-quick-setup-for-calls) + (lambda () (setq setup-called t))) + ((symbol-function 'file-directory-p) + (lambda (_dir) t)) + ((symbol-function 'start-process-shell-command) + (lambda (_name _buffer cmd) + (setq ffmpeg-cmd cmd) + (make-process :name "fake-audio" :command '("sleep" "1000"))))) + + ;; Toggle to start recording + (cj/audio-recording-toggle nil) + + ;; Setup should NOT be called + (should-not setup-called) + + ;; Should use cached devices + (should (string-match-p "cached-mic" ffmpeg-cmd)) + (should (string-match-p "cached-monitor" ffmpeg-cmd))))) + (test-integration-toggle-teardown))) + +;;; Integration Tests - Video Recording Workflow + +(ert-deftest test-integration-recording-toggle-workflow-video-full-cycle () + "Test complete video recording workflow. + +Components integrated: +- cj/video-recording-toggle +- cj/recording-get-devices +- cj/ffmpeg-record-video +- cj/recording-modeline-indicator +- cj/video-recording-stop + +Validates: +- Video recording follows same workflow as audio +- Modeline shows video indicator +- Toggle works for video" + (test-integration-toggle-setup) + (unwind-protect + (let ((setup-called nil)) + (cl-letf (((symbol-function 'y-or-n-p) + (lambda (_prompt) t)) + ((symbol-function 'cj/recording-quick-setup-for-calls) + (lambda () + (setq setup-called t) + (setq cj/recording-mic-device "test-mic") + (setq cj/recording-system-device "test-monitor"))) + ((symbol-function 'file-directory-p) + (lambda (_dir) t)) + ((symbol-function 'start-process-shell-command) + (lambda (_name _buffer _cmd) + (make-process :name "fake-video" :command '("sleep" "1000"))))) + + ;; Start video recording + (cj/video-recording-toggle nil) + + ;; Verify setup and recording + (should setup-called) + (should cj/video-recording-ffmpeg-process) + (should (equal " 🔴Video " (cj/recording-modeline-indicator))) + + ;; Stop recording + (cj/video-recording-toggle nil) + + ;; Verify cleanup + (should (null cj/video-recording-ffmpeg-process)) + (should (equal "" (cj/recording-modeline-indicator))))) + (test-integration-toggle-teardown))) + +;;; Integration Tests - Both Recordings Simultaneously + +(ert-deftest test-integration-recording-toggle-workflow-both-simultaneous () + "Test that both audio and video can record simultaneously. + +Components integrated: +- cj/audio-recording-toggle +- cj/video-recording-toggle +- cj/recording-modeline-indicator (shows both) +- Both ffmpeg-record functions + +Validates: +- Audio and video can run together +- Modeline shows both indicators +- Stopping one doesn't affect the other" + (test-integration-toggle-setup) + (unwind-protect + (progn + ;; Pre-configure devices + (setq cj/recording-mic-device "test-mic") + (setq cj/recording-system-device "test-monitor") + + (cl-letf (((symbol-function 'file-directory-p) + (lambda (_dir) t)) + ((symbol-function 'start-process-shell-command) + (lambda (name _buffer _cmd) + (make-process :name name :command '("sleep" "1000"))))) + + ;; Start both recordings + (cj/audio-recording-toggle nil) + (cj/video-recording-toggle nil) + + ;; Verify both are recording + (should cj/audio-recording-ffmpeg-process) + (should cj/video-recording-ffmpeg-process) + (should (equal " 🔴A+V " (cj/recording-modeline-indicator))) + + ;; Stop audio only + (cj/audio-recording-toggle nil) + + ;; Verify only video still recording + (should (null cj/audio-recording-ffmpeg-process)) + (should cj/video-recording-ffmpeg-process) + (should (equal " 🔴Video " (cj/recording-modeline-indicator))) + + ;; Stop video + (cj/video-recording-toggle nil) + + ;; Verify all cleared + (should (null cj/video-recording-ffmpeg-process)) + (should (equal "" (cj/recording-modeline-indicator))))) + (test-integration-toggle-teardown))) + +;;; Integration Tests - Sentinel Auto-Cleanup + +(ert-deftest test-integration-recording-toggle-workflow-sentinel-auto-cleanup () + "Test that sentinel auto-cleans when recording process dies unexpectedly. + +When the ffmpeg process crashes or exits unexpectedly: +1. Sentinel detects process death +2. Variable is automatically cleared +3. Modeline updates to show no recording +4. User can start new recording + +Components integrated: +- cj/audio-recording-toggle (process creation) +- cj/ffmpeg-record-audio (attaches sentinel) +- cj/recording-process-sentinel (cleanup on death) +- cj/recording-modeline-indicator (updates on cleanup) + +Validates: +- Sentinel cleans up on unexpected process death +- Modeline syncs when sentinel runs +- User can toggle again after crash" + (test-integration-toggle-setup) + (unwind-protect + (progn + ;; Pre-configure devices + (setq cj/recording-mic-device "test-mic") + (setq cj/recording-system-device "test-monitor") + + (let ((process nil)) + (cl-letf (((symbol-function 'file-directory-p) + (lambda (_dir) t)) + ((symbol-function 'start-process-shell-command) + (lambda (name _buffer _cmd) + (setq process (make-process :name name :command '("sh" "-c" "exit 1")))))) + + ;; Start recording + (cj/audio-recording-toggle nil) + + ;; Verify recording started + (should cj/audio-recording-ffmpeg-process) + (should (equal " 🔴Audio " (cj/recording-modeline-indicator))) + + ;; Wait for process to exit (sentinel should run) + (sit-for 0.3) + + ;; Verify sentinel cleaned up + (should (null cj/audio-recording-ffmpeg-process)) + (should (equal "" (cj/recording-modeline-indicator))) + + ;; Verify user can start new recording after crash + (cj/audio-recording-toggle nil) + (should cj/audio-recording-ffmpeg-process)))) + (test-integration-toggle-teardown))) + +(provide 'test-integration-recording-toggle-workflow) +;;; test-integration-recording-toggle-workflow.el ends here diff --git a/tests/test-integration-recurring-events.el b/tests/test-integration-recurring-events.el new file mode 100644 index 00000000..4629e6ef --- /dev/null +++ b/tests/test-integration-recurring-events.el @@ -0,0 +1,347 @@ +;;; test-integration-recurring-events.el --- Integration tests for recurring events -*- lexical-binding: t; -*- + +;;; Commentary: +;; Integration tests for the complete recurring event (RRULE) workflow. +;; Tests the full pipeline: ICS parsing → RRULE expansion → org formatting. +;; +;; Components integrated: +;; - calendar-sync--split-events (ICS event extraction) +;; - calendar-sync--get-property (property extraction with TZID) +;; - calendar-sync--parse-rrule (RRULE parsing) +;; - calendar-sync--expand-weekly/daily/monthly/yearly (event expansion) +;; - calendar-sync--parse-event (event parsing) +;; - calendar-sync--event-to-org (org formatting) +;; - calendar-sync--parse-ics (complete pipeline orchestration) +;; +;; This validates that the entire RRULE system works together correctly, +;; from raw ICS input to final org-mode output. + +;;; Code: + +(require 'ert) +(require 'calendar-sync) +(require 'testutil-calendar-sync) + +;;; Setup and Teardown + +(defun test-integration-recurring-events-setup () + "Setup for recurring events integration tests." + nil) + +(defun test-integration-recurring-events-teardown () + "Teardown for recurring events integration tests." + nil) + +;;; Test Data + +(defconst test-integration-recurring-events--weekly-ics + "BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +DTSTART;TZID=America/Chicago:20251118T103000 +DTEND;TZID=America/Chicago:20251118T110000 +RRULE:FREQ=WEEKLY;BYDAY=SA +SUMMARY:GTFO +UID:test-weekly@example.com +END:VEVENT +END:VCALENDAR" + "Test ICS with weekly recurring event (GTFO use case).") + +(defconst test-integration-recurring-events--daily-with-count-ics + "BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +DTSTART:20251120T090000Z +DTEND:20251120T100000Z +RRULE:FREQ=DAILY;COUNT=5 +SUMMARY:Daily Standup +UID:test-daily@example.com +END:VEVENT +END:VCALENDAR" + "Test ICS with daily recurring event limited by COUNT.") + +(defconst test-integration-recurring-events--mixed-ics + "BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +DTSTART:20251125T140000Z +DTEND:20251125T150000Z +SUMMARY:One-time Meeting +UID:test-onetime@example.com +END:VEVENT +BEGIN:VEVENT +DTSTART;TZID=America/Chicago:20251201T093000 +DTEND;TZID=America/Chicago:20251201T103000 +RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR +SUMMARY:Recurring Standup +UID:test-recurring@example.com +END:VEVENT +END:VCALENDAR" + "Test ICS with mix of recurring and non-recurring events.") + +;;; Normal Cases - Complete Workflow + +(ert-deftest test-integration-recurring-events-weekly-complete-workflow () + "Test complete workflow for weekly recurring event. + +Components integrated: +- calendar-sync--split-events (extract VEVENT blocks) +- calendar-sync--get-property (extract DTSTART, DTEND, RRULE with TZID) +- calendar-sync--parse-rrule (parse FREQ=WEEKLY;BYDAY=SA) +- calendar-sync--expand-weekly (generate Saturday occurrences) +- calendar-sync--event-to-org (format as org entries) +- calendar-sync--parse-ics (orchestrate complete pipeline) + +Validates: +- TZID parameters handled correctly +- RRULE expansion generates correct dates +- Multiple occurrences created from single event +- Org output is properly formatted with timestamps" + (test-integration-recurring-events-setup) + (unwind-protect + (let ((org-output (calendar-sync--parse-ics test-integration-recurring-events--weekly-ics))) + ;; Should generate org-formatted output + (should (stringp org-output)) + (should (string-match-p "^# Calendar Events" org-output)) + + ;; Should contain multiple GTFO entries + (let ((gtfo-count (with-temp-buffer + (insert org-output) + (goto-char (point-min)) + (how-many "^\\* GTFO")))) + (should (> gtfo-count 40)) ; ~52 weeks in a year + (should (< gtfo-count 60))) + + ;; Should have properly formatted Saturday timestamps + (should (string-match-p "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} Sat 10:30-11:00>" org-output))) + (test-integration-recurring-events-teardown))) + +(ert-deftest test-integration-recurring-events-daily-with-count-workflow () + "Test complete workflow for daily recurring event with COUNT limit. + +Components integrated: +- calendar-sync--parse-rrule (with COUNT parameter) +- calendar-sync--expand-daily (respects COUNT=5) +- calendar-sync--parse-ics (complete pipeline) + +Validates: +- COUNT parameter limits expansion correctly +- Daily recurrence generates consecutive days +- Exactly 5 occurrences created" + (test-integration-recurring-events-setup) + (unwind-protect + (let ((org-output (calendar-sync--parse-ics test-integration-recurring-events--daily-with-count-ics))) + (should (stringp org-output)) + + ;; Should generate exactly 5 Daily Standup entries + (let ((standup-count (with-temp-buffer + (insert org-output) + (goto-char (point-min)) + (how-many "^\\* Daily Standup")))) + (should (= standup-count 5)))) + (test-integration-recurring-events-teardown))) + +(ert-deftest test-integration-recurring-events-mixed-recurring-and-onetime () + "Test workflow with mixed recurring and non-recurring events. + +Components integrated: +- calendar-sync--split-events (handles multiple VEVENT blocks) +- calendar-sync--expand-recurring-event (detects RRULE vs non-recurring) +- calendar-sync--parse-event (handles both types) +- calendar-sync--parse-ics (processes both event types) + +Validates: +- Non-recurring events included once +- Recurring events expanded correctly +- Both types appear in output +- Events are sorted chronologically" + (test-integration-recurring-events-setup) + (unwind-protect + (let ((org-output (calendar-sync--parse-ics test-integration-recurring-events--mixed-ics))) + (should (stringp org-output)) + + ;; Should have one-time meeting + (should (string-match-p "^\\* One-time Meeting" org-output)) + + ;; Should have multiple recurring standup entries + (let ((standup-count (with-temp-buffer + (insert org-output) + (goto-char (point-min)) + (how-many "^\\* Recurring Standup")))) + (should (> standup-count 10))) ; ~3 days/week for 4 months + + ;; Events should be sorted by date (one-time comes before recurring) + (should (< (string-match "One-time Meeting" org-output) + (string-match "Recurring Standup" org-output)))) + (test-integration-recurring-events-teardown))) + +;;; Boundary Cases - Date Range Handling + +(ert-deftest test-integration-recurring-events-respects-rolling-window () + "Test that RRULE expansion respects rolling window boundaries. + +Components integrated: +- calendar-sync--get-date-range (calculates -3 months to +12 months) +- calendar-sync--date-in-range-p (filters occurrences) +- calendar-sync--expand-weekly (respects range) +- calendar-sync--parse-ics (applies range to all events) + +Validates: +- Events outside date range are excluded +- Rolling window is applied consistently +- Past events (> 3 months) excluded +- Future events (> 12 months) excluded" + (test-integration-recurring-events-setup) + (unwind-protect + (let* ((org-output (calendar-sync--parse-ics test-integration-recurring-events--weekly-ics)) + (now (current-time)) + (three-months-ago (time-subtract now (* 90 24 3600))) + (twelve-months-future (time-add now (* 365 24 3600)))) + (should (stringp org-output)) + + ;; Parse all dates from output + (with-temp-buffer + (insert org-output) + (goto-char (point-min)) + (let ((all-dates-in-range t)) + (while (re-search-forward "<\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)" nil t) + (let* ((year (string-to-number (match-string 1))) + (month (string-to-number (match-string 2))) + (day (string-to-number (match-string 3))) + (event-time (encode-time 0 0 0 day month year))) + ;; All dates should be within window + (when (or (time-less-p event-time three-months-ago) + (time-less-p twelve-months-future event-time)) + (setq all-dates-in-range nil)))) + (should all-dates-in-range)))) + (test-integration-recurring-events-teardown))) + +(ert-deftest test-integration-recurring-events-tzid-conversion () + "Test that TZID timestamps are handled correctly throughout pipeline. + +Components integrated: +- calendar-sync--get-property (extracts DTSTART;TZID=America/Chicago:...) +- calendar-sync--parse-timestamp (converts to local time) +- calendar-sync--format-timestamp (formats for org-mode) +- calendar-sync--event-to-org (includes formatted timestamp) + +Validates: +- TZID parameter doesn't break parsing (regression test) +- Timestamps are correctly formatted in org output +- Time values are preserved through pipeline" + (test-integration-recurring-events-setup) + (unwind-protect + (let ((org-output (calendar-sync--parse-ics test-integration-recurring-events--weekly-ics))) + (should (stringp org-output)) + + ;; Should have timestamps with time range + (should (string-match-p "Sat 10:30-11:00" org-output)) + + ;; Should NOT have TZID in output (converted to org format) + (should-not (string-match-p "TZID" org-output))) + (test-integration-recurring-events-teardown))) + +;;; Edge Cases - Error Handling + +(ert-deftest test-integration-recurring-events-empty-ics-returns-nil () + "Test that empty ICS content is handled gracefully. + +Components integrated: +- calendar-sync--parse-ics (top-level error handling) + +Validates: +- Empty input doesn't crash +- Returns nil for empty content" + (test-integration-recurring-events-setup) + (unwind-protect + (let ((org-output (calendar-sync--parse-ics ""))) + (should (null org-output))) + (test-integration-recurring-events-teardown))) + +(ert-deftest test-integration-recurring-events-malformed-ics-returns-nil () + "Test that malformed ICS content is handled gracefully. + +Components integrated: +- calendar-sync--parse-ics (error handling) + +Validates: +- Malformed input doesn't crash +- Error is caught and logged +- Returns nil for malformed content" + (test-integration-recurring-events-setup) + (unwind-protect + (let ((org-output (calendar-sync--parse-ics "INVALID ICS DATA"))) + ;; Should handle error gracefully + (should (null org-output))) + (test-integration-recurring-events-teardown))) + +(ert-deftest test-integration-recurring-events-missing-required-fields () + "Test handling of events missing required fields. + +Components integrated: +- calendar-sync--parse-event (validates required fields) +- calendar-sync--parse-ics (filters invalid events) + +Validates: +- Events without SUMMARY are excluded +- Events without DTSTART are excluded +- Valid events still processed" + (test-integration-recurring-events-setup) + (unwind-protect + (let* ((incomplete-ics "BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +DTSTART:20251201T100000Z +RRULE:FREQ=DAILY;COUNT=2 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Valid Event +DTSTART:20251201T110000Z +DTEND:20251201T120000Z +END:VEVENT +END:VCALENDAR") + (org-output (calendar-sync--parse-ics incomplete-ics))) + ;; Should still generate output (for valid event) + (should (stringp org-output)) + (should (string-match-p "Valid Event" org-output)) + + ;; Invalid event (no SUMMARY) should be excluded + (should-not (string-match-p "VEVENT" org-output))) + (test-integration-recurring-events-teardown))) + +(ert-deftest test-integration-recurring-events-unsupported-freq-skipped () + "Test that events with unsupported FREQ are handled gracefully. + +Components integrated: +- calendar-sync--parse-rrule (parses unsupported FREQ) +- calendar-sync--expand-recurring-event (detects unsupported FREQ) +- calendar-sync--parse-ics (continues processing other events) + +Validates: +- Unsupported FREQ doesn't crash pipeline +- Warning message is logged +- Other events still processed" + (test-integration-recurring-events-setup) + (unwind-protect + (let* ((unsupported-ics "BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +DTSTART:20251201T100000Z +DTEND:20251201T110000Z +RRULE:FREQ=HOURLY;COUNT=5 +SUMMARY:Unsupported Hourly Event +UID:unsupported@example.com +END:VEVENT +END:VCALENDAR") + (org-output (calendar-sync--parse-ics unsupported-ics))) + ;; Should handle gracefully (may return nil or skip the event) + ;; The key is it shouldn't crash + (should (or (null org-output) + (stringp org-output)))) + (test-integration-recurring-events-teardown))) + +(provide 'test-integration-recurring-events) +;;; test-integration-recurring-events.el ends here diff --git a/tests/test-integration-transcription.el b/tests/test-integration-transcription.el new file mode 100644 index 00000000..d014d00e --- /dev/null +++ b/tests/test-integration-transcription.el @@ -0,0 +1,150 @@ +;;; test-integration-transcription.el --- Integration tests for transcription -*- lexical-binding: t; -*- + +;;; Commentary: +;; End-to-end integration tests for transcription workflow +;; Tests complete workflow with temporary files and mocked processes +;; Categories: Normal workflow, Error handling, Cleanup + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +(require 'transcription-config) + +;; ----------------------------- Test Helpers ---------------------------------- + +(defun test-transcription--make-mock-audio-file () + "Create a temporary mock audio file for testing. +Returns the absolute path to the file." + (let ((file (make-temp-file "test-audio-" nil ".m4a"))) + (with-temp-file file + (insert "Mock audio data")) + file)) + +(defun test-transcription--cleanup-output-files (audio-file) + "Delete transcript and log files associated with AUDIO-FILE." + (let* ((outputs (cj/--transcription-output-files audio-file)) + (txt-file (car outputs)) + (log-file (cdr outputs))) + (when (file-exists-p txt-file) + (delete-file txt-file)) + (when (file-exists-p log-file) + (delete-file log-file)))) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-integration-transcription-output-files-created () + "Test that .txt and .log files are created for audio file." + (let* ((audio-file (test-transcription--make-mock-audio-file)) + (outputs (cj/--transcription-output-files audio-file)) + (txt-file (car outputs)) + (log-file (cdr outputs))) + (unwind-protect + (progn + ;; Verify output file paths are correct + (should (string-suffix-p ".txt" txt-file)) + (should (string-suffix-p ".log" log-file)) + (should (string= (file-name-sans-extension txt-file) + (file-name-sans-extension audio-file))) + (should (string= (file-name-sans-extension log-file) + (file-name-sans-extension audio-file)))) + ;; Cleanup + (delete-file audio-file) + (test-transcription--cleanup-output-files audio-file)))) + +(ert-deftest test-integration-transcription-validates-file-exists () + "Test that transcription fails for non-existent file." + (should-error + (cj/--start-transcription-process "/nonexistent/audio.m4a") + :type 'user-error)) + +(ert-deftest test-integration-transcription-validates-audio-extension () + "Test that transcription fails for non-audio file." + (let ((non-audio (make-temp-file "test-" nil ".txt"))) + (unwind-protect + (should-error + (cj/--start-transcription-process non-audio) + :type 'user-error) + (delete-file non-audio)))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-integration-transcription-audio-file-detection () + "Test various audio file extensions are accepted." + (dolist (ext '("m4a" "mp3" "wav" "flac" "ogg" "opus")) + (let ((audio-file (make-temp-file "test-audio-" nil (concat "." ext)))) + (unwind-protect + (progn + (should (cj/--audio-file-p audio-file)) + ;; Would start transcription if script existed + ) + (delete-file audio-file))))) + +(ert-deftest test-integration-transcription-filename-with-spaces () + "Test transcription with audio file containing spaces." + (let ((audio-file (make-temp-file "test audio file" nil ".m4a"))) + (unwind-protect + (let* ((outputs (cj/--transcription-output-files audio-file)) + (txt-file (car outputs)) + (log-file (cdr outputs))) + (should (file-name-absolute-p txt-file)) + (should (file-name-absolute-p log-file))) + (delete-file audio-file)))) + +(ert-deftest test-integration-transcription-filename-with-special-chars () + "Test transcription with special characters in filename." + (let ((audio-file (make-temp-file "test_(final)" nil ".m4a"))) + (unwind-protect + (let* ((outputs (cj/--transcription-output-files audio-file)) + (txt-file (car outputs))) + ;; make-temp-file adds random suffix, so just check it ends with .txt + ;; and contains the special chars + (should (string-suffix-p ".txt" txt-file)) + (should (string-match-p "test_(final)" txt-file))) + (delete-file audio-file)))) + +;; ----------------------------- Cleanup Tests --------------------------------- + +(ert-deftest test-integration-transcription-cleanup-completed () + "Test that completed transcriptions are removed from tracking." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running) + (proc2 "file2.m4a" nil complete) + (proc3 "file3.m4a" nil error)))) + (cj/--cleanup-completed-transcriptions) + (should (= 1 (length cj/transcriptions-list))) + (should (eq 'running (nth 3 (car cj/transcriptions-list)))))) + +(ert-deftest test-integration-transcription-cleanup-all-complete () + "Test cleanup when all transcriptions are complete." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil complete) + (proc2 "file2.m4a" nil error)))) + (cj/--cleanup-completed-transcriptions) + (should (null cj/transcriptions-list)))) + +(ert-deftest test-integration-transcription-cleanup-preserves-running () + "Test that running transcriptions are not cleaned up." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running) + (proc2 "file2.m4a" nil running)))) + (cj/--cleanup-completed-transcriptions) + (should (= 2 (length cj/transcriptions-list))))) + +;; ----------------------------- Backend Tests --------------------------------- + +(ert-deftest test-integration-transcription-script-path-exists () + "Test that transcription scripts exist in expected location." + (dolist (backend '(local-whisper openai-api)) + (let ((cj/transcribe-backend backend)) + (let ((script (cj/--transcription-script-path))) + (should (file-name-absolute-p script)) + ;; Note: Script may not exist in test environment, just check path format + (should (string-match-p "scripts/" script)))))) + +(provide 'test-integration-transcription) +;;; test-integration-transcription.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..57d5ae5f --- /dev/null +++ b/tests/test-lorem-optimum-benchmark.el @@ -0,0 +1,223 @@ +;;; 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. +DISABLED: Takes too long (minutes instead of seconds). +Needs lorem-optimum performance optimization before re-enabling." + :tags '(:slow) + (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 30.0))))) ; Should be < 30ms + +;;; Tokenization Performance Tests + +(ert-deftest benchmark-tokenize-10k-words () + "Benchmark tokenizing 10,000 words. +DISABLED: Takes too long (minutes instead of seconds). +Needs lorem-optimum performance optimization before re-enabling." + :tags '(:slow) + (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-mousetrap-mode--build-keymap.el b/tests/test-mousetrap-mode--build-keymap.el new file mode 100644 index 00000000..d632cc9a --- /dev/null +++ b/tests/test-mousetrap-mode--build-keymap.el @@ -0,0 +1,262 @@ +;;; test-mousetrap-mode--build-keymap.el --- Tests for keymap building -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for mouse-trap--build-keymap function. +;; Tests keymap generation for different profiles, event categories, +;; modifiers, and edge cases. + +;;; Code: + +(require 'ert) +(require 'mousetrap-mode) + +;;; Normal Cases + +(ert-deftest test-mousetrap-mode--build-keymap-normal-disabled-profile-blocks-all-events () + "Test disabled profile blocks all mouse events." + (let ((major-mode 'test-mode) + (mouse-trap-mode-profiles '((test-mode . disabled)))) + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map)) + ;; Check various events are blocked + (should (eq (lookup-key map (kbd "<mouse-1>")) 'ignore)) + (should (eq (lookup-key map (kbd "<wheel-up>")) 'ignore)) + (should (eq (lookup-key map (kbd "<drag-mouse-1>")) 'ignore)) + (should (eq (lookup-key map (kbd "<double-mouse-1>")) 'ignore))))) + +(ert-deftest test-mousetrap-mode--build-keymap-normal-scroll-only-allows-scroll () + "Test scroll-only profile allows scroll, blocks clicks." + (let ((major-mode 'test-mode) + (mouse-trap-mode-profiles '((test-mode . scroll-only)))) + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map)) + ;; Wheel events should NOT be in map (allowed) + (should (eq (lookup-key map (kbd "<wheel-up>")) nil)) + (should (eq (lookup-key map (kbd "<wheel-down>")) nil)) + ;; Click events should be blocked + (should (eq (lookup-key map (kbd "<mouse-1>")) 'ignore)) + (should (eq (lookup-key map (kbd "<mouse-2>")) 'ignore))))) + +(ert-deftest test-mousetrap-mode--build-keymap-normal-primary-click-allows-left-click () + "Test primary-click profile allows mouse-1, blocks others." + (let ((major-mode 'test-mode) + (mouse-trap-mode-profiles '((test-mode . primary-click)))) + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map)) + ;; mouse-1 should NOT be in map (allowed) + (should (eq (lookup-key map (kbd "<mouse-1>")) nil)) + (should (eq (lookup-key map (kbd "<down-mouse-1>")) nil)) + ;; mouse-2/3 should be blocked + (should (eq (lookup-key map (kbd "<mouse-2>")) 'ignore)) + (should (eq (lookup-key map (kbd "<mouse-3>")) 'ignore)) + ;; Scroll should be blocked + (should (eq (lookup-key map (kbd "<wheel-up>")) 'ignore))))) + +(ert-deftest test-mousetrap-mode--build-keymap-normal-scroll-plus-primary-allows-both () + "Test scroll+primary profile allows scrolling and left click." + (let ((major-mode 'test-mode) + (mouse-trap-mode-profiles '((test-mode . scroll+primary)))) + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map)) + ;; Allowed events + (should (eq (lookup-key map (kbd "<mouse-1>")) nil)) + (should (eq (lookup-key map (kbd "<wheel-up>")) nil)) + ;; Blocked events + (should (eq (lookup-key map (kbd "<mouse-2>")) 'ignore)) + (should (eq (lookup-key map (kbd "<drag-mouse-1>")) 'ignore))))) + +(ert-deftest test-mousetrap-mode--build-keymap-normal-full-profile-allows-all () + "Test full profile allows all events." + (let ((major-mode 'test-mode) + (mouse-trap-mode-profiles '((test-mode . full)))) + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map)) + ;; All events should be allowed (not in map) + (should (eq (lookup-key map (kbd "<mouse-1>")) nil)) + (should (eq (lookup-key map (kbd "<mouse-2>")) nil)) + (should (eq (lookup-key map (kbd "<wheel-up>")) nil)) + (should (eq (lookup-key map (kbd "<drag-mouse-1>")) nil)) + (should (eq (lookup-key map (kbd "<double-mouse-1>")) nil))))) + +(ert-deftest test-mousetrap-mode--build-keymap-normal-read-only-profile () + "Test read-only profile allows scrolling and all clicks, blocks drags/multi-clicks." + (let ((major-mode 'test-mode) + (mouse-trap-mode-profiles '((test-mode . read-only)))) + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map)) + ;; Allowed: scroll and all clicks + (should (eq (lookup-key map (kbd "<wheel-up>")) nil)) + (should (eq (lookup-key map (kbd "<mouse-1>")) nil)) + (should (eq (lookup-key map (kbd "<mouse-2>")) nil)) + ;; Blocked: drags and multi-clicks + (should (eq (lookup-key map (kbd "<drag-mouse-1>")) 'ignore)) + (should (eq (lookup-key map (kbd "<double-mouse-1>")) 'ignore))))) + +(ert-deftest test-mousetrap-mode--build-keymap-normal-interactive-profile () + "Test interactive profile allows scrolling, clicks, drags; blocks multi-clicks." + (let ((major-mode 'test-mode) + (mouse-trap-mode-profiles '((test-mode . interactive)))) + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map)) + ;; Allowed: scroll, clicks, drags + (should (eq (lookup-key map (kbd "<wheel-up>")) nil)) + (should (eq (lookup-key map (kbd "<mouse-1>")) nil)) + (should (eq (lookup-key map (kbd "<drag-mouse-1>")) nil)) + ;; Blocked: multi-clicks + (should (eq (lookup-key map (kbd "<double-mouse-1>")) 'ignore)) + (should (eq (lookup-key map (kbd "<triple-mouse-1>")) 'ignore))))) + +;;; Boundary Cases + +(ert-deftest test-mousetrap-mode--build-keymap-boundary-single-category-profile () + "Test profile with single category works correctly." + (let ((major-mode 'test-mode) + (mouse-trap-mode-profiles '((test-mode . primary-click)))) + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map)) + ;; Only primary-click should be allowed + (should (eq (lookup-key map (kbd "<mouse-1>")) nil)) + ;; Everything else blocked + (should (eq (lookup-key map (kbd "<mouse-2>")) 'ignore)) + (should (eq (lookup-key map (kbd "<wheel-up>")) 'ignore)) + (should (eq (lookup-key map (kbd "<drag-mouse-1>")) 'ignore)) + (should (eq (lookup-key map (kbd "<double-mouse-1>")) 'ignore))))) + +(ert-deftest test-mousetrap-mode--build-keymap-boundary-all-modifiers-included () + "Test all modifier combinations are handled." + (let ((major-mode 'test-mode) + (mouse-trap-mode-profiles '((test-mode . scroll-only)))) + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map)) + ;; Check various modifier combinations are blocked for clicks + (should (eq (lookup-key map (kbd "<mouse-1>")) 'ignore)) + (should (eq (lookup-key map (kbd "<C-mouse-1>")) 'ignore)) + (should (eq (lookup-key map (kbd "<M-mouse-1>")) 'ignore)) + (should (eq (lookup-key map (kbd "<S-mouse-1>")) 'ignore)) + (should (eq (lookup-key map (kbd "<C-M-mouse-1>")) 'ignore)) + (should (eq (lookup-key map (kbd "<C-S-mouse-1>")) 'ignore)) + (should (eq (lookup-key map (kbd "<M-S-mouse-1>")) 'ignore)) + (should (eq (lookup-key map (kbd "<C-M-S-mouse-1>")) 'ignore))))) + +(ert-deftest test-mousetrap-mode--build-keymap-boundary-all-button-numbers () + "Test button numbers handled according to category definitions. +Buttons 1-3 are in click categories, buttons 1-5 are in drag/multi-click." + (let ((major-mode 'test-mode) + (mouse-trap-mode-profiles '((test-mode . scroll-only)))) + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map)) + ;; Primary and secondary click buttons (1-3) should be blocked + (should (eq (lookup-key map (kbd "<mouse-1>")) 'ignore)) + (should (eq (lookup-key map (kbd "<mouse-2>")) 'ignore)) + (should (eq (lookup-key map (kbd "<mouse-3>")) 'ignore)) + ;; Drag events include all buttons (1-5) + (should (eq (lookup-key map (kbd "<drag-mouse-1>")) 'ignore)) + (should (eq (lookup-key map (kbd "<drag-mouse-4>")) 'ignore)) + (should (eq (lookup-key map (kbd "<drag-mouse-5>")) 'ignore))))) + +(ert-deftest test-mousetrap-mode--build-keymap-boundary-all-wheel-directions () + "Test all wheel directions are handled." + (let ((major-mode 'test-mode) + (mouse-trap-mode-profiles '((test-mode . primary-click)))) + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map)) + ;; All wheel directions should be blocked (not in primary-click) + (should (eq (lookup-key map (kbd "<wheel-up>")) 'ignore)) + (should (eq (lookup-key map (kbd "<wheel-down>")) 'ignore)) + (should (eq (lookup-key map (kbd "<wheel-left>")) 'ignore)) + (should (eq (lookup-key map (kbd "<wheel-right>")) 'ignore))))) + +(ert-deftest test-mousetrap-mode--build-keymap-boundary-returns-valid-keymap () + "Test function always returns a valid keymap." + (let ((major-mode 'test-mode) + (mouse-trap-mode-profiles '((test-mode . disabled)))) + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map))))) + +;;; Error Cases + +(ert-deftest test-mousetrap-mode--build-keymap-error-nil-profile-blocks-all () + "Test nil profile (unmapped) blocks all events." + (let ((major-mode 'unmapped-mode) + (mouse-trap-mode-profiles '((test-mode . disabled))) + (mouse-trap-profiles '((disabled . ())))) + ;; This mode will get nil from alist-get, treated as empty list + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map)) + ;; With default scroll-only, clicks should be blocked + (should (eq (lookup-key map (kbd "<mouse-1>")) 'ignore))))) + +(ert-deftest test-mousetrap-mode--build-keymap-error-invalid-category-ignored () + "Test invalid category in profile is gracefully ignored." + (let ((major-mode 'test-mode) + (mouse-trap-profiles '((test-profile . (scroll invalid-category primary-click)))) + (mouse-trap-mode-profiles '((test-mode . test-profile)))) + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map)) + ;; Valid categories should work + (should (eq (lookup-key map (kbd "<wheel-up>")) nil)) ; scroll allowed + (should (eq (lookup-key map (kbd "<mouse-1>")) nil)) ; primary-click allowed + ;; Other events should be blocked + (should (eq (lookup-key map (kbd "<mouse-2>")) 'ignore))))) + +(ert-deftest test-mousetrap-mode--build-keymap-error-empty-category-spec-ignored () + "Test empty category spec is handled gracefully." + (let ((major-mode 'test-mode) + (mouse-trap-mode-profiles '((test-mode . disabled))) + (mouse-trap--event-categories + '((primary-click . ((types . ("mouse" "down-mouse")) + (buttons . (1)))) + (empty-category . ()) ; Empty spec + (scroll . ((wheel . ("wheel-up" "wheel-down"))))))) + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map)) + ;; Should still work despite empty category + (should (eq (lookup-key map (kbd "<mouse-1>")) 'ignore))))) + +;;; Edge Cases + +(ert-deftest test-mousetrap-mode--build-keymap-edge-event-bound-to-ignore-function () + "Test blocked events are bound to ignore function, not nil." + (let ((major-mode 'test-mode) + (mouse-trap-mode-profiles '((test-mode . disabled)))) + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map)) + ;; Check binding is exactly 'ignore + (should (eq (lookup-key map (kbd "<mouse-1>")) 'ignore)) + (should-not (eq (lookup-key map (kbd "<mouse-1>")) nil))))) + +(ert-deftest test-mousetrap-mode--build-keymap-edge-allowed-events-not-in-keymap () + "Test allowed events are not present in keymap (nil lookup)." + (let ((major-mode 'test-mode) + (mouse-trap-mode-profiles '((test-mode . scroll-only)))) + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map)) + ;; Allowed events should return nil from lookup + (should (eq (lookup-key map (kbd "<wheel-up>")) nil)) + (should (eq (lookup-key map (kbd "<wheel-down>")) nil))))) + +(ert-deftest test-mousetrap-mode--build-keymap-edge-drag-vs-click-separation () + "Test drag events are independent from click events." + (let ((major-mode 'test-mode) + (mouse-trap-mode-profiles '((test-mode . primary-click)))) + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map)) + ;; mouse-1 allowed + (should (eq (lookup-key map (kbd "<mouse-1>")) nil)) + ;; drag-mouse-1 blocked + (should (eq (lookup-key map (kbd "<drag-mouse-1>")) 'ignore))))) + +(ert-deftest test-mousetrap-mode--build-keymap-edge-double-vs-single-click-separation () + "Test multi-clicks are independent from single clicks." + (let ((major-mode 'test-mode) + (mouse-trap-mode-profiles '((test-mode . primary-click)))) + (let ((map (mouse-trap--build-keymap))) + (should (keymapp map)) + ;; mouse-1 allowed + (should (eq (lookup-key map (kbd "<mouse-1>")) nil)) + ;; double-mouse-1 blocked + (should (eq (lookup-key map (kbd "<double-mouse-1>")) 'ignore)) + (should (eq (lookup-key map (kbd "<triple-mouse-1>")) 'ignore))))) + +(provide 'test-mousetrap-mode--build-keymap) +;;; test-mousetrap-mode--build-keymap.el ends here diff --git a/tests/test-mousetrap-mode--get-profile-for-mode.el b/tests/test-mousetrap-mode--get-profile-for-mode.el new file mode 100644 index 00000000..db0c47b9 --- /dev/null +++ b/tests/test-mousetrap-mode--get-profile-for-mode.el @@ -0,0 +1,98 @@ +;;; test-mousetrap-mode--get-profile-for-mode.el --- Tests for profile lookup -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for mouse-trap--get-profile-for-mode function. +;; Tests profile lookup logic including exact matches, inheritance, +;; and fallback to default profile. + +;;; Code: + +(require 'ert) +(require 'mousetrap-mode) + +;;; Normal Cases + +(ert-deftest test-mousetrap-mode--get-profile-for-mode-normal-exact-match-returns-profile () + "Test exact mode match returns mapped profile." + (let ((major-mode 'dashboard-mode)) + (should (eq 'scroll+primary (mouse-trap--get-profile-for-mode))))) + +(ert-deftest test-mousetrap-mode--get-profile-for-mode-normal-inherited-mode-returns-parent-profile () + "Test that org-mode inherits disabled profile from text-mode." + (with-temp-buffer + (org-mode) + (should (eq 'disabled (mouse-trap--get-profile-for-mode))))) + +(ert-deftest test-mousetrap-mode--get-profile-for-mode-normal-unmapped-mode-returns-default () + "Test unmapped mode returns default profile." + (with-temp-buffer + (emacs-lisp-mode) + (should (eq 'disabled (mouse-trap--get-profile-for-mode))))) + +(ert-deftest test-mousetrap-mode--get-profile-for-mode-normal-special-mode-derivative-returns-disabled () + "Test that help-mode inherits disabled from special-mode." + (with-temp-buffer + (help-mode) + (should (eq 'disabled (mouse-trap--get-profile-for-mode))))) + +(ert-deftest test-mousetrap-mode--get-profile-for-mode-normal-pdf-view-mode-returns-full () + "Test pdf-view-mode returns full profile." + (let ((major-mode 'pdf-view-mode)) + (should (eq 'full (mouse-trap--get-profile-for-mode))))) + +;;; Boundary Cases + +(ert-deftest test-mousetrap-mode--get-profile-for-mode-boundary-empty-mode-profiles-returns-default () + "Test empty mode profiles list returns default." + (let ((mouse-trap-mode-profiles nil)) + (with-temp-buffer + (emacs-lisp-mode) + (should (eq 'disabled (mouse-trap--get-profile-for-mode)))))) + +(ert-deftest test-mousetrap-mode--get-profile-for-mode-boundary-exact-match-priority-over-inheritance () + "Test exact mode match takes priority over inherited match." + (let ((major-mode 'text-mode)) + ;; text-mode is explicitly mapped to disabled + (should (eq 'disabled (mouse-trap--get-profile-for-mode))))) + +(ert-deftest test-mousetrap-mode--get-profile-for-mode-boundary-first-parent-match-wins () + "Test first matching parent profile wins with multiple inheritance. +When a mode could match multiple parent profiles, the first one +in mouse-trap-mode-profiles should win." + (let ((mouse-trap-mode-profiles + '((special-mode . disabled) + (text-mode . scroll-only))) + (major-mode 'derived-test-mode)) + ;; Simulate a mode that derives from special-mode + (put 'derived-test-mode 'derived-mode-parent 'special-mode) + (with-temp-buffer + (setq major-mode 'derived-test-mode) + (should (eq 'disabled (mouse-trap--get-profile-for-mode)))))) + +(ert-deftest test-mousetrap-mode--get-profile-for-mode-boundary-deeply-nested-inheritance () + "Test profile lookup works through deep inheritance chain." + ;; Create a deep inheritance chain: level3 -> level2 -> level1 -> text-mode + (let ((mouse-trap-mode-profiles + '((text-mode . disabled))) + (major-mode 'level3-mode)) + (put 'level1-mode 'derived-mode-parent 'text-mode) + (put 'level2-mode 'derived-mode-parent 'level1-mode) + (put 'level3-mode 'derived-mode-parent 'level2-mode) + (with-temp-buffer + (setq major-mode 'level3-mode) + (should (eq 'disabled (mouse-trap--get-profile-for-mode)))))) + +;;; Error Cases + +(ert-deftest test-mousetrap-mode--get-profile-for-mode-error-nil-major-mode-returns-default () + "Test nil major-mode returns default profile gracefully." + (let ((major-mode nil)) + (should (eq 'disabled (mouse-trap--get-profile-for-mode))))) + +(ert-deftest test-mousetrap-mode--get-profile-for-mode-error-invalid-symbol-returns-default () + "Test invalid major-mode symbol returns default profile." + (let ((major-mode 'not-a-real-mode-symbol)) + (should (eq 'disabled (mouse-trap--get-profile-for-mode))))) + +(provide 'test-mousetrap-mode--get-profile-for-mode) +;;; test-mousetrap-mode--get-profile-for-mode.el ends here diff --git a/tests/test-mousetrap-mode--lighter.el b/tests/test-mousetrap-mode--lighter.el new file mode 100644 index 00000000..982eed44 --- /dev/null +++ b/tests/test-mousetrap-mode--lighter.el @@ -0,0 +1,194 @@ +;;; test-mousetrap-mode--lighter.el --- Tests for lighter functionality -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for mouse-trap-mode lighter functionality. +;; Tests the dynamic lighter display and interactive clicking behavior. + +;;; Code: + +(require 'ert) +(require 'mousetrap-mode) + +;;; Normal Cases + +(ert-deftest test-mousetrap-mode--lighter-normal-shows-mousetrap-when-enabled () + "Test lighter shows 🪤 when mode is enabled." + (with-temp-buffer + (emacs-lisp-mode) + (mouse-trap-mode 1) + (let ((lighter (mouse-trap--lighter-string))) + (should (string-match-p "🪤" lighter))))) + +(ert-deftest test-mousetrap-mode--lighter-normal-shows-mouse-when-disabled () + "Test lighter shows 🐭 when mode is disabled." + (with-temp-buffer + (emacs-lisp-mode) + (mouse-trap-mode -1) + (let ((lighter (mouse-trap--lighter-string))) + (should (string-match-p "🐭" lighter))))) + +(ert-deftest test-mousetrap-mode--lighter-normal-has-help-echo () + "Test lighter has help-echo tooltip." + (with-temp-buffer + (emacs-lisp-mode) + (mouse-trap-mode 1) + (let ((lighter (mouse-trap--lighter-string))) + (should (get-text-property 0 'help-echo lighter)) + (should (string-match-p "Toggle" (get-text-property 0 'help-echo lighter)))))) + +(ert-deftest test-mousetrap-mode--lighter-normal-has-mouse-face () + "Test lighter has mouse-face for hover highlighting." + (with-temp-buffer + (emacs-lisp-mode) + (mouse-trap-mode 1) + (let ((lighter (mouse-trap--lighter-string))) + (should (eq (get-text-property 0 'mouse-face lighter) 'mode-line-highlight))))) + +(ert-deftest test-mousetrap-mode--lighter-normal-has-local-map () + "Test lighter has local-map for click handling." + (with-temp-buffer + (emacs-lisp-mode) + (mouse-trap-mode 1) + (let ((lighter (mouse-trap--lighter-string))) + (should (get-text-property 0 'local-map lighter)) + (should (keymapp (get-text-property 0 'local-map lighter)))))) + +(ert-deftest test-mousetrap-mode--lighter-normal-keymap-has-mouse-1-binding () + "Test lighter keymap has mouse-1 binding." + (should (keymapp mouse-trap--lighter-keymap)) + (let ((binding (lookup-key mouse-trap--lighter-keymap [mode-line mouse-1]))) + (should binding) + (should (functionp binding)))) + +(ert-deftest test-mousetrap-mode--lighter-normal-added-to-mode-line-misc-info () + "Test lighter is added to mode-line-misc-info when mode enabled." + (with-temp-buffer + (emacs-lisp-mode) + (let ((mode-line-misc-info nil)) ; Start fresh + (mouse-trap-mode 1) + (should (member '(:eval (mouse-trap--lighter-string)) mode-line-misc-info))))) + +(ert-deftest test-mousetrap-mode--lighter-normal-persists-when-mode-disabled () + "Test lighter stays in mode-line-misc-info when mode disabled. +This allows it to show the 🐭 indicator when mode is off." + (with-temp-buffer + (emacs-lisp-mode) + (let ((mode-line-misc-info nil)) ; Start fresh + (mouse-trap-mode 1) + (should (member '(:eval (mouse-trap--lighter-string)) mode-line-misc-info)) + (mouse-trap-mode -1) + ;; Lighter should still be present (to show 🐭) + (should (member '(:eval (mouse-trap--lighter-string)) mode-line-misc-info))))) + +;;; Boundary Cases + +(ert-deftest test-mousetrap-mode--lighter-boundary-toggle-changes-display () + "Test toggling mode changes lighter display between 🪤 and 🐭." + (with-temp-buffer + (emacs-lisp-mode) + (mouse-trap-mode 1) + (let ((lighter-on (mouse-trap--lighter-string))) + (should (string-match-p "🪤" lighter-on))) + (mouse-trap-mode -1) + (let ((lighter-off (mouse-trap--lighter-string))) + (should (string-match-p "🐭" lighter-off))) + (mouse-trap-mode 1) + (let ((lighter-on-again (mouse-trap--lighter-string))) + (should (string-match-p "🪤" lighter-on-again))))) + +(ert-deftest test-mousetrap-mode--lighter-boundary-multiple-enables-no-duplicates () + "Test enabling mode multiple times doesn't create duplicate lighters." + (with-temp-buffer + (emacs-lisp-mode) + (let ((mode-line-misc-info nil)) ; Start fresh + (mouse-trap-mode 1) + (mouse-trap-mode -1) + (mouse-trap-mode 1) + ;; Should only have one entry + (let ((count 0)) + (dolist (item mode-line-misc-info) + (when (equal item '(:eval (mouse-trap--lighter-string))) + (setq count (1+ count)))) + (should (= count 1)))))) + +(ert-deftest test-mousetrap-mode--lighter-boundary-different-buffers-independent () + "Test lighter state is independent in different buffers." + (let ((buf1 (generate-new-buffer "test1")) + (buf2 (generate-new-buffer "test2"))) + (unwind-protect + (progn + (with-current-buffer buf1 + (emacs-lisp-mode) + (mouse-trap-mode 1) + (should (string-match-p "🪤" (mouse-trap--lighter-string)))) + (with-current-buffer buf2 + (emacs-lisp-mode) + (mouse-trap-mode -1) + (should (string-match-p "🐭" (mouse-trap--lighter-string)))) + ;; Verify buf1 still shows 🪤 + (with-current-buffer buf1 + (should (string-match-p "🪤" (mouse-trap--lighter-string))))) + (kill-buffer buf1) + (kill-buffer buf2)))) + +;;; Edge Cases + +(ert-deftest test-mousetrap-mode--lighter-edge-always-evaluates-regardless-of-mode-state () + "Test that lighter entry always evaluates, even when mode is disabled. +This is critical - the entry structure is (:eval ...) not (mouse-trap-mode (:eval ...)) +so it displays regardless of the mode variable's value." + (with-temp-buffer + (emacs-lisp-mode) + (let ((mode-line-misc-info nil)) + ;; Enable mode - adds lighter + (mouse-trap-mode 1) + (let ((entry (car (member '(:eval (mouse-trap--lighter-string)) mode-line-misc-info)))) + (should entry) + ;; Entry should be (:eval ...) not (mouse-trap-mode (:eval ...)) + (should (eq (car entry) :eval)) + ;; Verify it's not conditional on mouse-trap-mode being the car + (should-not (eq (car entry) 'mouse-trap-mode))) + + ;; Disable mode - lighter stays and still evaluates + (mouse-trap-mode -1) + (let ((entry (car (member '(:eval (mouse-trap--lighter-string)) mode-line-misc-info)))) + (should entry) + (should (eq (car entry) :eval)))))) + +(ert-deftest test-mousetrap-mode--lighter-edge-string-always-has-space-prefix () + "Test lighter string always starts with space for proper modeline spacing." + (with-temp-buffer + (emacs-lisp-mode) + (mouse-trap-mode 1) + (let ((lighter-on (mouse-trap--lighter-string))) + (should (string-prefix-p " " lighter-on))) + (mouse-trap-mode -1) + (let ((lighter-off (mouse-trap--lighter-string))) + (should (string-prefix-p " " lighter-off))))) + +(ert-deftest test-mousetrap-mode--lighter-edge-properties-cover-entire-string () + "Test text properties are applied to entire lighter string." + (with-temp-buffer + (emacs-lisp-mode) + (mouse-trap-mode 1) + (let ((lighter (mouse-trap--lighter-string))) + ;; Check properties at each position + (dotimes (i (length lighter)) + (should (get-text-property i 'local-map lighter)) + (should (get-text-property i 'mouse-face lighter)) + (should (get-text-property i 'help-echo lighter)))))) + +(ert-deftest test-mousetrap-mode--lighter-edge-same-keymap-instance () + "Test all lighters use the same keymap instance for efficiency." + (with-temp-buffer + (emacs-lisp-mode) + (mouse-trap-mode 1) + (let ((lighter1 (mouse-trap--lighter-string)) + (lighter2 (mouse-trap--lighter-string))) + (should (eq (get-text-property 0 'local-map lighter1) + (get-text-property 0 'local-map lighter2))) + (should (eq (get-text-property 0 'local-map lighter1) + mouse-trap--lighter-keymap))))) + +(provide 'test-mousetrap-mode--lighter) +;;; test-mousetrap-mode--lighter.el ends here diff --git a/tests/test-music-config--append-track-to-m3u-file.el b/tests/test-music-config--append-track-to-m3u-file.el new file mode 100644 index 00000000..be0cbd8e --- /dev/null +++ b/tests/test-music-config--append-track-to-m3u-file.el @@ -0,0 +1,194 @@ +;;; test-music-config--append-track-to-m3u-file.el --- Tests for appending tracks to M3U files -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--append-track-to-m3u-file function. +;; Tests the pure, deterministic helper that appends track paths to M3U files. +;; +;; Test organization: +;; - Normal Cases: Standard append operations +;; - Boundary Cases: Edge conditions (unicode, long paths, special chars) +;; - Error Cases: File errors (missing, read-only, directory instead of file) +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--append-track-to-m3u-file-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--append-track-to-m3u-file-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--append-track-to-m3u-file-normal-empty-file-appends-track () + "Append to brand new empty M3U file." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (track-path (expand-file-name "artist/song.mp3" cj/music-root)) + (expected-relative "artist/song.mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) (concat expected-relative "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-normal-existing-with-newline-appends-track () + "Append to file with existing content ending with newline." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((existing-content "first.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content existing-content "test-playlist-")) + (track-path (expand-file-name "second.mp3" cj/music-root)) + (expected-relative "second.mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) + (concat existing-content expected-relative "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-normal-existing-without-newline-appends-track () + "Append to file without trailing newline adds leading newline." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((existing-content "first.mp3") + (m3u-file (cj/create-temp-test-file-with-content existing-content "test-playlist-")) + (track-path (expand-file-name "second.mp3" cj/music-root)) + (expected-relative "second.mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) + (concat existing-content "\n" expected-relative "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-normal-multiple-appends-all-succeed () + "Multiple appends to same file all succeed (allows duplicates)." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (track1 (expand-file-name "track1.mp3" cj/music-root)) + (track2 (expand-file-name "track2.mp3" cj/music-root)) + (track1-duplicate (expand-file-name "track1.mp3" cj/music-root)) + (rel1 "track1.mp3") + (rel2 "track2.mp3")) + (cj/music--append-track-to-m3u-file track1 m3u-file) + (cj/music--append-track-to-m3u-file track2 m3u-file) + (cj/music--append-track-to-m3u-file track1-duplicate m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (let ((content (buffer-string))) + (should (string= content + (concat rel1 "\n" rel2 "\n" rel1 "\n")))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--append-track-to-m3u-file-boundary-very-long-path-appends-successfully () + "Append very long track path without truncation." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + ;; Create a relative path that's ~450 chars long + (relative-path (concat (make-string 440 ?a) "/song.mp3")) + (track-path (expand-file-name relative-path cj/music-root))) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) (concat relative-path "\n"))) + (should (= (length (buffer-string)) (1+ (length relative-path)))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-boundary-path-with-unicode-appends-successfully () + "Append path with unicode characters preserves UTF-8 encoding." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (relative-path "中文/artist-名前/song🎵.mp3") + (track-path (expand-file-name relative-path cj/music-root))) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) (concat relative-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-boundary-path-with-spaces-appends-successfully () + "Append path with spaces and special characters." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (relative-path "Artist Name/Album (2024)/01 - Song's Title [Remix].mp3") + (track-path (expand-file-name relative-path cj/music-root))) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) (concat relative-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-boundary-m3u-with-comments-appends-after () + "Append to M3U file containing comments and metadata." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((existing-content "#EXTM3U\n#EXTINF:-1,Radio Station\nhttp://stream.url/radio\n") + (m3u-file (cj/create-temp-test-file-with-content existing-content "test-playlist-")) + (relative-path "local-track.mp3") + (track-path (expand-file-name relative-path cj/music-root))) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) + (concat existing-content relative-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--append-track-to-m3u-file-error-nonexistent-file-signals-error () + "Signal error when M3U file doesn't exist." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file "/nonexistent/path/to/playlist.m3u") + (track-path "/home/user/music/song.mp3")) + (should-error (cj/music--append-track-to-m3u-file track-path m3u-file) + :type 'error)) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-error-readonly-file-signals-error () + "Signal error when M3U file is read-only." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (track-path "/home/user/music/song.mp3")) + ;; Make file read-only + (set-file-modes m3u-file #o444) + (should-error (cj/music--append-track-to-m3u-file track-path m3u-file) + :type 'error)) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-error-directory-not-file-signals-error () + "Signal error when path points to directory instead of file." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-dir (cj/create-test-subdirectory "test-playlist-dir")) + (track-path "/home/user/music/song.mp3")) + (should-error (cj/music--append-track-to-m3u-file track-path m3u-dir) + :type 'error)) + (test-music-config--append-track-to-m3u-file-teardown))) + +(provide 'test-music-config--append-track-to-m3u-file) +;;; test-music-config--append-track-to-m3u-file.el ends here diff --git a/tests/test-music-config--collect-entries-recursive.el b/tests/test-music-config--collect-entries-recursive.el new file mode 100644 index 00000000..d71ceab6 --- /dev/null +++ b/tests/test-music-config--collect-entries-recursive.el @@ -0,0 +1,245 @@ +;;; test-music-config--collect-entries-recursive.el --- Tests for recursive music collection -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--collect-entries-recursive function. +;; Tests the recursive helper that collects music files and directories. +;; +;; Test organization: +;; - Normal Cases: Single level, nested directories, mixed files +;; - Boundary Cases: Hidden files/dirs, non-music files, empty dirs, sorting +;; - Error Cases: Empty root, nonexistent root +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--collect-entries-recursive-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--collect-entries-recursive-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--collect-entries-recursive-normal-single-level-files-and-dirs () + "Collect music files and subdirectories at single level." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Create files at root + (cj/create-directory-or-file-ensuring-parents "music/song1.mp3" "") + (cj/create-directory-or-file-ensuring-parents "music/song2.flac" "") + ;; Create subdirectories + (cj/create-directory-or-file-ensuring-parents "music/artist1/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist2/" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "artist1/" result)) + (should (member "artist2/" result)) + (should (member "song1.mp3" result)) + (should (member "song2.flac" result)) + (should (= (length result) 4)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-normal-nested-directories () + "Collect nested directories multiple levels deep." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Create nested structure + (cj/create-directory-or-file-ensuring-parents "music/artist/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist/album/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist/album/disc1/" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "artist/" result)) + (should (member "artist/album/" result)) + (should (member "artist/album/disc1/" result)) + (should (= (length result) 3)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-normal-mixed-files-at-multiple-levels () + "Collect music files at root, subdirs, and nested subdirs." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Root level file + (cj/create-directory-or-file-ensuring-parents "music/root-track.mp3" "") + ;; Subdir with file + (cj/create-directory-or-file-ensuring-parents "music/artist/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist/track1.mp3" "") + ;; Nested subdir with file + (cj/create-directory-or-file-ensuring-parents "music/artist/album/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist/album/track2.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "root-track.mp3" result)) + (should (member "artist/" result)) + (should (member "artist/track1.mp3" result)) + (should (member "artist/album/" result)) + (should (member "artist/album/track2.mp3" result)) + (should (= (length result) 5)))) + (test-music-config--collect-entries-recursive-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--collect-entries-recursive-boundary-hidden-directories-skipped () + "Hidden directories and their contents are excluded." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Visible file + (cj/create-directory-or-file-ensuring-parents "music/visible.mp3" "") + ;; Hidden directory with music file + (cj/create-directory-or-file-ensuring-parents "music/.hidden/" "") + (cj/create-directory-or-file-ensuring-parents "music/.hidden/secret.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "visible.mp3" result)) + (should-not (member ".hidden/" result)) + (should-not (member ".hidden/secret.mp3" result)) + (should (= (length result) 1)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-hidden-files-skipped () + "Hidden files at root are excluded." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Visible file + (cj/create-directory-or-file-ensuring-parents "music/visible.mp3" "") + ;; Hidden file (note: directory-files regex "^[^.].*" should skip it) + (cj/create-directory-or-file-ensuring-parents "music/.hidden-track.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "visible.mp3" result)) + (should-not (member ".hidden-track.mp3" result)) + (should (= (length result) 1)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-non-music-files-excluded () + "Non-music files are excluded." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Music file + (cj/create-directory-or-file-ensuring-parents "music/song.mp3" "") + ;; Non-music files + (cj/create-directory-or-file-ensuring-parents "music/readme.txt" "") + (cj/create-directory-or-file-ensuring-parents "music/cover.jpg" "") + (cj/create-directory-or-file-ensuring-parents "music/info.pdf" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "song.mp3" result)) + (should-not (member "readme.txt" result)) + (should-not (member "cover.jpg" result)) + (should-not (member "info.pdf" result)) + (should (= (length result) 1)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-empty-directories-included () + "Empty subdirectories are still listed with trailing slash." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Empty subdirectories + (cj/create-directory-or-file-ensuring-parents "music/empty-artist/" "") + (cj/create-directory-or-file-ensuring-parents "music/another-empty/" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "empty-artist/" result)) + (should (member "another-empty/" result)) + (should (= (length result) 2)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-sorted-output () + "Output is sorted alphabetically (case-insensitive)." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Create files in non-alphabetical order + (cj/create-directory-or-file-ensuring-parents "music/zebra.mp3" "") + (cj/create-directory-or-file-ensuring-parents "music/Alpha.mp3" "") + (cj/create-directory-or-file-ensuring-parents "music/beta.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + ;; Should be sorted alphabetically (case-insensitive) + (should (equal result '("Alpha.mp3" "beta.mp3" "zebra.mp3"))))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-directories-have-trailing-slash () + "Directories have trailing slash, files don't." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + (cj/create-directory-or-file-ensuring-parents "music/artist/" "") + (cj/create-directory-or-file-ensuring-parents "music/song.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + ;; Directory has trailing slash + (should (cl-some (lambda (entry) (string-suffix-p "/" entry)) result)) + ;; File doesn't have trailing slash + (should (cl-some (lambda (entry) (not (string-suffix-p "/" entry))) result)) + ;; Specifically check + (should (member "artist/" result)) + (should (member "song.mp3" result)) + (should-not (member "song.mp3/" result)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-all-music-extensions () + "All configured music extensions are collected." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Create file for each extension: aac, flac, m4a, mp3, ogg, opus, wav + (cj/create-directory-or-file-ensuring-parents "music/track.aac" "") + (cj/create-directory-or-file-ensuring-parents "music/track.flac" "") + (cj/create-directory-or-file-ensuring-parents "music/track.m4a" "") + (cj/create-directory-or-file-ensuring-parents "music/track.mp3" "") + (cj/create-directory-or-file-ensuring-parents "music/track.ogg" "") + (cj/create-directory-or-file-ensuring-parents "music/track.opus" "") + (cj/create-directory-or-file-ensuring-parents "music/track.wav" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (= (length result) 7)) + (should (member "track.aac" result)) + (should (member "track.flac" result)) + (should (member "track.m4a" result)) + (should (member "track.mp3" result)) + (should (member "track.ogg" result)) + (should (member "track.opus" result)) + (should (member "track.wav" result)))) + (test-music-config--collect-entries-recursive-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--collect-entries-recursive-error-empty-root-returns-empty () + "Empty root directory returns empty list." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "empty-music"))) + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (null result)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-error-nonexistent-root-returns-empty () + "Nonexistent directory returns empty list." + (let ((result (cj/music--collect-entries-recursive "/nonexistent/path/to/music"))) + (should (null result)))) + +(provide 'test-music-config--collect-entries-recursive) +;;; test-music-config--collect-entries-recursive.el ends here diff --git a/tests/test-music-config--completion-table.el b/tests/test-music-config--completion-table.el new file mode 100644 index 00000000..5be0479d --- /dev/null +++ b/tests/test-music-config--completion-table.el @@ -0,0 +1,134 @@ +;;; test-music-config--completion-table.el --- Tests for completion table generation -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--completion-table function. +;; Tests the completion table generator that creates custom completion tables. +;; +;; Test organization: +;; - Normal Cases: Metadata, completions, case-insensitive matching +;; - Boundary Cases: Empty candidates, partial matching, exact matches +;; - Error Cases: Nil candidates +;; +;;; Code: + +(require 'ert) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Normal Cases + +(ert-deftest test-music-config--completion-table-normal-metadata-action-returns-metadata () + "Completion table returns metadata when action is 'metadata." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "" nil 'metadata))) + (should (eq (car result) 'metadata)) + ;; Check metadata contains expected properties + (should (equal (alist-get 'display-sort-function (cdr result)) 'identity)) + (should (equal (alist-get 'cycle-sort-function (cdr result)) 'identity)) + (should (eq (alist-get 'completion-ignore-case (cdr result)) t)))) + +(ert-deftest test-music-config--completion-table-normal-t-action-returns-all-completions () + "Completion table returns all matching completions when action is t." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "" nil t))) + ;; Empty string should match all candidates + (should (equal (sort result #'string<) '("Classical" "Jazz" "Rock"))))) + +(ert-deftest test-music-config--completion-table-normal-nil-action-tries-completion () + "Completion table tries completion when action is nil." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "Roc" nil nil))) + ;; Should return completion attempt for "Roc" -> "Rock" + (should (stringp result)) + (should (string-prefix-p "Roc" result)))) + +(ert-deftest test-music-config--completion-table-normal-case-insensitive-metadata () + "Completion table metadata indicates case-insensitive completion." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (metadata (funcall table "" nil 'metadata))) + ;; Metadata should indicate case-insensitive + (should (eq (alist-get 'completion-ignore-case (cdr metadata)) t)))) + +;;; Boundary Cases + +(ert-deftest test-music-config--completion-table-boundary-empty-candidates () + "Completion table with empty candidate list returns no completions." + (let* ((candidates '()) + (table (cj/music--completion-table candidates)) + (result (funcall table "anything" nil t))) + (should (null result)))) + +(ert-deftest test-music-config--completion-table-boundary-single-candidate () + "Completion table with single candidate returns it on match." + (let* ((candidates '("OnlyOne")) + (table (cj/music--completion-table candidates)) + (result (funcall table "Only" nil t))) + (should (equal result '("OnlyOne"))))) + +(ert-deftest test-music-config--completion-table-boundary-partial-matching () + "Completion table matches multiple candidates with common prefix." + (let* ((candidates '("playlist1" "playlist2" "jazz")) + (table (cj/music--completion-table candidates)) + (result (funcall table "play" nil t))) + (should (= (length result) 2)) + (should (member "playlist1" result)) + (should (member "playlist2" result)) + (should-not (member "jazz" result)))) + +(ert-deftest test-music-config--completion-table-boundary-no-matches () + "Completion table returns empty when no candidates match." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "Metal" nil t))) + (should (null result)))) + +(ert-deftest test-music-config--completion-table-boundary-exact-match () + "Completion table returns t for exact match with nil action." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "Jazz" nil nil))) + ;; Exact match with nil action returns t + (should (eq result t)))) + +(ert-deftest test-music-config--completion-table-boundary-mixed-case-candidates () + "Completion table with mixed-case duplicate candidates." + (let* ((candidates '("Rock" "ROCK" "rock")) + (table (cj/music--completion-table candidates)) + (result (funcall table "R" nil t))) + ;; All start with "R", but exact case matters for complete-with-action + ;; Only exact case match "R" prefix + (should (member "Rock" result)) + (should (member "ROCK" result)) + ;; "rock" doesn't match "R" prefix (lowercase) + (should-not (member "rock" result)))) + +(ert-deftest test-music-config--completion-table-boundary-unicode-candidates () + "Completion table handles unicode characters in candidates." + (let* ((candidates '("中文" "日本語" "한국어")) + (table (cj/music--completion-table candidates)) + (result (funcall table "中" nil t))) + (should (member "中文" result)))) + +;;; Error Cases + +(ert-deftest test-music-config--completion-table-error-nil-candidates-handles-gracefully () + "Completion table with nil candidates handles gracefully." + (let* ((candidates nil) + (table (cj/music--completion-table candidates)) + (result (funcall table "anything" nil t))) + ;; Should not crash, returns empty + (should (null result)))) + +(provide 'test-music-config--completion-table) +;;; test-music-config--completion-table.el ends here diff --git a/tests/test-music-config--get-m3u-basenames.el b/tests/test-music-config--get-m3u-basenames.el new file mode 100644 index 00000000..91c8af70 --- /dev/null +++ b/tests/test-music-config--get-m3u-basenames.el @@ -0,0 +1,121 @@ +;;; test-music-config--get-m3u-basenames.el --- Tests for M3U basename extraction -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--get-m3u-basenames function. +;; Tests the helper that extracts M3U basenames (without .m3u extension). +;; +;; Test organization: +;; - Normal Cases: Multiple files, single file +;; - Boundary Cases: Empty directory, extension removal +;; - Error Cases: Nonexistent directory +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--get-m3u-basenames-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--get-m3u-basenames-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--get-m3u-basenames-normal-multiple-files-returns-basenames () + "Extract basenames from multiple M3U files without .m3u extension." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "rock.m3u")) + (file2 (cj/create-temp-test-file-with-content "" "jazz.m3u")) + (file3 (cj/create-temp-test-file-with-content "" "classical.m3u"))) + (rename-file file1 (expand-file-name "rock.m3u" test-dir)) + (rename-file file2 (expand-file-name "jazz.m3u" test-dir)) + (rename-file file3 (expand-file-name "classical.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (= (length result) 3)) + ;; Sort for consistent comparison + (let ((sorted-result (sort result #'string<))) + (should (equal sorted-result '("classical" "jazz" "rock"))))))) + (test-music-config--get-m3u-basenames-teardown))) + +(ert-deftest test-music-config--get-m3u-basenames-normal-single-file-returns-basename () + "Extract basename from single M3U file without .m3u extension." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "favorites.m3u"))) + (rename-file file1 (expand-file-name "favorites.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (= (length result) 1)) + (should (equal (car result) "favorites"))))) + (test-music-config--get-m3u-basenames-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--get-m3u-basenames-boundary-empty-directory-returns-empty () + "Extract basenames from empty directory returns empty list." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "empty-playlists"))) + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (null result))))) + (test-music-config--get-m3u-basenames-teardown))) + +(ert-deftest test-music-config--get-m3u-basenames-boundary-extension-removed () + "Basenames have .m3u extension removed." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "test.m3u"))) + (rename-file file1 (expand-file-name "playlist.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (equal result '("playlist"))) + ;; Verify no .m3u extension present + (should-not (string-match-p "\\.m3u" (car result)))))) + (test-music-config--get-m3u-basenames-teardown))) + +(ert-deftest test-music-config--get-m3u-basenames-boundary-spaces-in-filename-preserved () + "Basenames with spaces preserve the spaces." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "test.m3u"))) + (rename-file file1 (expand-file-name "My Favorite Songs.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (equal result '("My Favorite Songs")))))) + (test-music-config--get-m3u-basenames-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--get-m3u-basenames-error-nonexistent-directory-signals-error () + "Nonexistent directory signals error." + (let ((cj/music-m3u-root "/nonexistent/directory/path")) + (should-error (cj/music--get-m3u-basenames) + :type 'file-error))) + +(provide 'test-music-config--get-m3u-basenames) +;;; test-music-config--get-m3u-basenames.el ends here diff --git a/tests/test-music-config--get-m3u-files.el b/tests/test-music-config--get-m3u-files.el new file mode 100644 index 00000000..2d31d554 --- /dev/null +++ b/tests/test-music-config--get-m3u-files.el @@ -0,0 +1,150 @@ +;;; test-music-config--get-m3u-files.el --- Tests for M3U file discovery -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--get-m3u-files function. +;; Tests the helper that discovers M3U files in the music directory. +;; +;; Test organization: +;; - Normal Cases: Multiple M3U files, single file +;; - Boundary Cases: Empty directory, non-M3U files, various filenames +;; - Error Cases: Nonexistent directory +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--get-m3u-files-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--get-m3u-files-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--get-m3u-files-normal-multiple-files-returns-list () + "Discover multiple M3U files returns list of (basename . fullpath) conses." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "playlist1.m3u")) + (file2 (cj/create-temp-test-file-with-content "" "playlist2.m3u")) + (file3 (cj/create-temp-test-file-with-content "" "playlist3.m3u"))) + ;; Move files to test-dir + (rename-file file1 (expand-file-name "playlist1.m3u" test-dir)) + (rename-file file2 (expand-file-name "playlist2.m3u" test-dir)) + (rename-file file3 (expand-file-name "playlist3.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (= (length result) 3)) + ;; Check structure: list of (basename . fullpath) conses + ;; Sort for consistent comparison (directory-files order is filesystem-dependent) + (let ((basenames (sort (mapcar #'car result) #'string<)) + (fullpaths (sort (mapcar #'cdr result) #'string<))) + (should (equal basenames '("playlist1.m3u" "playlist2.m3u" "playlist3.m3u"))) + (should (equal fullpaths + (list (expand-file-name "playlist1.m3u" test-dir) + (expand-file-name "playlist2.m3u" test-dir) + (expand-file-name "playlist3.m3u" test-dir)))))))) + (test-music-config--get-m3u-files-teardown))) + +(ert-deftest test-music-config--get-m3u-files-normal-single-file-returns-list () + "Discover single M3U file returns single-item list." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "myplaylist.m3u"))) + (rename-file file1 (expand-file-name "myplaylist.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (= (length result) 1)) + (should (equal (caar result) "myplaylist.m3u")) + (should (equal (cdar result) (expand-file-name "myplaylist.m3u" test-dir)))))) + (test-music-config--get-m3u-files-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--get-m3u-files-boundary-empty-directory-returns-empty () + "Discover M3U files in empty directory returns empty list." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "empty-playlists"))) + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (null result))))) + (test-music-config--get-m3u-files-teardown))) + +(ert-deftest test-music-config--get-m3u-files-boundary-non-m3u-files-ignored () + "Directory with non-M3U files returns empty list." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "mixed-files")) + (txt-file (cj/create-temp-test-file-with-content "" "readme.txt")) + (mp3-file (cj/create-temp-test-file-with-content "" "song.mp3")) + (json-file (cj/create-temp-test-file-with-content "" "data.json"))) + (rename-file txt-file (expand-file-name "readme.txt" test-dir)) + (rename-file mp3-file (expand-file-name "song.mp3" test-dir)) + (rename-file json-file (expand-file-name "data.json" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (null result))))) + (test-music-config--get-m3u-files-teardown))) + +(ert-deftest test-music-config--get-m3u-files-boundary-m3u-with-spaces-included () + "M3U files with spaces in name are discovered." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "my-playlist.m3u"))) + (rename-file file1 (expand-file-name "My Favorite Songs.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (= (length result) 1)) + (should (equal (caar result) "My Favorite Songs.m3u"))))) + (test-music-config--get-m3u-files-teardown))) + +(ert-deftest test-music-config--get-m3u-files-boundary-mixed-m3u-and-other-files () + "Directory with both M3U and non-M3U files returns only M3U files." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "mixed")) + (m3u-file (cj/create-temp-test-file-with-content "" "playlist.m3u")) + (txt-file (cj/create-temp-test-file-with-content "" "readme.txt")) + (mp3-file (cj/create-temp-test-file-with-content "" "song.mp3"))) + (rename-file m3u-file (expand-file-name "playlist.m3u" test-dir)) + (rename-file txt-file (expand-file-name "readme.txt" test-dir)) + (rename-file mp3-file (expand-file-name "song.mp3" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (= (length result) 1)) + (should (equal (caar result) "playlist.m3u"))))) + (test-music-config--get-m3u-files-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--get-m3u-files-error-nonexistent-directory-signals-error () + "Nonexistent directory signals error." + (let ((cj/music-m3u-root "/nonexistent/directory/path")) + (should-error (cj/music--get-m3u-files) + :type 'file-error))) + +(provide 'test-music-config--get-m3u-files) +;;; test-music-config--get-m3u-files.el ends here diff --git a/tests/test-music-config--m3u-file-tracks.el b/tests/test-music-config--m3u-file-tracks.el new file mode 100644 index 00000000..badc9817 --- /dev/null +++ b/tests/test-music-config--m3u-file-tracks.el @@ -0,0 +1,193 @@ +;;; test-music-config--m3u-file-tracks.el --- Tests for M3U file parsing -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--m3u-file-tracks function. +;; Tests the M3U parser that extracts track paths from playlist files. +;; +;; Test organization: +;; - Normal Cases: Absolute paths, relative paths, URLs (http/https/mms) +;; - Boundary Cases: Empty lines, whitespace, comments, order preservation +;; - Error Cases: Nonexistent files, nil input +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--m3u-file-tracks-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--m3u-file-tracks-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--m3u-file-tracks-normal-absolute-paths-returns-list () + "Parse M3U with absolute paths returns list in order." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/home/user/music/track1.mp3\n/home/user/music/track2.mp3\n/home/user/music/track3.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track1.mp3" + "/home/user/music/track2.mp3" + "/home/user/music/track3.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-relative-paths-expanded () + "Parse M3U with relative paths expands them relative to M3U directory." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "artist/track1.mp3\nartist/track2.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (m3u-dir (file-name-directory m3u-file)) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks (list (expand-file-name "artist/track1.mp3" m3u-dir) + (expand-file-name "artist/track2.mp3" m3u-dir))))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-http-urls-preserved () + "Parse M3U with http:// URLs preserves them as-is." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "http://example.com/stream1.mp3\nhttp://example.com/stream2.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("http://example.com/stream1.mp3" + "http://example.com/stream2.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-https-urls-preserved () + "Parse M3U with https:// URLs preserves them as-is." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "https://secure.example.com/stream.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("https://secure.example.com/stream.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-mms-urls-preserved () + "Parse M3U with mms:// URLs preserves them as-is." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "mms://radio.example.com/stream\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("mms://radio.example.com/stream")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-mixed-paths-and-urls () + "Parse M3U with mix of absolute, relative, and URLs handles all correctly." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/home/user/music/local.mp3\nartist/relative.mp3\nhttp://example.com/stream.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (m3u-dir (file-name-directory m3u-file)) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks (list "/home/user/music/local.mp3" + (expand-file-name "artist/relative.mp3" m3u-dir) + "http://example.com/stream.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--m3u-file-tracks-boundary-empty-lines-ignored () + "Parse M3U with empty lines ignores them and returns tracks." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/home/user/music/track1.mp3\n\n/home/user/music/track2.mp3\n\n\n/home/user/music/track3.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track1.mp3" + "/home/user/music/track2.mp3" + "/home/user/music/track3.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-whitespace-only-lines-ignored () + "Parse M3U with whitespace-only lines ignores them." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/home/user/music/track1.mp3\n \n\t\t\n/home/user/music/track2.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track1.mp3" + "/home/user/music/track2.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-comments-ignored () + "Parse M3U with comment lines ignores them, returns only tracks." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "#EXTM3U\n#EXTINF:-1,Track Title\n/home/user/music/track.mp3\n#Another comment\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-leading-trailing-whitespace-trimmed () + "Parse M3U with whitespace around paths trims it." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content " /home/user/music/track1.mp3 \n\t/home/user/music/track2.mp3\t\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track1.mp3" + "/home/user/music/track2.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-empty-file-returns-nil () + "Parse empty M3U file returns nil." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (null tracks))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-only-comments-returns-empty () + "Parse M3U with only comments returns empty list." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "#EXTM3U\n#EXTINF:-1,Title\n#Another comment\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (null tracks))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-preserves-order () + "Parse M3U preserves track order (tests nreverse)." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/track1.mp3\n/track2.mp3\n/track3.mp3\n/track4.mp3\n/track5.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/track1.mp3" "/track2.mp3" "/track3.mp3" "/track4.mp3" "/track5.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--m3u-file-tracks-error-nonexistent-file-returns-nil () + "Parse nonexistent file returns nil." + (should (null (cj/music--m3u-file-tracks "/nonexistent/path/playlist.m3u")))) + +(ert-deftest test-music-config--m3u-file-tracks-error-nil-input-returns-nil () + "Parse nil input returns nil gracefully." + (should (null (cj/music--m3u-file-tracks nil)))) + +(provide 'test-music-config--m3u-file-tracks) +;;; test-music-config--m3u-file-tracks.el ends here diff --git a/tests/test-music-config--safe-filename.el b/tests/test-music-config--safe-filename.el new file mode 100644 index 00000000..8105ee15 --- /dev/null +++ b/tests/test-music-config--safe-filename.el @@ -0,0 +1,97 @@ +;;; test-music-config--safe-filename.el --- Tests for filename sanitization -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--safe-filename function. +;; Tests the pure helper that sanitizes filenames by replacing invalid chars. +;; +;; Test organization: +;; - Normal Cases: Valid filenames unchanged, spaces replaced +;; - Boundary Cases: Special chars, unicode, slashes, consecutive invalid chars +;; - Error Cases: Nil input +;; +;;; Code: + +(require 'ert) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Normal Cases + +(ert-deftest test-music-config--safe-filename-normal-alphanumeric-unchanged () + "Validate alphanumeric filename remains unchanged." + (should (string= (cj/music--safe-filename "MyPlaylist123") + "MyPlaylist123"))) + +(ert-deftest test-music-config--safe-filename-normal-with-hyphens-unchanged () + "Validate filename with hyphens remains unchanged." + (should (string= (cj/music--safe-filename "my-playlist-name") + "my-playlist-name"))) + +(ert-deftest test-music-config--safe-filename-normal-with-underscores-unchanged () + "Validate filename with underscores remains unchanged." + (should (string= (cj/music--safe-filename "my_playlist_name") + "my_playlist_name"))) + +(ert-deftest test-music-config--safe-filename-normal-spaces-replaced () + "Validate spaces are replaced with underscores." + (should (string= (cj/music--safe-filename "My Favorite Songs") + "My_Favorite_Songs"))) + +;;; Boundary Cases + +(ert-deftest test-music-config--safe-filename-boundary-special-chars-replaced () + "Validate special characters are replaced with underscores." + (should (string= (cj/music--safe-filename "playlist@#$%^&*()") + "playlist_________"))) + +(ert-deftest test-music-config--safe-filename-boundary-unicode-replaced () + "Validate unicode characters are replaced with underscores." + (should (string= (cj/music--safe-filename "中文歌曲") + "____"))) + +(ert-deftest test-music-config--safe-filename-boundary-mixed-valid-invalid () + "Validate mixed valid and invalid characters." + (should (string= (cj/music--safe-filename "Rock & Roll") + "Rock___Roll"))) + +(ert-deftest test-music-config--safe-filename-boundary-dots-replaced () + "Validate dots are replaced with underscores." + (should (string= (cj/music--safe-filename "my.playlist.name") + "my_playlist_name"))) + +(ert-deftest test-music-config--safe-filename-boundary-slashes-replaced () + "Validate slashes are replaced with underscores." + (should (string= (cj/music--safe-filename "folder/file") + "folder_file"))) + +(ert-deftest test-music-config--safe-filename-boundary-consecutive-invalid-chars () + "Validate consecutive invalid characters each become underscores." + (should (string= (cj/music--safe-filename "test!!!name") + "test___name"))) + +(ert-deftest test-music-config--safe-filename-boundary-empty-string-unchanged () + "Validate empty string remains unchanged." + (should (string= (cj/music--safe-filename "") + ""))) + +(ert-deftest test-music-config--safe-filename-boundary-only-invalid-chars () + "Validate string with only invalid characters becomes all underscores." + (should (string= (cj/music--safe-filename "!@#$%") + "_____"))) + +;;; Error Cases + +(ert-deftest test-music-config--safe-filename-error-nil-input-signals-error () + "Validate nil input signals error." + (should-error (cj/music--safe-filename nil) + :type 'wrong-type-argument)) + +(provide 'test-music-config--safe-filename) +;;; test-music-config--safe-filename.el ends here diff --git a/tests/test-music-config--valid-directory-p.el b/tests/test-music-config--valid-directory-p.el new file mode 100644 index 00000000..21c2b240 --- /dev/null +++ b/tests/test-music-config--valid-directory-p.el @@ -0,0 +1,139 @@ +;;; test-music-config--valid-directory-p.el --- Tests for directory validation -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--valid-directory-p function. +;; Tests the pure helper that validates non-hidden directories. +;; +;; Test organization: +;; - Normal Cases: Valid visible directories +;; - Boundary Cases: Trailing slashes, dots in names, hidden directories +;; - Error Cases: Files (not dirs), nonexistent paths, nil input +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--valid-directory-p-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--valid-directory-p-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--valid-directory-p-normal-visible-directory-returns-true () + "Validate visible directory returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir"))) + (should (cj/music--valid-directory-p test-dir))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-normal-nested-directory-returns-true () + "Validate nested visible directory returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir/subdir/nested"))) + (should (cj/music--valid-directory-p test-dir))) + (test-music-config--valid-directory-p-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--valid-directory-p-boundary-trailing-slash-returns-true () + "Validate directory with trailing slash returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir"))) + (should (cj/music--valid-directory-p (file-name-as-directory test-dir)))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-no-trailing-slash-returns-true () + "Validate directory without trailing slash returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir"))) + (should (cj/music--valid-directory-p (directory-file-name test-dir)))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-dot-in-middle-returns-true () + "Validate directory with dot in middle of name returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "my.music.dir"))) + (should (cj/music--valid-directory-p test-dir))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-hidden-directory-returns-nil () + "Validate hidden directory (starting with dot) returns nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory ".hidden"))) + (should-not (cj/music--valid-directory-p test-dir))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-current-dir-dot-returns-nil () + "Validate current directory '.' returns nil (hidden)." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir"))) + ;; Change to test dir and check "." + (let ((default-directory test-dir)) + (should-not (cj/music--valid-directory-p ".")))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-parent-dir-dotdot-returns-nil () + "Validate parent directory '..' returns nil (hidden)." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir/subdir"))) + ;; Change to subdir and check ".." + (let ((default-directory test-dir)) + (should-not (cj/music--valid-directory-p "..")))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-hidden-subdir-basename-check () + "Validate hidden subdirectory returns nil based on basename." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((hidden-dir (cj/create-test-subdirectory "visible/.hidden"))) + (should-not (cj/music--valid-directory-p hidden-dir))) + (test-music-config--valid-directory-p-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--valid-directory-p-error-regular-file-returns-nil () + "Validate regular file (not directory) returns nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-file (cj/create-temp-test-file "testfile-"))) + (should-not (cj/music--valid-directory-p test-file))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-error-nonexistent-path-returns-nil () + "Validate nonexistent path returns nil." + (should-not (cj/music--valid-directory-p "/nonexistent/path/to/directory"))) + +(ert-deftest test-music-config--valid-directory-p-error-nil-input-returns-nil () + "Validate nil input returns nil gracefully." + (should-not (cj/music--valid-directory-p nil))) + +(ert-deftest test-music-config--valid-directory-p-error-empty-string-returns-nil () + "Validate empty string returns nil." + (should-not (cj/music--valid-directory-p ""))) + +(provide 'test-music-config--valid-directory-p) +;;; test-music-config--valid-directory-p.el ends here diff --git a/tests/test-music-config--valid-file-p.el b/tests/test-music-config--valid-file-p.el new file mode 100644 index 00000000..8099c50c --- /dev/null +++ b/tests/test-music-config--valid-file-p.el @@ -0,0 +1,99 @@ +;;; test-music-config--valid-file-p.el --- Tests for music file validation -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--valid-file-p function. +;; Tests the pure, deterministic helper that validates music file extensions. +;; +;; Test organization: +;; - Normal Cases: Valid music extensions (case-insensitive) +;; - Boundary Cases: Edge conditions (no extension, dots in path, empty strings) +;; - Error Cases: Invalid extensions, nil input +;; +;;; Code: + +(require 'ert) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Normal Cases + +(ert-deftest test-music-config--valid-file-p-normal-mp3-extension-returns-true () + "Validate mp3 file extension returns non-nil." + (should (cj/music--valid-file-p "/path/to/song.mp3"))) + +(ert-deftest test-music-config--valid-file-p-normal-flac-extension-returns-true () + "Validate flac file extension returns non-nil." + (should (cj/music--valid-file-p "/path/to/song.flac"))) + +(ert-deftest test-music-config--valid-file-p-normal-all-extensions-return-true () + "Validate all configured music extensions return non-nil." + ;; Test each extension from cj/music-file-extensions + (dolist (ext '("aac" "flac" "m4a" "mp3" "ogg" "opus" "wav")) + (should (cj/music--valid-file-p (format "/path/to/song.%s" ext))))) + +(ert-deftest test-music-config--valid-file-p-normal-uppercase-extension-returns-true () + "Validate uppercase extension returns non-nil (case-insensitive)." + (should (cj/music--valid-file-p "/path/to/song.MP3"))) + +(ert-deftest test-music-config--valid-file-p-normal-mixed-case-extension-returns-true () + "Validate mixed-case extension returns non-nil (case-insensitive)." + (should (cj/music--valid-file-p "/path/to/song.Mp3")) + (should (cj/music--valid-file-p "/path/to/song.FLaC"))) + +;;; Boundary Cases + +(ert-deftest test-music-config--valid-file-p-boundary-dots-in-path-returns-true () + "Validate file with dots in directory path uses only last extension." + (should (cj/music--valid-file-p "/path/with.dots/in.directory/song.mp3"))) + +(ert-deftest test-music-config--valid-file-p-boundary-multiple-extensions-uses-last () + "Validate file with multiple extensions uses rightmost extension." + (should (cj/music--valid-file-p "/path/to/song.backup.mp3")) + (should (cj/music--valid-file-p "/path/to/song.old.flac"))) + +(ert-deftest test-music-config--valid-file-p-boundary-just-filename-with-extension-returns-true () + "Validate bare filename without path returns non-nil." + (should (cj/music--valid-file-p "song.mp3"))) + +(ert-deftest test-music-config--valid-file-p-boundary-no-extension-returns-nil () + "Validate file without extension returns nil." + (should-not (cj/music--valid-file-p "/path/to/song"))) + +(ert-deftest test-music-config--valid-file-p-boundary-dot-at-end-returns-nil () + "Validate file ending with dot (empty extension) returns nil." + (should-not (cj/music--valid-file-p "/path/to/song."))) + +(ert-deftest test-music-config--valid-file-p-boundary-empty-string-returns-nil () + "Validate empty string returns nil." + (should-not (cj/music--valid-file-p ""))) + +;;; Error Cases + +(ert-deftest test-music-config--valid-file-p-error-nil-input-returns-nil () + "Validate nil input returns nil gracefully." + (should-not (cj/music--valid-file-p nil))) + +(ert-deftest test-music-config--valid-file-p-error-non-music-extension-returns-nil () + "Validate non-music file extension returns nil." + (should-not (cj/music--valid-file-p "/path/to/document.txt")) + (should-not (cj/music--valid-file-p "/path/to/readme.md"))) + +(ert-deftest test-music-config--valid-file-p-error-image-extension-returns-nil () + "Validate image file extension returns nil." + (should-not (cj/music--valid-file-p "/path/to/cover.jpg")) + (should-not (cj/music--valid-file-p "/path/to/artwork.png"))) + +(ert-deftest test-music-config--valid-file-p-error-video-extension-returns-nil () + "Validate video file extension returns nil (mp4 not in list, only m4a)." + (should-not (cj/music--valid-file-p "/path/to/video.mp4")) + (should-not (cj/music--valid-file-p "/path/to/clip.mkv"))) + +(provide 'test-music-config--valid-file-p) +;;; test-music-config--valid-file-p.el ends here diff --git a/tests/test-org-agenda-build-list.el b/tests/test-org-agenda-build-list.el new file mode 100644 index 00000000..6b424200 --- /dev/null +++ b/tests/test-org-agenda-build-list.el @@ -0,0 +1,294 @@ +;;; test-org-agenda-build-list.el --- Tests for cj/build-org-agenda-list -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/build-org-agenda-list caching logic. +;; Tests cache behavior, TTL expiration, force rebuild, and async build flag. + +;;; Code: + +(require 'ert) + +;; Add modules to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Stub dependencies before loading the module +(defvar inbox-file "/tmp/test-inbox.org") +(defvar schedule-file "/tmp/test-schedule.org") +(defvar gcal-file "/tmp/test-gcal.org") +(defvar projects-dir "/tmp/test-projects/") + +;; Now load the actual production module +(require 'org-agenda-config) + +;;; Setup and Teardown + +(defun test-org-agenda-setup () + "Reset cache and state before each test." + (setq cj/org-agenda-files-cache nil) + (setq cj/org-agenda-files-cache-time nil) + (setq cj/org-agenda-files-building nil) + (setq org-agenda-files nil)) + +(defun test-org-agenda-teardown () + "Clean up after each test." + (setq cj/org-agenda-files-cache nil) + (setq cj/org-agenda-files-cache-time nil) + (setq cj/org-agenda-files-building nil) + (setq org-agenda-files nil)) + +;;; Normal Cases + +(ert-deftest test-org-agenda-build-list-normal-first-call-builds-cache () + "Test that first call builds cache from scratch. + +When cache is empty, function should: +1. Scan directory for todo.org files +2. Build agenda files list +3. Populate cache +4. Set cache timestamp" + (test-org-agenda-setup) + (unwind-protect + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (_dir _pattern &optional _include-dirs) '("/tmp/project/todo.org")))) + + ;; Before call: cache empty + (should (null cj/org-agenda-files-cache)) + (should (null cj/org-agenda-files-cache-time)) + + ;; Build agenda files + (cj/build-org-agenda-list) + + ;; After call: cache populated + (should cj/org-agenda-files-cache) + (should cj/org-agenda-files-cache-time) + (should org-agenda-files) + + ;; Cache matches org-agenda-files + (should (equal cj/org-agenda-files-cache org-agenda-files)) + + ;; Contains base files (inbox, schedule, gcal) plus project files + (should (>= (length org-agenda-files) 3))) + (test-org-agenda-teardown))) + +(ert-deftest test-org-agenda-build-list-normal-second-call-uses-cache () + "Test that second call uses cache instead of rebuilding. + +When cache is valid (not expired): +1. Should NOT scan directories again +2. Should restore files from cache +3. Should NOT update cache timestamp" + (test-org-agenda-setup) + (unwind-protect + (let ((scan-count 0)) + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (_dir _pattern &optional _include-dirs) + (setq scan-count (1+ scan-count)) + '("/tmp/project/todo.org")))) + + ;; First call: builds cache + (cj/build-org-agenda-list) + (should (= scan-count 1)) ; 1 directory scanned + + (let ((cached-time cj/org-agenda-files-cache-time) + (cached-files cj/org-agenda-files-cache)) + + ;; Second call: uses cache + (cj/build-org-agenda-list) + + ;; Scan count unchanged (cache hit) + (should (= scan-count 1)) + + ;; Cache unchanged + (should (equal cj/org-agenda-files-cache-time cached-time)) + (should (equal cj/org-agenda-files-cache cached-files))))) + (test-org-agenda-teardown))) + +(ert-deftest test-org-agenda-build-list-normal-force-rebuild-bypasses-cache () + "Test that force-rebuild parameter bypasses cache. + +When force-rebuild is non-nil: +1. Should ignore valid cache +2. Should rebuild from scratch +3. Should update cache with new data" + (test-org-agenda-setup) + (unwind-protect + (let ((scan-count 0)) + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (_dir _pattern &optional _include-dirs) + (setq scan-count (1+ scan-count)) + (if (> scan-count 1) + '("/tmp/project/todo.org" "/tmp/project2/todo.org") ; New file on rebuild + '("/tmp/project/todo.org"))))) + + ;; First call: builds cache + (cj/build-org-agenda-list) + (let ((initial-count (length org-agenda-files))) + + ;; Force rebuild + (cj/build-org-agenda-list 'force) + + ;; Scanned again + (should (= scan-count 2)) + + ;; New files include additional project + (should (> (length org-agenda-files) initial-count))))) + (test-org-agenda-teardown))) + +;;; Boundary Cases + +(ert-deftest test-org-agenda-build-list-boundary-cache-expires-after-ttl () + "Test that cache expires after TTL period. + +When cache timestamp exceeds TTL: +1. Should rebuild files list +2. Should update cache timestamp +3. Should rescan directory" + (test-org-agenda-setup) + (unwind-protect + (let ((scan-count 0)) + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (_dir _pattern &optional _include-dirs) + (setq scan-count (1+ scan-count)) + '("/tmp/project/todo.org")))) + + ;; First call: builds cache + (cj/build-org-agenda-list) + (should (= scan-count 1)) + + ;; Simulate cache expiration (set time to 2 hours ago) + (setq cj/org-agenda-files-cache-time + (- (float-time) (* 2 3600))) + + ;; Second call: cache expired, rebuild + (cj/build-org-agenda-list) + + ;; Scanned again (cache was expired) + (should (= scan-count 2)) + + ;; Cache timestamp updated to current time + (should (< (- (float-time) cj/org-agenda-files-cache-time) 1)))) + (test-org-agenda-teardown))) + +(ert-deftest test-org-agenda-build-list-boundary-empty-directory-creates-minimal-list () + "Test behavior when directory contains no todo.org files. + +When directory scan returns empty: +1. Should still create base files (inbox, schedule) +2. Should not fail or error +3. Should cache the minimal result" + (test-org-agenda-setup) + (unwind-protect + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (_dir _pattern &optional _include-dirs) nil))) ; No files found + + (cj/build-org-agenda-list) + + ;; Should have base files only (inbox, schedule, gcal) + (should (= (length org-agenda-files) 3)) + + ;; Cache should contain base files + (should cj/org-agenda-files-cache) + (should (= (length cj/org-agenda-files-cache) 3))) + (test-org-agenda-teardown))) + +(ert-deftest test-org-agenda-build-list-boundary-building-flag-set-during-build () + "Test that building flag is set during build and cleared after. + +During build: +1. Flag should be set to prevent concurrent builds +2. Flag should clear even if build fails +3. Flag state should be consistent" + (test-org-agenda-setup) + (unwind-protect + (let ((flag-during-build nil)) + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (_dir _pattern &optional _include-dirs) + ;; Capture flag state during directory scan + (setq flag-during-build cj/org-agenda-files-building) + '("/tmp/project/todo.org")))) + + ;; Before build + (should (null cj/org-agenda-files-building)) + + ;; Build + (cj/build-org-agenda-list) + + ;; Flag was set during build + (should flag-during-build) + + ;; Flag cleared after build + (should (null cj/org-agenda-files-building)))) + (test-org-agenda-teardown))) + +(ert-deftest test-org-agenda-build-list-boundary-building-flag-clears-on-error () + "Test that building flag clears even if build errors. + +When build encounters error: +1. Flag should still be cleared (unwind-protect) +2. Prevents permanently locked state +3. Next build can proceed" + (test-org-agenda-setup) + (unwind-protect + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (_dir _pattern &optional _include-dirs) + (error "Simulated scan failure")))) + + ;; Build will error + (should-error (cj/build-org-agenda-list)) + + ;; Flag cleared despite error (unwind-protect) + (should (null cj/org-agenda-files-building))) + (test-org-agenda-teardown))) + +;;; Error Cases + +(ert-deftest test-org-agenda-build-list-error-nil-cache-with-old-timestamp () + "Test handling of inconsistent state (nil cache but timestamp set). + +When cache is nil but timestamp exists: +1. Should recognize cache as invalid +2. Should rebuild files list +3. Should set both cache and timestamp" + (test-org-agenda-setup) + (unwind-protect + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (_dir _pattern &optional _include-dirs) '("/tmp/project/todo.org")))) + + ;; Set inconsistent state + (setq cj/org-agenda-files-cache nil) + (setq cj/org-agenda-files-cache-time (float-time)) + + ;; Build should recognize invalid state + (cj/build-org-agenda-list) + + ;; Cache now populated + (should cj/org-agenda-files-cache) + (should cj/org-agenda-files-cache-time) + (should org-agenda-files)) + (test-org-agenda-teardown))) + +(ert-deftest test-org-agenda-build-list-error-directory-scan-failure-propagates () + "Test that directory scan failures propagate as errors. + +When directory-files-recursively errors: +1. Error should propagate to caller +2. Cache should not be corrupted +3. Building flag should clear" + (test-org-agenda-setup) + (unwind-protect + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (_dir _pattern &optional _include-dirs) + (error "Permission denied")))) + + ;; Should propagate error + (should-error (cj/build-org-agenda-list)) + + ;; Cache not corrupted (still nil) + (should (null cj/org-agenda-files-cache)) + + ;; Building flag cleared + (should (null cj/org-agenda-files-building))) + (test-org-agenda-teardown))) + +(provide 'test-org-agenda-build-list) +;;; test-org-agenda-build-list.el ends here diff --git a/tests/test-org-contacts-capture-finalize.el b/tests/test-org-contacts-capture-finalize.el new file mode 100644 index 00000000..6793defe --- /dev/null +++ b/tests/test-org-contacts-capture-finalize.el @@ -0,0 +1,178 @@ +;;; test-org-contacts-capture-finalize.el --- Tests for org-contacts capture template finalization -*- lexical-binding: t; -*- + +;; Copyright (C) 2025 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;;; Commentary: + +;; Unit tests for the org-contacts capture template finalization function +;; that automatically inserts birthday timestamps. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'org) + +;; 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 contacts-file "/tmp/test-contacts.org" + "Stub contacts file for testing.") + +;; Declare org-capture-plist for dynamic scoping in tests +(defvar org-capture-plist nil + "Plist that org-capture uses during capture. Declared for testing.") + +;; Load the actual module +(require 'org-contacts-config) + +;;; Tests for birthday timestamp finalization + +(ert-deftest test-contacts-capture-finalize-with-full-birthday () + "Test that finalize adds timestamp for YYYY-MM-DD birthday." + (with-temp-buffer + (org-mode) + (insert "* Alice Anderson\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: alice@example.com\n") + (insert ":BIRTHDAY: 1985-03-15\n") + (insert ":END:\n") + (insert "Added: [2025-11-01 Fri 20:30]\n") + + ;; Simulate capture context + (let ((org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + (let ((content (buffer-string))) + ;; Should have birthday timestamp + (should (string-match-p "<1985-03-15 [A-Za-z]\\{3\\} \\+1y>" content)) + ;; Timestamp should be after :END: + (should (string-match-p ":END:\n<1985-03-15" content)))))) + +(ert-deftest test-contacts-capture-finalize-with-partial-birthday () + "Test that finalize adds timestamp for MM-DD birthday with current year." + (let ((current-year (nth 5 (decode-time)))) + (with-temp-buffer + (org-mode) + (insert "* Bob Baker\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 07-04\n") + (insert ":END:\n") + + (let ((org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + (let ((content (buffer-string))) + ;; Should have birthday timestamp with current year + (should (string-match-p (format "<%d-07-04 [A-Za-z]\\{3\\} \\+1y>" current-year) content))))))) + +(ert-deftest test-contacts-capture-finalize-without-birthday () + "Test that finalize does nothing when no birthday property." + (with-temp-buffer + (org-mode) + (insert "* Carol Chen\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: carol@example.com\n") + (insert ":END:\n") + + (let ((original-content (buffer-string)) + (org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + ;; Content should be unchanged + (should (string= (buffer-string) original-content)) + ;; Should have no timestamp + (should-not (string-match-p "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}" (buffer-string)))))) + +(ert-deftest test-contacts-capture-finalize-with-empty-birthday () + "Test that finalize skips empty birthday values." + (with-temp-buffer + (org-mode) + (insert "* David Davis\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: \n") + (insert ":END:\n") + + (let ((original-content (buffer-string)) + (org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + ;; Content should be unchanged + (should (string= (buffer-string) original-content)) + ;; Should have no timestamp + (should-not (string-match-p "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}" (buffer-string)))))) + +(ert-deftest test-contacts-capture-finalize-prevents-duplicates () + "Test that finalize doesn't add duplicate timestamps." + (with-temp-buffer + (org-mode) + (insert "* Eve Evans\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert ":END:\n") + (insert "<2000-01-01 Sat +1y>\n") + + (let ((org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + ;; Should have exactly one timestamp + (should (= 1 (how-many "<2000-01-01 [A-Za-z]\\{3\\} \\+1y>" (point-min) (point-max))))))) + +(ert-deftest test-contacts-capture-finalize-only-for-contact-template () + "Test that finalize only runs for 'C' template key." + (with-temp-buffer + (org-mode) + (insert "* Task with birthday property\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert ":END:\n") + + (let ((original-content (buffer-string)) + (org-capture-plist '(:key "t"))) ; Different template key + (cj/org-contacts-finalize-birthday-timestamp) + + ;; Content should be unchanged + (should (string= (buffer-string) original-content))))) + +(ert-deftest test-contacts-capture-finalize-preserves-existing-content () + "Test that finalize preserves all existing content." + (with-temp-buffer + (org-mode) + (insert "* Alice Anderson\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: alice@example.com\n") + (insert ":PHONE: 555-1234\n") + (insert ":BIRTHDAY: 1985-03-15\n") + (insert ":NICKNAME: Ali\n") + (insert ":NOTE: Met at conference\n") + (insert ":END:\n") + (insert "Added: [2025-11-01 Fri 20:30]\n") + + (let ((org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + (let ((content (buffer-string))) + ;; All properties should still be present + (should (string-search ":EMAIL: alice@example.com" content)) + (should (string-search ":PHONE: 555-1234" content)) + (should (string-search ":BIRTHDAY: 1985-03-15" content)) + (should (string-search ":NICKNAME: Ali" content)) + (should (string-search ":NOTE: Met at conference" content)) + ;; Added timestamp should still be there + (should (string-search "Added: [2025-11-01 Fri 20:30]" content)) + ;; Birthday timestamp should be added + (should (string-match-p "<1985-03-15 [A-Za-z]\\{3\\} \\+1y>" content)))))) + +(provide 'test-org-contacts-capture-finalize) +;;; test-org-contacts-capture-finalize.el ends here diff --git a/tests/test-org-contacts-parse-email.el b/tests/test-org-contacts-parse-email.el new file mode 100644 index 00000000..37e79fba --- /dev/null +++ b/tests/test-org-contacts-parse-email.el @@ -0,0 +1,219 @@ +;;; test-org-contacts-parse-email.el --- Tests for cj/--parse-email-string -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--parse-email-string function from org-contacts-config.el +;; +;; This function parses a string containing one or more email addresses +;; separated by commas, semicolons, or spaces, and formats them as +;; "Name <email>" strings. +;; +;; Examples: +;; Input: name="John Doe", email-string="john@example.com" +;; Output: '("John Doe <john@example.com>") +;; +;; Input: name="Jane Smith", email-string="jane@work.com, jane@home.com" +;; Output: '("Jane Smith <jane@work.com>" "Jane Smith <jane@home.com>") + +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Now load the actual production module +(require 'org-contacts-config) + +;;; Test Helpers + +(defun test-parse-email (name email-string) + "Test cj/--parse-email-string with NAME and EMAIL-STRING. +Returns the formatted email list." + (cj/--parse-email-string name email-string)) + +;;; Normal Cases - Single Email + +(ert-deftest test-parse-single-email () + "Should format single email address." + (let ((result (test-parse-email "John Doe" "john@example.com"))) + (should (equal result '("John Doe <john@example.com>"))))) + +(ert-deftest test-parse-single-email-with-subdomain () + "Should handle email with subdomain." + (let ((result (test-parse-email "Jane Smith" "jane@mail.company.com"))) + (should (equal result '("Jane Smith <jane@mail.company.com>"))))) + +(ert-deftest test-parse-email-with-numbers () + "Should handle email containing numbers." + (let ((result (test-parse-email "User 123" "user123@test.com"))) + (should (equal result '("User 123 <user123@test.com>"))))) + +(ert-deftest test-parse-email-with-dots () + "Should handle email with dots in local part." + (let ((result (test-parse-email "Bob Jones" "bob.jones@example.com"))) + (should (equal result '("Bob Jones <bob.jones@example.com>"))))) + +(ert-deftest test-parse-email-with-hyphen () + "Should handle email with hyphens." + (let ((result (test-parse-email "Alice Brown" "alice-brown@test-domain.com"))) + (should (equal result '("Alice Brown <alice-brown@test-domain.com>"))))) + +;;; Normal Cases - Multiple Emails with Different Separators + +(ert-deftest test-parse-two-emails-comma () + "Should parse two emails separated by comma." + (let ((result (test-parse-email "John Doe" "john@work.com, john@home.com"))) + (should (equal result '("John Doe <john@work.com>" "John Doe <john@home.com>"))))) + +(ert-deftest test-parse-two-emails-semicolon () + "Should parse two emails separated by semicolon." + (let ((result (test-parse-email "Jane Smith" "jane@work.com; jane@home.com"))) + (should (equal result '("Jane Smith <jane@work.com>" "Jane Smith <jane@home.com>"))))) + +(ert-deftest test-parse-two-emails-space () + "Should parse two emails separated by space." + (let ((result (test-parse-email "Bob Jones" "bob@work.com bob@home.com"))) + (should (equal result '("Bob Jones <bob@work.com>" "Bob Jones <bob@home.com>"))))) + +(ert-deftest test-parse-three-emails-mixed-separators () + "Should parse emails with mixed separators." + (let ((result (test-parse-email "Alice" "alice@a.com, alice@b.com; alice@c.com"))) + (should (equal result '("Alice <alice@a.com>" "Alice <alice@b.com>" "Alice <alice@c.com>"))))) + +(ert-deftest test-parse-multiple-emails-with-spaces () + "Should parse comma-separated emails with spaces." + (let ((result (test-parse-email "User" "a@test.com , b@test.com , c@test.com"))) + (should (equal result '("User <a@test.com>" "User <b@test.com>" "User <c@test.com>"))))) + +;;; Normal Cases - Whitespace Handling + +(ert-deftest test-parse-email-leading-whitespace () + "Should trim leading whitespace from email." + (let ((result (test-parse-email "John" " john@example.com"))) + (should (equal result '("John <john@example.com>"))))) + +(ert-deftest test-parse-email-trailing-whitespace () + "Should trim trailing whitespace from email." + (let ((result (test-parse-email "Jane" "jane@example.com "))) + (should (equal result '("Jane <jane@example.com>"))))) + +(ert-deftest test-parse-email-surrounding-whitespace () + "Should trim surrounding whitespace from email." + (let ((result (test-parse-email "Bob" " bob@example.com "))) + (should (equal result '("Bob <bob@example.com>"))))) + +(ert-deftest test-parse-emails-with-tabs () + "Should handle emails separated by tabs." + (let ((result (test-parse-email "User" "a@test.com\tb@test.com"))) + (should (equal result '("User <a@test.com>" "User <b@test.com>"))))) + +;;; Edge Cases - Empty and Nil + +(ert-deftest test-parse-nil-email-string () + "Should return nil for nil email string." + (let ((result (test-parse-email "John Doe" nil))) + (should (null result)))) + +(ert-deftest test-parse-empty-email-string () + "Should return nil for empty email string." + (let ((result (test-parse-email "Jane Smith" ""))) + (should (null result)))) + +(ert-deftest test-parse-whitespace-only () + "Should return nil for whitespace-only string." + (let ((result (test-parse-email "Bob Jones" " "))) + (should (null result)))) + +(ert-deftest test-parse-tabs-only () + "Should return nil for tabs-only string." + (let ((result (test-parse-email "Alice" "\t\t\t"))) + (should (null result)))) + +(ert-deftest test-parse-mixed-whitespace-only () + "Should return nil for mixed whitespace." + (let ((result (test-parse-email "User" " \t \n "))) + (should (null result)))) + +;;; Edge Cases - Multiple Consecutive Separators + +(ert-deftest test-parse-multiple-commas () + "Should handle multiple consecutive commas." + (let ((result (test-parse-email "John" "john@a.com,,,john@b.com"))) + (should (equal result '("John <john@a.com>" "John <john@b.com>"))))) + +(ert-deftest test-parse-multiple-semicolons () + "Should handle multiple consecutive semicolons." + (let ((result (test-parse-email "Jane" "jane@a.com;;;jane@b.com"))) + (should (equal result '("Jane <jane@a.com>" "Jane <jane@b.com>"))))) + +(ert-deftest test-parse-multiple-spaces () + "Should handle multiple consecutive spaces." + (let ((result (test-parse-email "Bob" "bob@a.com bob@b.com"))) + (should (equal result '("Bob <bob@a.com>" "Bob <bob@b.com>"))))) + +(ert-deftest test-parse-mixed-multiple-separators () + "Should handle mixed consecutive separators." + (let ((result (test-parse-email "User" "a@test.com , ; b@test.com"))) + (should (equal result '("User <a@test.com>" "User <b@test.com>"))))) + +;;; Edge Cases - Special Name Formats + +(ert-deftest test-parse-name-with-title () + "Should handle name with title." + (let ((result (test-parse-email "Dr. John Smith" "john@example.com"))) + (should (equal result '("Dr. John Smith <john@example.com>"))))) + +(ert-deftest test-parse-name-with-suffix () + "Should handle name with suffix." + (let ((result (test-parse-email "John Doe Jr." "john@example.com"))) + (should (equal result '("John Doe Jr. <john@example.com>"))))) + +(ert-deftest test-parse-name-with-special-chars () + "Should handle name with special characters." + (let ((result (test-parse-email "O'Brien, Patrick" "patrick@example.com"))) + (should (equal result '("O'Brien, Patrick <patrick@example.com>"))))) + +(ert-deftest test-parse-unicode-name () + "Should handle Unicode characters in name." + (let ((result (test-parse-email "José García" "jose@example.com"))) + (should (equal result '("José García <jose@example.com>"))))) + +;;; Edge Cases - Special Email Formats + +(ert-deftest test-parse-email-with-plus () + "Should handle email with plus sign." + (let ((result (test-parse-email "User" "user+tag@example.com"))) + (should (equal result '("User <user+tag@example.com>"))))) + +(ert-deftest test-parse-email-with-underscore () + "Should handle email with underscore." + (let ((result (test-parse-email "User" "user_name@example.com"))) + (should (equal result '("User <user_name@example.com>"))))) + +(ert-deftest test-parse-very-long-email () + "Should handle very long email address." + (let* ((long-local (make-string 50 ?a)) + (email (concat long-local "@example.com")) + (result (test-parse-email "User" email))) + (should (equal result (list (format "User <%s>" email)))))) + +;;; Integration Tests + +(ert-deftest test-parse-realistic-contact () + "Should parse realistic contact with multiple emails." + (let ((result (test-parse-email "John Doe" "john.doe@company.com, jdoe@personal.com"))) + (should (equal result '("John Doe <john.doe@company.com>" "John Doe <jdoe@personal.com>"))))) + +(ert-deftest test-parse-messy-input () + "Should handle messy real-world input." + (let ((result (test-parse-email "Jane Smith" " jane@work.com ; jane@home.com,jane@mobile.com "))) + (should (equal result '("Jane Smith <jane@work.com>" "Jane Smith <jane@home.com>" "Jane Smith <jane@mobile.com>"))))) + +(ert-deftest test-parse-single-with-extra-separators () + "Should handle single email with trailing separators." + (let ((result (test-parse-email "Bob" "bob@example.com;;;"))) + (should (equal result '("Bob <bob@example.com>"))))) + +(provide 'test-org-contacts-parse-email) +;;; test-org-contacts-parse-email.el ends here diff --git a/tests/test-org-drill-first-function.el b/tests/test-org-drill-first-function.el new file mode 100644 index 00000000..925cdf84 --- /dev/null +++ b/tests/test-org-drill-first-function.el @@ -0,0 +1,135 @@ +;;; test-org-drill-first-function.el --- Test org-drill 'first' function compatibility -*- lexical-binding: t -*- + +;;; Commentary: +;; +;; Tests to reproduce and verify the fix for org-drill's use of deprecated +;; 'first' function which was removed in modern Emacs. +;; +;; Original error: "mapcar: Symbol's function definition is void: first" +;; +;; The error occurred because org-drill (or its dependencies) use old Common Lisp +;; functions like 'first' instead of the modern 'cl-first' from cl-lib. + +;;; Code: + +(require 'ert) + +(ert-deftest test-org-drill-first-function-not-defined-without-compat () + "Verify that 'first' function doesn't exist by default in modern Emacs. + +This test documents the original problem - the 'first' function from the +old 'cl' package is not available in modern Emacs, which only provides +'cl-first' from cl-lib." + (let ((first-defined (fboundp 'first))) + ;; In a clean Emacs without our compatibility shim, 'first' should not exist + ;; (unless the old 'cl' package was loaded, which is deprecated) + (should (or (not first-defined) + ;; If it IS defined, it should be our compatibility alias + (eq (symbol-function 'first) 'cl-first))))) + +(ert-deftest test-org-drill-cl-first-is-available () + "Verify that cl-first is available from cl-lib. + +The modern cl-lib package provides cl-first as the replacement for +the deprecated 'first' function." + (require 'cl-lib) + (should (fboundp 'cl-first)) + ;; Test it works + (should (eq 'a (cl-first '(a b c))))) + +(ert-deftest test-org-drill-first-compatibility-alias () + "Verify that our compatibility alias makes 'first' work like 'cl-first'. + +This is the fix we applied - creating an alias so that code using the +old 'first' function will work with the modern 'cl-first'." + (require 'cl-lib) + + ;; Create the compatibility alias (same as in org-drill-config.el) + (unless (fboundp 'first) + (defalias 'first 'cl-first)) + + ;; Now 'first' should be defined + (should (fboundp 'first)) + + ;; And it should behave like cl-first + (should (eq 'a (first '(a b c)))) + (should (eq 'x (first '(x y z)))) + (should (eq nil (first '())))) + +(ert-deftest test-org-drill-mapcar-with-first () + "Test the exact error scenario: (mapcar 'first ...). + +This reproduces the original error that occurred during org-drill's +item collection phase where it uses mapcar with the 'first' function." + (require 'cl-lib) + + ;; Create the compatibility alias + (unless (fboundp 'first) + (defalias 'first 'cl-first)) + + ;; Simulate org-drill data structure: list of (status data) pairs + (let ((drill-entries '((:new 0 0) + (:young 5 3) + (:overdue 10 2) + (:mature 20 1)))) + + ;; This is the kind of operation that was failing + ;; Extract first element from each entry + (let ((statuses (mapcar 'first drill-entries))) + (should (equal statuses '(:new :young :overdue :mature)))))) + +(ert-deftest test-org-drill-second-and-third-aliases () + "Verify that second and third compatibility aliases also work. + +org-drill might use other deprecated cl functions too, so we create +aliases for second and third as well." + (require 'cl-lib) + + ;; Create all compatibility aliases + (unless (fboundp 'first) + (defalias 'first 'cl-first)) + (unless (fboundp 'second) + (defalias 'second 'cl-second)) + (unless (fboundp 'third) + (defalias 'third 'cl-third)) + + (let ((test-list '(a b c d e))) + (should (eq 'a (first test-list))) + (should (eq 'b (second test-list))) + (should (eq 'c (third test-list))))) + +(ert-deftest test-org-drill-config-loads-without-error () + "Verify that org-drill-config.el loads successfully with our fix. + +This test ensures that the :init block in our use-package form +doesn't cause any loading errors." + ;; This should not throw an error + (should-not (condition-case err + (progn + (load (expand-file-name "modules/org-drill-config.el" + user-emacs-directory)) + nil) + (error err)))) + +(ert-deftest test-org-drill-data-structure-operations () + "Verify that common org-drill data structure operations work with our fix. + +org-drill works with data structures that require extracting elements. +This test ensures our compatibility aliases work with typical patterns." + (require 'cl-lib) + + ;; Create compatibility aliases + (unless (fboundp 'first) + (defalias 'first 'cl-first)) + + ;; Test that we can work with org-drill-like data structures + ;; (similar to what persist-defvar would store) + (let ((test-data '((:status-1 data-1) + (:status-2 data-2) + (:status-3 data-3)))) + ;; This kind of operation should work + (should (equal '(:status-1 :status-2 :status-3) + (mapcar 'first test-data))))) + +(provide 'test-org-drill-first-function) +;;; test-org-drill-first-function.el ends here diff --git a/tests/test-org-drill-font-switching.el b/tests/test-org-drill-font-switching.el new file mode 100644 index 00000000..27d5f420 --- /dev/null +++ b/tests/test-org-drill-font-switching.el @@ -0,0 +1,175 @@ +;;; test-org-drill-font-switching.el --- Tests for org-drill display management -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests that org-drill automatically manages display settings (fonts, modeline) +;; and restores them when the session ends. +;; +;; These are unit tests for the pure logic functions, testing them in isolation +;; without requiring the full org-drill package. + +;;; Code: + +(require 'ert) + +;; Define the functions we're testing (extracted from org-drill-config.el) + +(defvar cj/org-drill-previous-preset nil + "Stores the font preset active before starting org-drill.") + +(defvar cj/org-drill-previous-modeline-format nil + "Stores the modeline format active before starting org-drill.") + +(defvar fontaine-current-preset nil + "Current fontaine preset (mocked for testing).") + +(defvar mode-line-format '("Mock modeline") + "Mock modeline format for testing.") + +(defvar org-drill-hide-modeline-during-session t + "Whether to hide modeline during drill sessions.") + +(defun fontaine-set-preset (preset) + "Mock function: Set fontaine preset to PRESET." + (setq fontaine-current-preset preset)) + +(defun cj/org-drill-setup-display () + "Set up display for drill sessions: larger fonts and hidden modeline." + (unless cj/org-drill-previous-preset + (setq cj/org-drill-previous-preset fontaine-current-preset)) + (fontaine-set-preset 'EBook) + (when org-drill-hide-modeline-during-session + (unless cj/org-drill-previous-modeline-format + (setq cj/org-drill-previous-modeline-format mode-line-format)) + (setq mode-line-format nil))) + +(defun cj/org-drill-restore-display () + "Restore display settings after drill session ends." + (when cj/org-drill-previous-preset + (fontaine-set-preset cj/org-drill-previous-preset) + (setq cj/org-drill-previous-preset nil)) + (when cj/org-drill-previous-modeline-format + (setq mode-line-format cj/org-drill-previous-modeline-format) + (setq cj/org-drill-previous-modeline-format nil))) + +;;; Font Management Tests + +(ert-deftest test-org-drill-display/saves-current-preset () + "Test that starting org-drill saves the current font preset." + (let ((cj/org-drill-previous-preset nil) + (cj/org-drill-previous-modeline-format nil) + (fontaine-current-preset 'default)) + (cj/org-drill-setup-display) + (should (eq cj/org-drill-previous-preset 'default)))) + +(ert-deftest test-org-drill-display/switches-to-ebook () + "Test that starting org-drill switches to EBook preset." + (let ((cj/org-drill-previous-preset nil) + (cj/org-drill-previous-modeline-format nil) + (fontaine-current-preset 'default)) + (cj/org-drill-setup-display) + (should (eq fontaine-current-preset 'EBook)))) + +(ert-deftest test-org-drill-display/restores-previous-preset () + "Test that ending org-drill restores the previous font preset." + (let ((cj/org-drill-previous-preset 'default) + (cj/org-drill-previous-modeline-format nil) + (fontaine-current-preset 'EBook)) + (cj/org-drill-restore-display) + (should (eq fontaine-current-preset 'default)))) + +(ert-deftest test-org-drill-display/clears-saved-preset-after-restore () + "Test that restoring display clears the saved preset." + (let ((cj/org-drill-previous-preset 'default) + (cj/org-drill-previous-modeline-format nil) + (fontaine-current-preset 'EBook)) + (cj/org-drill-restore-display) + (should (null cj/org-drill-previous-preset)))) + +;;; Modeline Management Tests + +(ert-deftest test-org-drill-display/hides-modeline () + "Test that starting org-drill hides the modeline when configured." + (let ((cj/org-drill-previous-preset nil) + (cj/org-drill-previous-modeline-format nil) + (fontaine-current-preset 'default) + (mode-line-format '("Mock modeline")) + (org-drill-hide-modeline-during-session t)) + (cj/org-drill-setup-display) + (should (null mode-line-format)) + (should (equal cj/org-drill-previous-modeline-format '("Mock modeline"))))) + +(ert-deftest test-org-drill-display/respects-modeline-config () + "Test that modeline hiding respects the configuration variable." + (let ((cj/org-drill-previous-preset nil) + (cj/org-drill-previous-modeline-format nil) + (fontaine-current-preset 'default) + (mode-line-format '("Mock modeline")) + (org-drill-hide-modeline-during-session nil)) + (cj/org-drill-setup-display) + (should (equal mode-line-format '("Mock modeline"))) + (should (null cj/org-drill-previous-modeline-format)))) + +(ert-deftest test-org-drill-display/restores-modeline () + "Test that ending org-drill restores the modeline." + (let ((cj/org-drill-previous-preset 'default) + (cj/org-drill-previous-modeline-format '("Mock modeline")) + (fontaine-current-preset 'EBook) + (mode-line-format nil)) + (cj/org-drill-restore-display) + (should (equal mode-line-format '("Mock modeline"))) + (should (null cj/org-drill-previous-modeline-format)))) + +;;; Boundary Cases + +(ert-deftest test-org-drill-display/does-not-save-preset-twice () + "Test that calling setup twice doesn't overwrite the saved preset." + (let ((cj/org-drill-previous-preset nil) + (cj/org-drill-previous-modeline-format nil) + (fontaine-current-preset 'default)) + ;; First call saves 'default + (cj/org-drill-setup-display) + (should (eq cj/org-drill-previous-preset 'default)) + + ;; Manually change current preset (simulating a preset change during drill) + (setq fontaine-current-preset 'FiraCode) + + ;; Second call should NOT update saved preset + (cj/org-drill-setup-display) + (should (eq cj/org-drill-previous-preset 'default)) + (should-not (eq cj/org-drill-previous-preset 'FiraCode)))) + +(ert-deftest test-org-drill-display/restore-with-nil-previous-preset () + "Test that restore does nothing when no preset was saved." + (let ((cj/org-drill-previous-preset nil) + (cj/org-drill-previous-modeline-format nil) + (fontaine-current-preset 'EBook)) + (cj/org-drill-restore-display) + ;; Should remain at EBook (no restore happened) + (should (eq fontaine-current-preset 'EBook)) + (should (null cj/org-drill-previous-preset)))) + +;;; Integration Tests + +(ert-deftest test-org-drill-display/full-cycle () + "Test complete cycle: save -> switch -> restore." + (let ((cj/org-drill-previous-preset nil) + (cj/org-drill-previous-modeline-format nil) + (fontaine-current-preset 'FiraCode) + (mode-line-format '("Original modeline")) + (org-drill-hide-modeline-during-session t)) + ;; Step 1: Start drill (save state, switch to EBook, hide modeline) + (cj/org-drill-setup-display) + (should (eq cj/org-drill-previous-preset 'FiraCode)) + (should (eq fontaine-current-preset 'EBook)) + (should (equal cj/org-drill-previous-modeline-format '("Original modeline"))) + (should (null mode-line-format)) + + ;; Step 2: End drill (restore everything) + (cj/org-drill-restore-display) + (should (eq fontaine-current-preset 'FiraCode)) + (should (null cj/org-drill-previous-preset)) + (should (equal mode-line-format '("Original modeline"))) + (should (null cj/org-drill-previous-modeline-format)))) + +(provide 'test-org-drill-font-switching) +;;; test-org-drill-font-switching.el ends here diff --git a/tests/test-org-noter--generate-notes-template.el b/tests/test-org-noter--generate-notes-template.el new file mode 100644 index 00000000..df545ccf --- /dev/null +++ b/tests/test-org-noter--generate-notes-template.el @@ -0,0 +1,109 @@ +;;; test-org-noter--generate-notes-template.el --- Tests for cj/org-noter--generate-notes-template -*- lexical-binding: t; -*- + +;;; Commentary: +;; ERT tests for the generate-notes-template function used in org-noter workflow. +;; Tests cover normal, boundary, and error cases. + +;;; Code: + +(require 'ert) +(require 'org-noter-config) + +;;; Test Helpers + +(defun test-org-noter--template-has-property (template property value) + "Check if TEMPLATE contains PROPERTY with VALUE in properties drawer." + (string-match-p (format ":%s: %s" property (regexp-quote value)) template)) + +(defun test-org-noter--template-has-keyword (template keyword value) + "Check if TEMPLATE contains #+KEYWORD: VALUE." + (string-match-p (format "#\\+%s: %s" keyword (regexp-quote value)) template)) + +;;; Normal Cases + +(ert-deftest test-org-noter--generate-notes-template-normal-basic () + "Normal case: Basic template generation." + (let ((template (cj/org-noter--generate-notes-template "Test Book" "/path/to/book.pdf"))) + (should (stringp template)) + (should (string-match-p ":PROPERTIES:" template)) + (should (string-match-p ":END:" template)) + (should (string-match-p "\\* Notes" template)))) + +(ert-deftest test-org-noter--generate-notes-template-normal-has-id () + "Normal case: Template has ID property." + (let ((template (cj/org-noter--generate-notes-template "Test Book" "/path/to/book.pdf"))) + (should (string-match-p ":ID: [a-f0-9-]+" template)))) + +(ert-deftest test-org-noter--generate-notes-template-normal-has-noter-document () + "Normal case: Template has NOTER_DOCUMENT property." + (let ((template (cj/org-noter--generate-notes-template "Test Book" "/path/to/book.pdf"))) + (should (test-org-noter--template-has-property template "NOTER_DOCUMENT" "/path/to/book.pdf")))) + +(ert-deftest test-org-noter--generate-notes-template-normal-has-roam-refs () + "Normal case: Template has ROAM_REFS property." + (let ((template (cj/org-noter--generate-notes-template "Test Book" "/path/to/book.pdf"))) + (should (test-org-noter--template-has-property template "ROAM_REFS" "/path/to/book.pdf")))) + +(ert-deftest test-org-noter--generate-notes-template-normal-has-title () + "Normal case: Template has title with book name." + (let ((template (cj/org-noter--generate-notes-template "The Great Gatsby" "/books/gatsby.epub"))) + (should (test-org-noter--template-has-keyword template "title" "Notes on The Great Gatsby")))) + +(ert-deftest test-org-noter--generate-notes-template-normal-has-filetags () + "Normal case: Template has ReadingNotes filetag." + (let ((template (cj/org-noter--generate-notes-template "Test Book" "/path/to/book.pdf"))) + (should (test-org-noter--template-has-keyword template "FILETAGS" ":ReadingNotes:")))) + +(ert-deftest test-org-noter--generate-notes-template-normal-has-category () + "Normal case: Template has CATEGORY set to book title." + (let ((template (cj/org-noter--generate-notes-template "Clean Code" "/books/clean-code.pdf"))) + (should (test-org-noter--template-has-keyword template "CATEGORY" "Clean Code")))) + +;;; Boundary Cases + +(ert-deftest test-org-noter--generate-notes-template-boundary-long-title () + "Boundary case: Very long title." + (let* ((long-title "This Is An Incredibly Long Book Title That Goes On And On") + (template (cj/org-noter--generate-notes-template long-title "/books/long.pdf"))) + (should (test-org-noter--template-has-keyword template "title" (format "Notes on %s" long-title))) + (should (test-org-noter--template-has-keyword template "CATEGORY" long-title)))) + +(ert-deftest test-org-noter--generate-notes-template-boundary-special-chars-in-title () + "Boundary case: Special characters in title." + (let ((template (cj/org-noter--generate-notes-template "C++: A Guide" "/books/cpp.pdf"))) + (should (test-org-noter--template-has-keyword template "title" "Notes on C++: A Guide")))) + +(ert-deftest test-org-noter--generate-notes-template-boundary-special-chars-in-path () + "Boundary case: Special characters in path." + (let ((template (cj/org-noter--generate-notes-template "Test" "/path/with spaces/book.pdf"))) + (should (test-org-noter--template-has-property template "NOTER_DOCUMENT" "/path/with spaces/book.pdf")))) + +(ert-deftest test-org-noter--generate-notes-template-boundary-epub-path () + "Boundary case: EPUB file path." + (let ((template (cj/org-noter--generate-notes-template "Novel" "/library/novel.epub"))) + (should (test-org-noter--template-has-property template "NOTER_DOCUMENT" "/library/novel.epub")))) + +;;; Structure Tests + +(ert-deftest test-org-noter--generate-notes-template-structure-properties-first () + "Structure: Properties drawer comes first." + (let ((template (cj/org-noter--generate-notes-template "Test" "/path.pdf"))) + (should (string-match "\\`:PROPERTIES:" template)))) + +(ert-deftest test-org-noter--generate-notes-template-structure-notes-heading () + "Structure: Has Notes heading for content." + (let ((template (cj/org-noter--generate-notes-template "Test" "/path.pdf"))) + (should (string-match-p "^\\* Notes$" template)))) + +(ert-deftest test-org-noter--generate-notes-template-structure-unique-ids () + "Structure: Each call generates unique ID." + (let ((template1 (cj/org-noter--generate-notes-template "Test1" "/path1.pdf")) + (template2 (cj/org-noter--generate-notes-template "Test2" "/path2.pdf"))) + (string-match ":ID: \\([a-f0-9-]+\\)" template1) + (let ((id1 (match-string 1 template1))) + (string-match ":ID: \\([a-f0-9-]+\\)" template2) + (let ((id2 (match-string 1 template2))) + (should-not (equal id1 id2)))))) + +(provide 'test-org-noter--generate-notes-template) +;;; test-org-noter--generate-notes-template.el ends here diff --git a/tests/test-org-noter--title-to-slug.el b/tests/test-org-noter--title-to-slug.el new file mode 100644 index 00000000..b6880cf6 --- /dev/null +++ b/tests/test-org-noter--title-to-slug.el @@ -0,0 +1,100 @@ +;;; test-org-noter--title-to-slug.el --- Tests for cj/org-noter--title-to-slug -*- lexical-binding: t; -*- + +;;; Commentary: +;; ERT tests for the title-to-slug function used in org-noter workflow. +;; Tests cover normal, boundary, and error cases. + +;;; Code: + +(require 'ert) +(require 'org-noter-config) + +;;; Normal Cases + +(ert-deftest test-org-noter--title-to-slug-normal-simple-title () + "Normal case: Simple title with spaces." + (should (equal (cj/org-noter--title-to-slug "The Pragmatic Programmer") + "the-pragmatic-programmer"))) + +(ert-deftest test-org-noter--title-to-slug-normal-single-word () + "Normal case: Single word title." + (should (equal (cj/org-noter--title-to-slug "Dune") + "dune"))) + +(ert-deftest test-org-noter--title-to-slug-normal-with-numbers () + "Normal case: Title with numbers." + (should (equal (cj/org-noter--title-to-slug "1984 by George Orwell") + "1984-by-george-orwell"))) + +(ert-deftest test-org-noter--title-to-slug-normal-mixed-case () + "Normal case: Title with mixed case." + (should (equal (cj/org-noter--title-to-slug "SICP Structure and Interpretation") + "sicp-structure-and-interpretation"))) + +;;; Boundary Cases + +(ert-deftest test-org-noter--title-to-slug-boundary-special-chars () + "Boundary case: Title with special characters." + (should (equal (cj/org-noter--title-to-slug "C++: The Complete Guide") + "c-the-complete-guide"))) + +(ert-deftest test-org-noter--title-to-slug-boundary-punctuation () + "Boundary case: Title with punctuation." + (should (equal (cj/org-noter--title-to-slug "Why's (Poignant) Guide to Ruby") + "why-s-poignant-guide-to-ruby"))) + +(ert-deftest test-org-noter--title-to-slug-boundary-leading-special () + "Boundary case: Title starting with special character." + (should (equal (cj/org-noter--title-to-slug "...And Then There Were None") + "and-then-there-were-none"))) + +(ert-deftest test-org-noter--title-to-slug-boundary-trailing-special () + "Boundary case: Title ending with special character." + (should (equal (cj/org-noter--title-to-slug "What Is This Thing Called Love?") + "what-is-this-thing-called-love"))) + +(ert-deftest test-org-noter--title-to-slug-boundary-multiple-spaces () + "Boundary case: Title with multiple consecutive spaces." + (should (equal (cj/org-noter--title-to-slug "The Great Gatsby") + "the-great-gatsby"))) + +(ert-deftest test-org-noter--title-to-slug-boundary-underscores () + "Boundary case: Title with underscores." + (should (equal (cj/org-noter--title-to-slug "file_name_example") + "file-name-example"))) + +(ert-deftest test-org-noter--title-to-slug-boundary-hyphens () + "Boundary case: Title with existing hyphens." + (should (equal (cj/org-noter--title-to-slug "Self-Reliance") + "self-reliance"))) + +(ert-deftest test-org-noter--title-to-slug-boundary-all-numbers () + "Boundary case: Title that is all numbers." + (should (equal (cj/org-noter--title-to-slug "2001") + "2001"))) + +;;; Edge Cases + +(ert-deftest test-org-noter--title-to-slug-edge-empty-string () + "Edge case: Empty string." + (should (equal (cj/org-noter--title-to-slug "") + ""))) + +(ert-deftest test-org-noter--title-to-slug-edge-only-special-chars () + "Edge case: Only special characters." + (should (equal (cj/org-noter--title-to-slug "!@#$%^&*()") + ""))) + +(ert-deftest test-org-noter--title-to-slug-edge-unicode () + "Edge case: Title with unicode characters." + (should (equal (cj/org-noter--title-to-slug "Café au Lait") + "caf-au-lait"))) + +(ert-deftest test-org-noter--title-to-slug-edge-long-title () + "Edge case: Very long title." + (let ((long-title "The Absolutely Incredibly Long Title of This Book That Goes On and On")) + (should (equal (cj/org-noter--title-to-slug long-title) + "the-absolutely-incredibly-long-title-of-this-book-that-goes-on-and-on")))) + +(provide 'test-org-noter--title-to-slug) +;;; test-org-noter--title-to-slug.el ends here diff --git a/tests/test-org-refile-build-targets.el b/tests/test-org-refile-build-targets.el new file mode 100644 index 00000000..e7ab5c42 --- /dev/null +++ b/tests/test-org-refile-build-targets.el @@ -0,0 +1,305 @@ +;;; test-org-refile-build-targets.el --- Tests for cj/build-org-refile-targets -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/build-org-refile-targets caching logic. +;; Tests cache behavior, TTL expiration, force rebuild, and async build flag. + +;;; Code: + +(require 'ert) + +;; Add modules to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Stub dependencies before loading the module +(defvar inbox-file "/tmp/test-inbox.org") +(defvar reference-file "/tmp/test-reference.org") +(defvar schedule-file "/tmp/test-schedule.org") +(defvar user-emacs-directory "/tmp/test-emacs.d/") +(defvar code-dir "/tmp/test-code/") +(defvar projects-dir "/tmp/test-projects/") + +;; Now load the actual production module +(require 'org-refile-config) + +;;; Setup and Teardown + +(defun test-org-refile-setup () + "Reset cache and state before each test." + (setq cj/org-refile-targets-cache nil) + (setq cj/org-refile-targets-cache-time nil) + (setq cj/org-refile-targets-building nil) + (setq org-refile-targets nil)) + +(defun test-org-refile-teardown () + "Clean up after each test." + (setq cj/org-refile-targets-cache nil) + (setq cj/org-refile-targets-cache-time nil) + (setq cj/org-refile-targets-building nil) + (setq org-refile-targets nil)) + +;;; Normal Cases + +(ert-deftest test-org-refile-build-targets-normal-first-call-builds-cache () + "Test that first call builds cache from scratch. + +When cache is empty, function should: +1. Scan directories for todo.org files +2. Build refile targets list +3. Populate cache +4. Set cache timestamp" + (test-org-refile-setup) + (unwind-protect + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (_dir _pattern) '("/tmp/todo.org"))) + ((symbol-function 'fboundp) (lambda (_sym) nil))) + + ;; Before call: cache empty + (should (null cj/org-refile-targets-cache)) + (should (null cj/org-refile-targets-cache-time)) + + ;; Build targets + (cj/build-org-refile-targets) + + ;; After call: cache populated + (should cj/org-refile-targets-cache) + (should cj/org-refile-targets-cache-time) + (should org-refile-targets) + + ;; Cache matches org-refile-targets + (should (equal cj/org-refile-targets-cache org-refile-targets)) + + ;; Contains base files (inbox, reference, schedule) + (should (>= (length org-refile-targets) 3))) + (test-org-refile-teardown))) + +(ert-deftest test-org-refile-build-targets-normal-second-call-uses-cache () + "Test that second call uses cache instead of rebuilding. + +When cache is valid (not expired): +1. Should NOT scan directories again +2. Should restore targets from cache +3. Should NOT update cache timestamp" + (test-org-refile-setup) + (unwind-protect + (let ((scan-count 0)) + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (_dir _pattern) + (setq scan-count (1+ scan-count)) + '("/tmp/todo.org"))) + ((symbol-function 'fboundp) (lambda (_sym) nil))) + + ;; First call: builds cache + (cj/build-org-refile-targets) + (should (= scan-count 3)) ; 3 directories scanned + + (let ((cached-time cj/org-refile-targets-cache-time) + (cached-targets cj/org-refile-targets-cache)) + + ;; Second call: uses cache + (cj/build-org-refile-targets) + + ;; Scan count unchanged (cache hit) + (should (= scan-count 3)) + + ;; Cache unchanged + (should (equal cj/org-refile-targets-cache-time cached-time)) + (should (equal cj/org-refile-targets-cache cached-targets))))) + (test-org-refile-teardown))) + +(ert-deftest test-org-refile-build-targets-normal-force-rebuild-bypasses-cache () + "Test that force-rebuild parameter bypasses cache. + +When force-rebuild is non-nil: +1. Should ignore valid cache +2. Should rebuild from scratch +3. Should update cache with new data" + (test-org-refile-setup) + (unwind-protect + (let ((scan-count 0)) + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (_dir _pattern) + (setq scan-count (1+ scan-count)) + (if (> scan-count 3) + '("/tmp/todo.org" "/tmp/todo2.org") ; New file on rebuild + '("/tmp/todo.org")))) + ((symbol-function 'fboundp) (lambda (_sym) nil))) + + ;; First call: builds cache + (cj/build-org-refile-targets) + (let ((initial-count (length org-refile-targets))) + + ;; Force rebuild + (cj/build-org-refile-targets 'force) + + ;; Scanned again (3 more directories) + (should (= scan-count 6)) + + ;; New targets include additional file + (should (> (length org-refile-targets) initial-count))))) + (test-org-refile-teardown))) + +;;; Boundary Cases + +(ert-deftest test-org-refile-build-targets-boundary-cache-expires-after-ttl () + "Test that cache expires after TTL period. + +When cache timestamp exceeds TTL: +1. Should rebuild targets +2. Should update cache timestamp +3. Should rescan directories" + (test-org-refile-setup) + (unwind-protect + (let ((scan-count 0)) + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (_dir _pattern) + (setq scan-count (1+ scan-count)) + '("/tmp/todo.org"))) + ((symbol-function 'fboundp) (lambda (_sym) nil))) + + ;; First call: builds cache + (cj/build-org-refile-targets) + (should (= scan-count 3)) + + ;; Simulate cache expiration (set time to 2 hours ago) + (setq cj/org-refile-targets-cache-time + (- (float-time) (* 2 3600))) + + ;; Second call: cache expired, rebuild + (cj/build-org-refile-targets) + + ;; Scanned again (cache was expired) + (should (= scan-count 6)) + + ;; Cache timestamp updated to current time + (should (< (- (float-time) cj/org-refile-targets-cache-time) 1)))) + (test-org-refile-teardown))) + +(ert-deftest test-org-refile-build-targets-boundary-empty-directories-creates-minimal-targets () + "Test behavior when directories contain no todo.org files. + +When directory scans return empty: +1. Should still create base targets (inbox, reference, schedule) +2. Should not fail or error +3. Should cache the minimal result" + (test-org-refile-setup) + (unwind-protect + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (_dir _pattern) nil)) ; No files found + ((symbol-function 'fboundp) (lambda (_sym) nil))) + + (cj/build-org-refile-targets) + + ;; Should have base files only + (should (= (length org-refile-targets) 3)) + + ;; Cache should contain base files + (should cj/org-refile-targets-cache) + (should (= (length cj/org-refile-targets-cache) 3))) + (test-org-refile-teardown))) + +(ert-deftest test-org-refile-build-targets-boundary-building-flag-set-during-build () + "Test that building flag is set during build and cleared after. + +During build: +1. Flag should be set to prevent concurrent builds +2. Flag should clear even if build fails +3. Flag state should be consistent" + (test-org-refile-setup) + (unwind-protect + (let ((flag-during-build nil)) + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (_dir _pattern) + ;; Capture flag state during directory scan + (setq flag-during-build cj/org-refile-targets-building) + '("/tmp/todo.org"))) + ((symbol-function 'fboundp) (lambda (_sym) nil))) + + ;; Before build + (should (null cj/org-refile-targets-building)) + + ;; Build + (cj/build-org-refile-targets) + + ;; Flag was set during build + (should flag-during-build) + + ;; Flag cleared after build + (should (null cj/org-refile-targets-building)))) + (test-org-refile-teardown))) + +(ert-deftest test-org-refile-build-targets-boundary-building-flag-clears-on-error () + "Test that building flag clears even if build errors. + +When build encounters error: +1. Flag should still be cleared (unwind-protect) +2. Prevents permanently locked state +3. Next build can proceed" + (test-org-refile-setup) + (unwind-protect + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (_dir _pattern) + (error "Simulated scan failure"))) + ((symbol-function 'fboundp) (lambda (_sym) nil))) + + ;; Build will error + (should-error (cj/build-org-refile-targets)) + + ;; Flag cleared despite error (unwind-protect) + (should (null cj/org-refile-targets-building))) + (test-org-refile-teardown))) + +;;; Error Cases + +(ert-deftest test-org-refile-build-targets-error-nil-cache-with-old-timestamp () + "Test handling of inconsistent state (nil cache but timestamp set). + +When cache is nil but timestamp exists: +1. Should recognize cache as invalid +2. Should rebuild targets +3. Should set both cache and timestamp" + (test-org-refile-setup) + (unwind-protect + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (_dir _pattern) '("/tmp/todo.org"))) + ((symbol-function 'fboundp) (lambda (_sym) nil))) + + ;; Set inconsistent state + (setq cj/org-refile-targets-cache nil) + (setq cj/org-refile-targets-cache-time (float-time)) + + ;; Build should recognize invalid state + (cj/build-org-refile-targets) + + ;; Cache now populated + (should cj/org-refile-targets-cache) + (should cj/org-refile-targets-cache-time) + (should org-refile-targets)) + (test-org-refile-teardown))) + +(ert-deftest test-org-refile-build-targets-error-directory-scan-failure-propagates () + "Test that directory scan failures propagate as errors. + +When directory-files-recursively errors: +1. Error should propagate to caller +2. Cache should not be corrupted +3. Building flag should clear" + (test-org-refile-setup) + (unwind-protect + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (_dir _pattern) + (error "Permission denied"))) + ((symbol-function 'fboundp) (lambda (_sym) nil))) + + ;; Should propagate error + (should-error (cj/build-org-refile-targets)) + + ;; Cache not corrupted (still nil) + (should (null cj/org-refile-targets-cache)) + + ;; Building flag cleared + (should (null cj/org-refile-targets-building))) + (test-org-refile-teardown))) + +(provide 'test-org-refile-build-targets) +;;; test-org-refile-build-targets.el ends here diff --git a/tests/test-org-roam-config-demote.el b/tests/test-org-roam-config-demote.el new file mode 100644 index 00000000..98cc8244 --- /dev/null +++ b/tests/test-org-roam-config-demote.el @@ -0,0 +1,183 @@ +;;; test-org-roam-config-demote.el --- Tests for cj/--demote-org-subtree -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--demote-org-subtree function from org-roam-config.el +;; +;; This function demotes org subtree content from one level to another. +;; All headings in the tree are adjusted proportionally, with a minimum level of 1. +;; +;; Examples: +;; Input: "*** Heading\n**** Sub", from: 3, to: 1 +;; Output: "* Heading\n** Sub" +;; +;; Input: "** Heading\n*** Sub", from: 2, to: 1 +;; Output: "* Heading\n** Sub" + +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Now load the actual production module +(require 'org-roam-config) + +;;; Test Helpers + +(defun test-demote (content from-level to-level) + "Test cj/--demote-org-subtree on CONTENT. +FROM-LEVEL is the current top level, TO-LEVEL is the desired top level. +Returns the demoted content." + (cj/--demote-org-subtree content from-level to-level)) + +;;; Normal Cases - Single Heading + +(ert-deftest test-demote-level2-to-level1 () + "Should demote level 2 heading to level 1." + (let ((result (test-demote "** Heading\n" 2 1))) + (should (string= result "* Heading\n")))) + +(ert-deftest test-demote-level3-to-level1 () + "Should demote level 3 heading to level 1." + (let ((result (test-demote "*** Heading\n" 3 1))) + (should (string= result "* Heading\n")))) + +(ert-deftest test-demote-level4-to-level1 () + "Should demote level 4 heading to level 1." + (let ((result (test-demote "**** Heading\n" 4 1))) + (should (string= result "* Heading\n")))) + +(ert-deftest test-demote-level3-to-level2 () + "Should demote level 3 heading to level 2." + (let ((result (test-demote "*** Heading\n" 3 2))) + (should (string= result "** Heading\n")))) + +;;; Normal Cases - Multiple Headings at Same Level + +(ert-deftest test-demote-multiple-same-level () + "Should demote multiple headings at same level." + (let ((result (test-demote "** First\n** Second\n** Third\n" 2 1))) + (should (string= result "* First\n* Second\n* Third\n")))) + +;;; Normal Cases - Hierarchical Structure + +(ert-deftest test-demote-with-subheading () + "Should demote heading and subheading proportionally." + (let ((result (test-demote "** Heading\n*** Subheading\n" 2 1))) + (should (string= result "* Heading\n** Subheading\n")))) + +(ert-deftest test-demote-three-levels () + "Should demote three-level hierarchy." + (let ((result (test-demote "** Main\n*** Sub\n**** SubSub\n" 2 1))) + (should (string= result "* Main\n** Sub\n*** SubSub\n")))) + +(ert-deftest test-demote-complex-hierarchy () + "Should demote complex hierarchy maintaining relative structure." + (let ((result (test-demote "*** Top\n**** Sub1\n***** Deep\n**** Sub2\n" 3 1))) + (should (string= result "* Top\n** Sub1\n*** Deep\n** Sub2\n")))) + +;;; Normal Cases - With Content + +(ert-deftest test-demote-heading-with-text () + "Should demote heading preserving body text." + (let ((result (test-demote "** Heading\nBody text\n" 2 1))) + (should (string= result "* Heading\nBody text\n")))) + +(ert-deftest test-demote-with-properties () + "Should demote heading preserving properties." + (let ((result (test-demote "** Heading\n:PROPERTIES:\n:ID: 123\n:END:\n" 2 1))) + (should (string= result "* Heading\n:PROPERTIES:\n:ID: 123\n:END:\n")))) + +(ert-deftest test-demote-with-mixed-content () + "Should demote headings preserving all content." + (let ((result (test-demote "** H1\nText\n*** H2\nMore text\n" 2 1))) + (should (string= result "* H1\nText\n** H2\nMore text\n")))) + +;;; Boundary Cases - No Demotion Needed + +(ert-deftest test-demote-same-level () + "Should return content unchanged when from equals to." + (let ((result (test-demote "* Heading\n" 1 1))) + (should (string= result "* Heading\n")))) + +(ert-deftest test-demote-promote-ignored () + "Should return content unchanged when to > from (promotion)." + (let ((result (test-demote "* Heading\n" 1 2))) + (should (string= result "* Heading\n")))) + +;;; Boundary Cases - Minimum Level + +(ert-deftest test-demote-respects-minimum-level () + "Should not demote below level 1." + (let ((result (test-demote "** Main\n*** Sub\n" 2 1))) + (should (string= result "* Main\n** Sub\n")) + ;; Sub went from 3 to 2, not below 1 + (should (string-match-p "^\\*\\* Sub" result)))) + +(ert-deftest test-demote-deep-hierarchy-min-level () + "Should respect minimum level for deep hierarchies." + (let ((result (test-demote "**** L4\n***** L5\n****** L6\n" 4 1))) + (should (string= result "* L4\n** L5\n*** L6\n")))) + +;;; Boundary Cases - Empty and Edge Cases + +(ert-deftest test-demote-empty-string () + "Should handle empty string." + (let ((result (test-demote "" 2 1))) + (should (string= result "")))) + +(ert-deftest test-demote-no-headings () + "Should return non-heading content unchanged." + (let ((result (test-demote "Just plain text\nNo headings here\n" 2 1))) + (should (string= result "Just plain text\nNo headings here\n")))) + +(ert-deftest test-demote-heading-without-space () + "Should not match headings without space after stars." + (let ((result (test-demote "**Not a heading\n** Real Heading\n" 2 1))) + (should (string= result "**Not a heading\n* Real Heading\n")))) + +;;; Edge Cases - Special Heading Content + +(ert-deftest test-demote-heading-with-tags () + "Should demote heading preserving tags." + (let ((result (test-demote "** Heading :tag1:tag2:\n" 2 1))) + (should (string= result "* Heading :tag1:tag2:\n")))) + +(ert-deftest test-demote-heading-with-todo () + "Should demote heading preserving TODO keyword." + (let ((result (test-demote "** TODO Task\n" 2 1))) + (should (string= result "* TODO Task\n")))) + +(ert-deftest test-demote-heading-with-priority () + "Should demote heading preserving priority." + (let ((result (test-demote "** [#A] Important\n" 2 1))) + (should (string= result "* [#A] Important\n")))) + +;;; Edge Cases - Whitespace + +(ert-deftest test-demote-preserves-indentation () + "Should preserve indentation in body text." + (let ((result (test-demote "** Heading\n Indented text\n" 2 1))) + (should (string= result "* Heading\n Indented text\n")))) + +(ert-deftest test-demote-multiple-spaces-after-stars () + "Should handle multiple spaces after stars." + (let ((result (test-demote "** Heading\n" 2 1))) + (should (string= result "* Heading\n")))) + +;;; Edge Cases - Large Demotion + +(ert-deftest test-demote-large-level-difference () + "Should handle large level differences." + (let ((result (test-demote "****** Level 6\n******* Level 7\n" 6 1))) + (should (string= result "* Level 6\n** Level 7\n")))) + +(ert-deftest test-demote-to-level-2 () + "Should demote to level 2 when specified." + (let ((result (test-demote "***** Level 5\n****** Level 6\n" 5 2))) + (should (string= result "** Level 5\n*** Level 6\n")))) + +(provide 'test-org-roam-config-demote) +;;; test-org-roam-config-demote.el ends here diff --git a/tests/test-org-roam-config-format.el b/tests/test-org-roam-config-format.el new file mode 100644 index 00000000..e9378b7a --- /dev/null +++ b/tests/test-org-roam-config-format.el @@ -0,0 +1,151 @@ +;;; test-org-roam-config-format.el --- Tests for cj/--format-roam-node -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--format-roam-node function from org-roam-config.el +;; +;; This function formats org-roam node file content with title, node-id, and body content. +;; It creates a complete org-roam file with properties, title, category, and filetags. +;; +;; Example: +;; Input: title: "My Note", node-id: "abc123", content: "* Content\n" +;; Output: Full org-roam file with metadata and content + +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Now load the actual production module +(require 'org-roam-config) + +;;; Test Helpers + +(defun test-format (title node-id content) + "Test cj/--format-roam-node with TITLE, NODE-ID, and CONTENT. +Returns the formatted file content." + (cj/--format-roam-node title node-id content)) + +;;; Normal Cases + +(ert-deftest test-format-simple-node () + "Should format simple node with all components." + (let ((result (test-format "Test Title" "id-123" "* Content\n"))) + (should (string-match-p ":PROPERTIES:" result)) + (should (string-match-p ":ID: id-123" result)) + (should (string-match-p "#\\+TITLE: Test Title" result)) + (should (string-match-p "#\\+CATEGORY: Test Title" result)) + (should (string-match-p "#\\+FILETAGS: Topic" result)) + (should (string-match-p "\\* Content" result)))) + +(ert-deftest test-format-properties-first () + "Should place properties at the beginning." + (let ((result (test-format "Title" "id" "content"))) + (should (string-prefix-p ":PROPERTIES:\n" result)))) + +(ert-deftest test-format-id-after-properties () + "Should place ID in properties block." + (let ((result (test-format "Title" "test-id-456" "content"))) + (should (string-match-p ":PROPERTIES:\n:ID: test-id-456\n:END:" result)))) + +(ert-deftest test-format-title-after-properties () + "Should place title after properties." + (let ((result (test-format "My Title" "id" "content"))) + (should (string-match-p ":END:\n#\\+TITLE: My Title\n" result)))) + +(ert-deftest test-format-category-matches-title () + "Should set category to match title." + (let ((result (test-format "Project Name" "id" "content"))) + (should (string-match-p "#\\+TITLE: Project Name\n#\\+CATEGORY: Project Name\n" result)))) + +(ert-deftest test-format-filetags-topic () + "Should set filetags to Topic." + (let ((result (test-format "Title" "id" "content"))) + (should (string-match-p "#\\+FILETAGS: Topic\n" result)))) + +(ert-deftest test-format-content-at-end () + "Should place content after metadata." + (let ((result (test-format "Title" "id" "* Heading\nBody text\n"))) + (should (string-suffix-p "* Heading\nBody text\n" result)))) + +;;; Edge Cases - Various Titles + +(ert-deftest test-format-title-with-spaces () + "Should handle title with spaces." + (let ((result (test-format "Multi Word Title" "id" "content"))) + (should (string-match-p "#\\+TITLE: Multi Word Title" result)) + (should (string-match-p "#\\+CATEGORY: Multi Word Title" result)))) + +(ert-deftest test-format-title-with-punctuation () + "Should handle title with punctuation." + (let ((result (test-format "Title: With, Punctuation!" "id" "content"))) + (should (string-match-p "#\\+TITLE: Title: With, Punctuation!" result)))) + +(ert-deftest test-format-title-with-numbers () + "Should handle title with numbers." + (let ((result (test-format "Version 2.0" "id" "content"))) + (should (string-match-p "#\\+TITLE: Version 2\\.0" result)))) + +;;; Edge Cases - Various Node IDs + +(ert-deftest test-format-uuid-style-id () + "Should handle UUID-style ID." + (let ((result (test-format "Title" "a1b2c3d4-e5f6-7890-abcd-ef1234567890" "content"))) + (should (string-match-p ":ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890" result)))) + +(ert-deftest test-format-short-id () + "Should handle short ID." + (let ((result (test-format "Title" "1" "content"))) + (should (string-match-p ":ID: 1" result)))) + +(ert-deftest test-format-long-id () + "Should handle long ID." + (let* ((long-id (make-string 100 ?a)) + (result (test-format "Title" long-id "content"))) + (should (string-match-p (concat ":ID: " long-id) result)))) + +;;; Edge Cases - Various Content + +(ert-deftest test-format-empty-content () + "Should handle empty content." + (let ((result (test-format "Title" "id" ""))) + (should (string-suffix-p "#+FILETAGS: Topic\n\n" result)))) + +(ert-deftest test-format-multiline-content () + "Should handle multiline content." + (let ((result (test-format "Title" "id" "* H1\nText\n** H2\nMore\n"))) + (should (string-suffix-p "* H1\nText\n** H2\nMore\n" result)))) + +(ert-deftest test-format-content-with-properties () + "Should handle content that already has properties." + (let ((result (test-format "Title" "id" "* Heading\n:PROPERTIES:\n:CUSTOM: value\n:END:\n"))) + (should (string-match-p ":CUSTOM: value" result)))) + +;;; Integration Tests - Structure + +(ert-deftest test-format-complete-structure () + "Should create proper org-roam file structure." + (let ((result (test-format "My Note" "abc-123" "* Content\n"))) + ;; Check order of components + (should (< (string-match ":PROPERTIES:" result) + (string-match ":ID:" result))) + (should (< (string-match ":ID:" result) + (string-match ":END:" result))) + (should (< (string-match ":END:" result) + (string-match "#\\+TITLE:" result))) + (should (< (string-match "#\\+TITLE:" result) + (string-match "#\\+CATEGORY:" result))) + (should (< (string-match "#\\+CATEGORY:" result) + (string-match "#\\+FILETAGS:" result))) + (should (< (string-match "#\\+FILETAGS:" result) + (string-match "\\* Content" result))))) + +(ert-deftest test-format-double-newline-after-metadata () + "Should have double newline between metadata and content." + (let ((result (test-format "Title" "id" "* Content"))) + (should (string-match-p "#\\+FILETAGS: Topic\n\n\\* Content" result)))) + +(provide 'test-org-roam-config-format) +;;; test-org-roam-config-format.el ends here diff --git a/tests/test-org-roam-config-link-description.el b/tests/test-org-roam-config-link-description.el new file mode 100644 index 00000000..06321b8f --- /dev/null +++ b/tests/test-org-roam-config-link-description.el @@ -0,0 +1,188 @@ +;;; test-org-roam-config-link-description.el --- Tests for cj/org-link-get-description -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/org-link-get-description function from org-roam-config.el +;; +;; This function extracts the description from an org link, or returns the text unchanged. +;; If TEXT contains an org link like [[url][description]], it returns description. +;; If TEXT contains multiple links, only the first one is processed. +;; Otherwise it returns TEXT unchanged. +;; +;; Examples: +;; Input: "[[https://example.com][Example Site]]" +;; Output: "Example Site" +;; +;; Input: "[[https://example.com]]" +;; Output: "https://example.com" +;; +;; Input: "Plain text" +;; Output: "Plain text" + +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Now load the actual production module +(require 'org-roam-config) + +;;; Test Helpers + +(defun test-link-description (text) + "Test cj/org-link-get-description on TEXT. +Returns the extracted description or text unchanged." + (cj/org-link-get-description text)) + +;;; Normal Cases - Link with Description + +(ert-deftest test-link-with-description () + "Should extract description from link with description." + (let ((result (test-link-description "[[https://example.com][Example Site]]"))) + (should (string= result "Example Site")))) + +(ert-deftest test-link-with-multiword-description () + "Should extract multi-word description." + (let ((result (test-link-description "[[url][Multiple Word Description]]"))) + (should (string= result "Multiple Word Description")))) + +(ert-deftest test-link-with-special-chars-in-description () + "Should extract description with special characters." + (let ((result (test-link-description "[[url][Description: with, punctuation!]]"))) + (should (string= result "Description: with, punctuation!")))) + +(ert-deftest test-link-file-path-with-description () + "Should extract description from file link." + (let ((result (test-link-description "[[file:~/document.pdf][My Document]]"))) + (should (string= result "My Document")))) + +(ert-deftest test-link-with-numbers-in-description () + "Should extract description containing numbers." + (let ((result (test-link-description "[[url][Chapter 42]]"))) + (should (string= result "Chapter 42")))) + +;;; Normal Cases - Link without Description + +(ert-deftest test-link-without-description-url () + "Should return URL when no description is present." + (let ((result (test-link-description "[[https://example.com]]"))) + (should (string= result "https://example.com")))) + +(ert-deftest test-link-without-description-file () + "Should return file path when no description." + (let ((result (test-link-description "[[file:~/notes.org]]"))) + (should (string= result "file:~/notes.org")))) + +(ert-deftest test-link-without-description-id () + "Should return ID when no description." + (let ((result (test-link-description "[[id:abc123]]"))) + (should (string= result "id:abc123")))) + +;;; Normal Cases - No Link + +(ert-deftest test-plain-text () + "Should return plain text unchanged." + (let ((result (test-link-description "Plain text without link"))) + (should (string= result "Plain text without link")))) + +(ert-deftest test-text-with-brackets-but-not-link () + "Should return text with single brackets unchanged." + (let ((result (test-link-description "Text [with] brackets"))) + (should (string= result "Text [with] brackets")))) + +(ert-deftest test-text-with-partial-link-syntax () + "Should return text with partial link syntax unchanged." + (let ((result (test-link-description "[[incomplete link"))) + (should (string= result "[[incomplete link")))) + +;;; Boundary Cases - Multiple Links + +(ert-deftest test-multiple-links-extracts-first () + "Should extract description from first link only." + (let ((result (test-link-description "[[url1][First]] and [[url2][Second]]"))) + (should (string= result "First")))) + +(ert-deftest test-multiple-links-first-has-no-description () + "Should extract URL from first link when it has no description." + (let ((result (test-link-description "[[url1]] and [[url2][Second]]"))) + (should (string= result "url1")))) + +;;; Boundary Cases - Empty and Edge Cases + +(ert-deftest test-empty-string () + "Should return empty string unchanged." + (let ((result (test-link-description ""))) + (should (string= result "")))) + +(ert-deftest test-link-with-empty-description () + "Should return text unchanged when description brackets are empty." + (let ((result (test-link-description "[[https://example.com][]]"))) + ;; Regex requires at least one char in description, so no match + (should (string= result "[[https://example.com][]]")))) + +(ert-deftest test-link-with-empty-url () + "Should return text unchanged when link is completely empty." + (let ((result (test-link-description "[[]]"))) + ;; Regex requires at least one char in URL, so no match, returns unchanged + (should (string= result "[[]]")))) + +(ert-deftest test-link-with-empty-url-and-description () + "Should handle completely empty link." + (let ((result (test-link-description "[][]"))) + (should (string= result "[][]")))) + +;;; Edge Cases - Special Link Types + +(ert-deftest test-internal-link () + "Should extract description from internal link." + (let ((result (test-link-description "[[*Heading][My Heading]]"))) + (should (string= result "My Heading")))) + +(ert-deftest test-internal-link-without-description () + "Should return heading target from internal link without description." + (let ((result (test-link-description "[[*Heading]]"))) + (should (string= result "*Heading")))) + +(ert-deftest test-custom-id-link () + "Should handle custom ID links." + (let ((result (test-link-description "[[#custom-id][Custom Section]]"))) + (should (string= result "Custom Section")))) + +;;; Edge Cases - Link with Surrounding Text + +(ert-deftest test-link-with-prefix-text () + "Should extract description from link with prefix text." + (let ((result (test-link-description "See [[url][documentation]] for details"))) + (should (string= result "documentation")))) + +(ert-deftest test-link-at-start () + "Should extract description from link at start of text." + (let ((result (test-link-description "[[url][Link]] at beginning"))) + (should (string= result "Link")))) + +(ert-deftest test-link-at-end () + "Should extract description from link at end of text." + (let ((result (test-link-description "Text with [[url][link]]"))) + (should (string= result "link")))) + +;;; Edge Cases - Special Characters in URL + +(ert-deftest test-link-with-query-params () + "Should handle URL with query parameters." + (let ((result (test-link-description "[[https://example.com?q=test&foo=bar][Search]]"))) + (should (string= result "Search")))) + +(ert-deftest test-link-with-anchor () + "Should handle URL with anchor." + (let ((result (test-link-description "[[https://example.com#section][Section]]"))) + (should (string= result "Section")))) + +(ert-deftest test-link-with-spaces-in-description () + "Should preserve spaces in description." + (let ((result (test-link-description "[[url][Multiple Spaces]]"))) + (should (string= result "Multiple Spaces")))) + +(provide 'test-org-roam-config-link-description) +;;; test-org-roam-config-link-description.el ends here diff --git a/tests/test-org-roam-config-slug.el b/tests/test-org-roam-config-slug.el new file mode 100644 index 00000000..eb3149dd --- /dev/null +++ b/tests/test-org-roam-config-slug.el @@ -0,0 +1,223 @@ +;;; test-org-roam-config-slug.el --- Tests for cj/--generate-roam-slug -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--generate-roam-slug function from org-roam-config.el +;; +;; This function converts a title to a filename-safe slug by: +;; 1. Converting to lowercase +;; 2. Replacing non-alphanumeric characters with hyphens +;; 3. Removing leading and trailing hyphens +;; +;; Examples: +;; Input: "My Project Name" +;; Output: "my-project-name" +;; +;; Input: "Hello, World!" +;; Output: "hello-world" + +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Now load the actual production module +(require 'org-roam-config) + +;;; Test Helpers + +(defun test-slug (title) + "Test cj/--generate-roam-slug on TITLE. +Returns the slugified string." + (cj/--generate-roam-slug title)) + +;;; Normal Cases - Simple Titles + +(ert-deftest test-slug-simple-word () + "Should return lowercase simple word." + (let ((result (test-slug "Hello"))) + (should (string= result "hello")))) + +(ert-deftest test-slug-multiple-words () + "Should replace spaces with hyphens." + (let ((result (test-slug "My Project Name"))) + (should (string= result "my-project-name")))) + +(ert-deftest test-slug-already-lowercase () + "Should handle already lowercase text." + (let ((result (test-slug "simple"))) + (should (string= result "simple")))) + +(ert-deftest test-slug-mixed-case () + "Should convert mixed case to lowercase." + (let ((result (test-slug "MixedCaseTitle"))) + (should (string= result "mixedcasetitle")))) + +;;; Normal Cases - Punctuation + +(ert-deftest test-slug-with-comma () + "Should remove commas." + (let ((result (test-slug "Hello, World"))) + (should (string= result "hello-world")))) + +(ert-deftest test-slug-with-period () + "Should remove periods." + (let ((result (test-slug "Version 2.0"))) + (should (string= result "version-2-0")))) + +(ert-deftest test-slug-with-exclamation () + "Should remove exclamation marks." + (let ((result (test-slug "Hello World!"))) + (should (string= result "hello-world")))) + +(ert-deftest test-slug-with-question () + "Should remove question marks." + (let ((result (test-slug "What Is This?"))) + (should (string= result "what-is-this")))) + +(ert-deftest test-slug-with-colon () + "Should remove colons." + (let ((result (test-slug "Note: Important"))) + (should (string= result "note-important")))) + +(ert-deftest test-slug-with-parentheses () + "Should remove parentheses." + (let ((result (test-slug "Item (copy)"))) + (should (string= result "item-copy")))) + +;;; Normal Cases - Numbers + +(ert-deftest test-slug-with-numbers () + "Should preserve numbers." + (let ((result (test-slug "Chapter 42"))) + (should (string= result "chapter-42")))) + +(ert-deftest test-slug-only-numbers () + "Should handle titles with only numbers." + (let ((result (test-slug "123"))) + (should (string= result "123")))) + +(ert-deftest test-slug-mixed-alphanumeric () + "Should preserve alphanumeric characters." + (let ((result (test-slug "Test123ABC"))) + (should (string= result "test123abc")))) + +;;; Boundary Cases - Multiple Consecutive Special Chars + +(ert-deftest test-slug-multiple-spaces () + "Should collapse multiple spaces into single hyphen." + (let ((result (test-slug "Hello World"))) + (should (string= result "hello-world")))) + +(ert-deftest test-slug-mixed-punctuation () + "Should collapse mixed punctuation into single hyphen." + (let ((result (test-slug "Hello, ... World!"))) + (should (string= result "hello-world")))) + +(ert-deftest test-slug-consecutive-hyphens () + "Should collapse consecutive hyphens." + (let ((result (test-slug "Hello---World"))) + (should (string= result "hello-world")))) + +;;; Boundary Cases - Leading/Trailing Special Chars + +(ert-deftest test-slug-leading-space () + "Should remove leading hyphen from leading space." + (let ((result (test-slug " Hello"))) + (should (string= result "hello")))) + +(ert-deftest test-slug-trailing-space () + "Should remove trailing hyphen from trailing space." + (let ((result (test-slug "Hello "))) + (should (string= result "hello")))) + +(ert-deftest test-slug-leading-punctuation () + "Should remove leading hyphen from leading punctuation." + (let ((result (test-slug "...Hello"))) + (should (string= result "hello")))) + +(ert-deftest test-slug-trailing-punctuation () + "Should remove trailing hyphen from trailing punctuation." + (let ((result (test-slug "Hello!!!"))) + (should (string= result "hello")))) + +(ert-deftest test-slug-leading-and-trailing () + "Should remove both leading and trailing hyphens." + (let ((result (test-slug " Hello World "))) + (should (string= result "hello-world")))) + +;;; Boundary Cases - Empty and Short + +(ert-deftest test-slug-empty-string () + "Should return empty string for empty input." + (let ((result (test-slug ""))) + (should (string= result "")))) + +(ert-deftest test-slug-only-punctuation () + "Should return empty string for only punctuation." + (let ((result (test-slug "!!!"))) + (should (string= result "")))) + +(ert-deftest test-slug-only-spaces () + "Should return empty string for only spaces." + (let ((result (test-slug " "))) + (should (string= result "")))) + +(ert-deftest test-slug-single-char () + "Should handle single character." + (let ((result (test-slug "A"))) + (should (string= result "a")))) + +;;; Edge Cases - Special Characters + +(ert-deftest test-slug-with-underscore () + "Should replace underscores with hyphens." + (let ((result (test-slug "my_variable_name"))) + (should (string= result "my-variable-name")))) + +(ert-deftest test-slug-with-slash () + "Should remove slashes." + (let ((result (test-slug "path/to/file"))) + (should (string= result "path-to-file")))) + +(ert-deftest test-slug-with-at-sign () + "Should remove at signs." + (let ((result (test-slug "user@example"))) + (should (string= result "user-example")))) + +(ert-deftest test-slug-with-hash () + "Should remove hash symbols." + (let ((result (test-slug "#hashtag"))) + (should (string= result "hashtag")))) + +(ert-deftest test-slug-with-dollar () + "Should remove dollar signs." + (let ((result (test-slug "$price"))) + (should (string= result "price")))) + +;;; Edge Cases - Unicode (if supported) + +(ert-deftest test-slug-with-unicode () + "Should remove unicode characters." + (let ((result (test-slug "Café"))) + (should (string= result "caf")))) + +(ert-deftest test-slug-with-emoji () + "Should remove emoji." + (let ((result (test-slug "Hello 😀 World"))) + (should (string= result "hello-world")))) + +;;; Edge Cases - Long Titles + +(ert-deftest test-slug-very-long-title () + "Should handle very long titles." + (let* ((long-title (mapconcat #'identity (make-list 20 "word") " ")) + (result (test-slug long-title))) + (should (string-prefix-p "word-" result)) + (should (string-suffix-p "-word" result)) + (should (not (string-match-p " " result))))) + +(provide 'test-org-roam-config-slug) +;;; test-org-roam-config-slug.el ends here diff --git a/tests/test-org-sort-by-todo-and-priority.el b/tests/test-org-sort-by-todo-and-priority.el new file mode 100644 index 00000000..873f37c2 --- /dev/null +++ b/tests/test-org-sort-by-todo-and-priority.el @@ -0,0 +1,283 @@ +;;; test-org-sort-by-todo-and-priority.el --- Tests for cj/org-sort-by-todo-and-priority -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Unit tests for cj/org-sort-by-todo-and-priority function. +;; Tests multi-level sorting: TODO status (TODO before DONE) and priority (A before B before C). +;; +;; Testing approach: +;; - Use real org-mode buffers (don't mock org-sort-entries) +;; - Trust org-mode framework works correctly +;; - Test OUR integration logic: calling org-sort-entries twice in correct order +;; - Verify final sort order matches expected TODO/priority combination +;; +;; The function uses stable sorting: +;; 1. First sort by priority (A, B, C, D, none) +;; 2. Then sort by TODO status (TODO before DONE) +;; Result: Priority order preserved within each TODO state group + +;;; Code: + +(require 'ert) +(require 'org) +(require 'org-config) ; Defines cj/org-sort-by-todo-and-priority + +;;; Test Helpers + +(defun test-org-sort-by-todo-and-priority--create-buffer (content) + "Create a temporary org-mode buffer with CONTENT. +Returns the buffer object. +Disables org-mode hooks to avoid missing package dependencies in batch mode." + (let ((buf (generate-new-buffer "*test-org-sort*"))) + (with-current-buffer buf + ;; Disable hooks to prevent org-superstar and other package loads + (let ((org-mode-hook nil)) + (org-mode)) + (insert content) + (goto-char (point-min))) + buf)) + +(defun test-org-sort-by-todo-and-priority--get-entry-order (buffer) + "Extract ordered list of TODO states and priorities from BUFFER. +Returns list of strings like \"TODO [#A]\" or \"DONE\" for each heading." + (with-current-buffer buffer + (goto-char (point-min)) + (let (entries) + (org-map-entries + (lambda () + (let* ((todo-state (org-get-todo-state)) + ;; Get heading: no-tags, no-todo, KEEP priority, no-comment + (heading (org-get-heading t t nil t)) + ;; Extract priority cookie from heading text + (priority (when (string-match "\\[#\\([A-Z]\\)\\]" heading) + (match-string 1 heading)))) + (push (if priority + (format "%s [#%s]" (or todo-state "") priority) + (or todo-state "")) + entries))) + nil 'tree) + (nreverse entries)))) + +(defun test-org-sort-by-todo-and-priority--sort-children (buffer) + "Position cursor on parent heading in BUFFER and sort its children. +Moves to first * heading (Parent) and calls sort function to sort children." + (with-current-buffer buffer + (goto-char (point-min)) + (when (re-search-forward "^\\* " nil t) + (beginning-of-line) + (cj/org-sort-by-todo-and-priority)))) + +;;; Normal Cases + +(ert-deftest test-org-sort-by-todo-and-priority-normal-mixed-todo-done-sorts-correctly () + "Test mixed TODO and DONE entries with various priorities sort correctly. + +Input: TODO [#A], DONE [#B], TODO [#C], DONE [#A] +Expected: TODO [#A], TODO [#C], DONE [#A], DONE [#B]" + (let* ((content "* Parent +** TODO [#A] First task +** DONE [#B] Second task +** TODO [#C] Third task +** DONE [#A] Fourth task +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "TODO [#A]" "TODO [#C]" "DONE [#A]" "DONE [#B]"))))) + (kill-buffer buf)))) + +(ert-deftest test-org-sort-by-todo-and-priority-normal-multiple-todos-sorts-by-priority () + "Test multiple TODO entries sort by priority A before B before C. + +Input: TODO [#C], TODO [#A], TODO [#B] +Expected: TODO [#A], TODO [#B], TODO [#C]" + (let* ((content "* Parent +** TODO [#C] Task C +** TODO [#A] Task A +** TODO [#B] Task B +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "TODO [#A]" "TODO [#B]" "TODO [#C]"))))) + (kill-buffer buf)))) + +(ert-deftest test-org-sort-by-todo-and-priority-normal-multiple-dones-sorts-by-priority () + "Test multiple DONE entries sort by priority A before B before C. + +Input: DONE [#C], DONE [#A], DONE [#B] +Expected: DONE [#A], DONE [#B], DONE [#C]" + (let* ((content "* Parent +** DONE [#C] Done C +** DONE [#A] Done A +** DONE [#B] Done B +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "DONE [#A]" "DONE [#B]" "DONE [#C]"))))) + (kill-buffer buf)))) + +(ert-deftest test-org-sort-by-todo-and-priority-normal-same-priority-todo-before-done () + "Test entries with same priority sort TODO before DONE. + +Input: DONE [#A], TODO [#A] +Expected: TODO [#A], DONE [#A]" + (let* ((content "* Parent +** DONE [#A] Done task +** TODO [#A] Todo task +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "TODO [#A]" "DONE [#A]"))))) + (kill-buffer buf)))) + +;;; Boundary Cases + +(ert-deftest test-org-sort-by-todo-and-priority-boundary-empty-section-no-error () + "Test sorting empty section does not signal error. + +Input: Heading with no children +Expected: No error, no change" + (let* ((content "* Parent\n") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (with-current-buffer buf + (goto-char (point-min)) + (should-not (condition-case err + (progn + (cj/org-sort-by-todo-and-priority) + nil) + (error err)))) + (kill-buffer buf)))) + +(ert-deftest test-org-sort-by-todo-and-priority-boundary-single-todo-no-change () + "Test sorting single TODO entry does not change order. + +Input: Single TODO [#A] +Expected: Same order (no change)" + (let* ((content "* Parent +** TODO [#A] Only task +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "TODO [#A]"))))) + (kill-buffer buf)))) + +(ert-deftest test-org-sort-by-todo-and-priority-boundary-single-done-no-change () + "Test sorting single DONE entry does not change order. + +Input: Single DONE [#B] +Expected: Same order (no change)" + (let* ((content "* Parent +** DONE [#B] Only task +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "DONE [#B]"))))) + (kill-buffer buf)))) + +(ert-deftest test-org-sort-by-todo-and-priority-boundary-all-todos-sorts-by-priority () + "Test all TODO entries sort by priority only. + +Input: TODO [#C], TODO [#A], TODO [#B] +Expected: TODO [#A], TODO [#B], TODO [#C]" + (let* ((content "* Parent +** TODO [#C] Task C +** TODO [#A] Task A +** TODO [#B] Task B +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "TODO [#A]" "TODO [#B]" "TODO [#C]"))))) + (kill-buffer buf)))) + +(ert-deftest test-org-sort-by-todo-and-priority-boundary-all-dones-sorts-by-priority () + "Test all DONE entries sort by priority only. + +Input: DONE [#B], DONE [#D], DONE [#A] +Expected: DONE [#A], DONE [#B], DONE [#D]" + (let* ((content "* Parent +** DONE [#B] Done B +** DONE [#D] Done D +** DONE [#A] Done A +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "DONE [#A]" "DONE [#B]" "DONE [#D]"))))) + (kill-buffer buf)))) + +(ert-deftest test-org-sort-by-todo-and-priority-boundary-no-priorities-sorts-by-todo () + "Test entries without priorities sort by TODO status only. + +Input: TODO (no priority), DONE (no priority), TODO (no priority) +Expected: TODO, TODO, DONE" + (let* ((content "* Parent +** TODO Task 1 +** DONE Task 2 +** TODO Task 3 +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "TODO" "TODO" "DONE"))))) + (kill-buffer buf)))) + +(ert-deftest test-org-sort-by-todo-and-priority-boundary-unprioritized-after-prioritized () + "Test unprioritized entries appear after prioritized within TODO/DONE groups. + +Input: TODO (no priority), TODO [#A], DONE [#B], DONE (no priority) +Expected: TODO [#A], TODO (no priority), DONE [#B], DONE (no priority)" + (let* ((content "* Parent +** TODO Task no priority +** TODO [#A] Task A +** DONE [#B] Done B +** DONE Done no priority +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "TODO [#A]" "TODO" "DONE [#B]" "DONE"))))) + (kill-buffer buf)))) + +;;; Error Cases + +(ert-deftest test-org-sort-by-todo-and-priority-error-non-org-buffer-signals-error () + "Test calling in non-org-mode buffer signals user-error. + +Input: fundamental-mode buffer +Expected: user-error" + (let ((buf (generate-new-buffer "*test-non-org*"))) + (unwind-protect + (with-current-buffer buf + (fundamental-mode) + (should-error (cj/org-sort-by-todo-and-priority) :type 'user-error)) + (kill-buffer buf)))) + +(provide 'test-org-sort-by-todo-and-priority) +;;; test-org-sort-by-todo-and-priority.el ends here diff --git a/tests/test-org-webclipper-process.el b/tests/test-org-webclipper-process.el new file mode 100644 index 00000000..9a25ef5c --- /dev/null +++ b/tests/test-org-webclipper-process.el @@ -0,0 +1,210 @@ +;;; test-org-webclipper-process.el --- Tests for cj/--process-webclip-content -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--process-webclip-content function from org-webclipper.el +;; +;; This function processes webclipped org-mode content by: +;; 1. Removing the first top-level heading +;; 2. Removing any initial blank lines +;; 3. Demoting all remaining headings by one level +;; +;; Examples: +;; Input: "* Title\nContent\n** Sub\n" +;; Output: "Content\n*** Sub\n" +;; +;; Input: "* Title\n\n\n** Sub\n" +;; Output: "*** Sub\n" + +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Now load the actual production module +(require 'org-webclipper) + +;;; Test Helpers + +(defun test-process-webclip (content) + "Test cj/--process-webclip-content on CONTENT. +Returns the processed content." + (cj/--process-webclip-content content)) + +;;; Normal Cases - Single Heading Removal + +(ert-deftest test-process-removes-first-heading () + "Should remove the first top-level heading." + (let ((result (test-process-webclip "* Title\nContent\n"))) + (should (string= result "Content\n")))) + +(ert-deftest test-process-removes-heading-with-text () + "Should remove first heading preserving body text." + (let ((result (test-process-webclip "* Page Title\nParagraph text\n"))) + (should (string= result "Paragraph text\n")))) + +(ert-deftest test-process-removes-heading-with-tags () + "Should remove first heading even with tags." + (let ((result (test-process-webclip "* Title :tag1:tag2:\nContent\n"))) + (should (string= result "Content\n")))) + +(ert-deftest test-process-removes-heading-with-todo () + "Should remove first heading even with TODO keyword." + (let ((result (test-process-webclip "* TODO Task\nContent\n"))) + (should (string= result "Content\n")))) + +;;; Normal Cases - Blank Line Removal + +(ert-deftest test-process-removes-single-blank-line () + "Should remove single blank line after heading removal." + (let ((result (test-process-webclip "* Title\n\nContent\n"))) + (should (string= result "Content\n")))) + +(ert-deftest test-process-removes-multiple-blank-lines () + "Should remove multiple blank lines after heading removal." + (let ((result (test-process-webclip "* Title\n\n\n\nContent\n"))) + (should (string= result "Content\n")))) + +(ert-deftest test-process-removes-blank-lines-with-spaces () + "Should remove blank lines that contain only spaces." + (let ((result (test-process-webclip "* Title\n \n\t\nContent\n"))) + (should (string= result "Content\n")))) + +(ert-deftest test-process-preserves-blank-lines-in-content () + "Should preserve blank lines within the content." + (let ((result (test-process-webclip "* Title\nPara 1\n\nPara 2\n"))) + (should (string= result "Para 1\n\nPara 2\n")))) + +;;; Normal Cases - Heading Demotion + +(ert-deftest test-process-demotes-second-level () + "Should demote level 2 heading to level 3." + (let ((result (test-process-webclip "* Title\n** Section\n"))) + (should (string= result "*** Section\n")))) + +(ert-deftest test-process-demotes-third-level () + "Should demote level 3 heading to level 4." + (let ((result (test-process-webclip "* Title\n*** Subsection\n"))) + (should (string= result "**** Subsection\n")))) + +(ert-deftest test-process-demotes-multiple-headings () + "Should demote all headings in the content." + (let ((result (test-process-webclip "* Title\n** Section 1\n** Section 2\n"))) + (should (string= result "*** Section 1\n*** Section 2\n")))) + +(ert-deftest test-process-demotes-nested-hierarchy () + "Should demote nested heading structure." + (let ((result (test-process-webclip "* Title\n** Section\n*** Subsection\n"))) + (should (string= result "*** Section\n**** Subsection\n")))) + +;;; Normal Cases - Combined Processing + +(ert-deftest test-process-full-workflow () + "Should remove heading, blank lines, and demote remaining headings." + (let ((result (test-process-webclip "* Article Title\n\n** Introduction\nText\n** Conclusion\n"))) + (should (string= result "*** Introduction\nText\n*** Conclusion\n")))) + +(ert-deftest test-process-with-properties () + "Should preserve properties in demoted headings." + (let ((result (test-process-webclip "* Title\n** Heading\n:PROPERTIES:\n:ID: 123\n:END:\n"))) + (should (string= result "*** Heading\n:PROPERTIES:\n:ID: 123\n:END:\n")))) + +(ert-deftest test-process-with-mixed-content () + "Should handle mixed text and headings." + (let ((result (test-process-webclip "* Title\nIntro text\n** Section\nBody text\n"))) + (should (string= result "Intro text\n*** Section\nBody text\n")))) + +;;; Edge Cases - Empty and Minimal Content + +(ert-deftest test-process-empty-string () + "Should return empty string for empty input." + (let ((result (test-process-webclip ""))) + (should (string= result "")))) + +(ert-deftest test-process-only-heading () + "Should return empty string when only first heading present." + (let ((result (test-process-webclip "* Title\n"))) + (should (string= result "")))) + +(ert-deftest test-process-only-blank-lines () + "Should return empty string for only blank lines after heading." + (let ((result (test-process-webclip "* Title\n\n\n"))) + (should (string= result "")))) + +(ert-deftest test-process-no-heading () + "Should handle content without any heading." + (let ((result (test-process-webclip "Just plain text\n"))) + (should (string= result "Just plain text\n")))) + +(ert-deftest test-process-heading-no-newline () + "Should demote heading without trailing newline (doesn't match removal pattern)." + (let ((result (test-process-webclip "* Title"))) + (should (string= result "** Title")))) + +;;; Edge Cases - Heading Variations + +(ert-deftest test-process-heading-without-space () + "Should not match heading without space after stars." + (let ((result (test-process-webclip "*Title\nContent\n"))) + (should (string= result "*Title\nContent\n")))) + +(ert-deftest test-process-multiple-top-level-headings () + "Should only remove first top-level heading." + (let ((result (test-process-webclip "* Title 1\n* Title 2\n"))) + (should (string= result "** Title 2\n")))) + +(ert-deftest test-process-heading-with-priority () + "Should remove heading with priority marker." + (let ((result (test-process-webclip "* [#A] Important\nContent\n"))) + (should (string= result "Content\n")))) + +(ert-deftest test-process-heading-with-links () + "Should remove heading containing links." + (let ((result (test-process-webclip "* [[url][Link Title]]\nContent\n"))) + (should (string= result "Content\n")))) + +;;; Edge Cases - Special Content + +(ert-deftest test-process-preserves-lists () + "Should preserve list formatting." + (let ((result (test-process-webclip "* Title\n- Item 1\n- Item 2\n"))) + (should (string= result "- Item 1\n- Item 2\n")))) + +(ert-deftest test-process-preserves-code-blocks () + "Should preserve code block content." + (let ((result (test-process-webclip "* Title\n#+BEGIN_SRC python\nprint('hi')\n#+END_SRC\n"))) + (should (string= result "#+BEGIN_SRC python\nprint('hi')\n#+END_SRC\n")))) + +(ert-deftest test-process-preserves-tables () + "Should preserve org table content." + (let ((result (test-process-webclip "* Title\n| A | B |\n| 1 | 2 |\n"))) + (should (string= result "| A | B |\n| 1 | 2 |\n")))) + +;;; Edge Cases - Deep Nesting + +(ert-deftest test-process-very-deep-headings () + "Should demote very deep heading structures." + (let ((result (test-process-webclip "* Title\n****** Level 6\n"))) + (should (string= result "******* Level 6\n")))) + +(ert-deftest test-process-complex-document () + "Should handle complex document structure." + (let ((result (test-process-webclip "* Main Title\n\n** Section 1\nText 1\n*** Subsection 1.1\nText 2\n** Section 2\nText 3\n"))) + (should (string= result "*** Section 1\nText 1\n**** Subsection 1.1\nText 2\n*** Section 2\nText 3\n")))) + +;;; Integration Tests + +(ert-deftest test-process-realistic-webpage () + "Should process realistic webclipped content." + (let ((result (test-process-webclip "* How to Program in Emacs Lisp\n\n** Introduction\nEmacs Lisp is powerful.\n\n** Getting Started\nFirst, open Emacs.\n\n*** Installation\nDownload from gnu.org\n"))) + (should (string= result "*** Introduction\nEmacs Lisp is powerful.\n\n*** Getting Started\nFirst, open Emacs.\n\n**** Installation\nDownload from gnu.org\n")))) + +(ert-deftest test-process-article-with-metadata () + "Should handle article with org metadata." + (let ((result (test-process-webclip "* Article Title :article:web:\n#+DATE: 2024-01-01\n\n** Content\nBody text\n"))) + (should (string= result "#+DATE: 2024-01-01\n\n*** Content\nBody text\n")))) + +(provide 'test-org-webclipper-process) +;;; test-org-webclipper-process.el ends here diff --git a/tests/test-system-lib-executable-exists-p.el b/tests/test-system-lib-executable-exists-p.el new file mode 100644 index 00000000..457bb010 --- /dev/null +++ b/tests/test-system-lib-executable-exists-p.el @@ -0,0 +1,73 @@ +;;; test-system-lib-executable-exists-p.el --- Tests for cj/executable-exists-p -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/executable-exists-p function from system-lib.el. +;; Tests whether external programs are correctly detected in PATH. + +;;; Code: + +(require 'ert) +(require 'system-lib) + +;;; Normal Cases + +(ert-deftest test-system-lib-executable-exists-p-normal-existing-program-returns-path () + "Test that existing program in PATH returns non-nil. + +Standard case: checking for a program that definitely exists on all systems." + (should (cj/executable-exists-p "ls"))) + +(ert-deftest test-system-lib-executable-exists-p-normal-diff-exists-returns-path () + "Test that diff program exists and is detected. + +Tests specifically for diff which we use in our diff functionality." + (should (cj/executable-exists-p "diff"))) + +;;; Boundary Cases + +(ert-deftest test-system-lib-executable-exists-p-boundary-empty-string-returns-nil () + "Test that empty string returns nil. + +Boundary case: empty string is not a valid program name." + (should-not (cj/executable-exists-p ""))) + +(ert-deftest test-system-lib-executable-exists-p-boundary-whitespace-only-returns-nil () + "Test that whitespace-only string returns nil. + +Boundary case: strings containing only whitespace are not valid programs." + (should-not (cj/executable-exists-p " "))) + +(ert-deftest test-system-lib-executable-exists-p-boundary-absolute-path-returns-path () + "Test that absolute path to executable returns the path. + +Boundary case: executable-find accepts both program names and full paths." + (should (cj/executable-exists-p "/usr/bin/ls"))) + +;;; Error Cases + +(ert-deftest test-system-lib-executable-exists-p-error-nil-input-returns-nil () + "Test that nil input returns nil gracefully. + +Error case: nil is not a valid program name." + (should-not (cj/executable-exists-p nil))) + +(ert-deftest test-system-lib-executable-exists-p-error-number-input-returns-nil () + "Test that numeric input returns nil gracefully. + +Error case: number is not a valid program name." + (should-not (cj/executable-exists-p 42))) + +(ert-deftest test-system-lib-executable-exists-p-error-nonexistent-program-returns-nil () + "Test that nonexistent program returns nil. + +Error case: program that definitely doesn't exist in PATH." + (should-not (cj/executable-exists-p "this-program-definitely-does-not-exist-xyz123"))) + +(ert-deftest test-system-lib-executable-exists-p-error-special-characters-returns-nil () + "Test that program name with special characters returns nil. + +Error case: invalid characters in program name." + (should-not (cj/executable-exists-p "program-with-$pecial-ch@rs"))) + +(provide 'test-system-lib-executable-exists-p) +;;; test-system-lib-executable-exists-p.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 diff --git a/tests/test-testutil-filesystem-directory-entries.el b/tests/test-testutil-filesystem-directory-entries.el deleted file mode 100644 index 7ddbf426..00000000 --- a/tests/test-testutil-filesystem-directory-entries.el +++ /dev/null @@ -1,317 +0,0 @@ -;;; test-testutil-filesystem-directory-entries.el --- -*- coding: utf-8; lexical-binding: t; -*- -;; -;; Author: Craig Jennings <c@cjennings.net> -;; -;;; Commentary: -;; ERT tests for testutil-filesystem.el -;; Tests cj/list-directory-recursive and it's helper function cj/get--directory-entries. -;; -;;; Code: - -(require 'ert) -(require 'f) - -;; load test directory -(add-to-list 'load-path (concat user-emacs-directory "tests/")) -(require 'testutil-general) ;; helper functions -(require 'testutil-filesystem) ;; file under test - -(defun cj/test--setup () - "Create the test base directory using `cj/create-test-base-dir'." - (cj/create-test-base-dir)) - -(defun cj/test--teardown () - "Remove the test base directory using `cj/delete-test-base-dir'." - (cj/delete-test-base-dir)) - -;;; ---------------------- CJ/GET--DIRECTORY-ENTRIES TESTS ---------------------- -;;;; Normal Case Tests - -(ert-deftest test-normal-one-file () - "Test a single file at the base directory." - (cj/test--setup) - (unwind-protect - (progn - (cj/create-directory-or-file-ensuring-parents "file.txt" "Test file") - (let - ;; get paths to all files - ((entries (cj/get--directory-entries cj/test-base-dir))) - ;; check for files of different types and in subdirectories - (should (cl-some (lambda (e) (string= (f-filename e) "file.txt")) entries)))) - (cj/test--teardown))) - -(ert-deftest test-normal-includes-subdirectories-but-no-contents () - "Test that we do include subdirectories themselves." - (cj/test--setup) - (unwind-protect - (progn - ;; create yoru test assets - (cj/create-directory-or-file-ensuring-parents "file1.org" "Test file 1" t) - (cj/create-directory-or-file-ensuring-parents "subdir/file2.org" "Nested file") - ;; get paths to all files - (let ((entries (cj/get--directory-entries cj/test-base-dir))) - (should (cl-some (lambda (e) (and (file-directory-p e) - (string= (f-filename e) "subdir"))) entries)) - (should-not (cl-some (lambda (e) (string= (f-filename e) "file2.org")) entries)))) - (cj/test--teardown))) - -(ert-deftest test-normal-excludes-hidden-by-default () - "Test that hidden files (i.e.,begin with a dot) are excluded by default. -Asserts no subdirectories or hidden files or visible files in hidden subdirectories are returned." - (cj/test--setup) - (unwind-protect - (progn - ;; create your test assets - (cj/create-directory-or-file-ensuring-parents ".hiddenfile" "Hidden content") - ;; get paths to all files - (let ((entries (cj/get--directory-entries cj/test-base-dir))) - ;; should not see hidden file - (should-not (cl-some (lambda (e) (string= (f-filename e) ".hiddenfile")) entries)))) - (cj/test--teardown))) - -(ert-deftest test-normal-includes-hidden-with-flag () - "Non-nil means hidden files are included." - (cj/test--setup) - (unwind-protect - (progn - ;; create your test assets - (cj/create-directory-or-file-ensuring-parents ".hiddenfile" "Hidden content") - ;; get paths to all files passing in t to reveal hidden files - (let ((entries (cj/get--directory-entries cj/test-base-dir t))) - ;; should not see hidden file - (should (cl-some (lambda (e) (string= (f-filename e) ".hiddenfile")) entries)))) - (cj/test--teardown))) - -;; -;;;; Boundary Cases - -(ert-deftest test-boundary-empty-directory () - "Test an empty directory returns empty list." - (cj/test--setup) - (unwind-protect - (let ((entries (cj/get--directory-entries cj/test-base-dir))) - (should (equal entries nil))) - (cj/test--teardown))) - -(ert-deftest test-boundary-files-with-unusual-names () - "Test files with unusual names." - (cj/test--setup) - (unwind-protect - (progn - (cj/create-directory-or-file-ensuring-parents "file with spaces.org" "content") - (cj/create-directory-or-file-ensuring-parents "unicode-ß₄©.org" "content") ;; Direct Unicode chars - ;; Or use proper escape sequences: - ;; (cj/create-directory-or-file-ensuring-parents "unicode-\u00DF\u2074\u00A9.org" "content") - (let ((entries (cj/get--directory-entries cj/test-base-dir))) - (should (cl-some (lambda (e) (string= (f-filename e) "file with spaces.org")) entries)) - (should (cl-some (lambda (e) (string= (f-filename e) "unicode-ß₄©.org")) entries)))) - (cj/test--teardown))) - -;;;; Error Cases - -(ert-deftest test-error-nonexistent-directory () - "Test calling on nonexistent directory returns nil or error handled." - (should-error (cj/get--directory-entries "/path/does/not/exist"))) - ; -(ert-deftest test-error-not-a-directory-path () - "Test calling on a file path signals error." - (cj/test--setup) - (unwind-protect - (let ((filepath (cj/create-directory-or-file-ensuring-parents "file.txt" "data"))) - (should-error (cj/get--directory-entries filepath))) - (cj/test--teardown))) - -(ert-deftest test-error-permission-denied () - "Test directory with no permission signals error or returns nil." - (cj/test--setup) - (unwind-protect - (let ((dir (expand-file-name "noperm" cj/test-base-dir))) - (cj/create-directory-or-file-ensuring-parents "noperm/file2.org" "Nested file") - (let ((original-mode (file-modes dir))) ; Save original permissions - (set-file-modes dir #o000) ; Remove all permissions - (unwind-protect - (should-error (cj/get--directory-entries dir)) - (set-file-modes dir original-mode)))) ; Restore permissions - extra paren here - (cj/test--teardown))) - -;;; --------------------- CJ/LIST-DIRECTORY-RECURSIVE TESTS --------------------- -;;;; Normal Cases - -(ert-deftest test-normal-single-file-at-root () - "Test the normal base case: one single file at the root." - (cj/test--setup) - (unwind-protect - (progn - (cj/create-directory-or-file-ensuring-parents "file.txt" "Content") - (let ((file-infos (cj/list-directory-recursive cj/test-base-dir))) - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file.txt")) file-infos)))) - (cj/test--teardown))) - -(ert-deftest test-normal-multiple-files-at-root () - "Test finding multiple files at the root directory." - (cj/test--setup) - (unwind-protect - (cj/create-directory-or-file-ensuring-parents "file1.txt" "Content in File 1") - (cj/create-directory-or-file-ensuring-parents "file2.org" "Content in File 2") - (cj/create-directory-or-file-ensuring-parents "file3.md" "Content in File 3") - (let ((file-infos (cj/list-directory-recursive cj/test-base-dir))) - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file1.txt")) file-infos)) - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file2.org")) file-infos)) - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file3.md")) file-infos))) - (cj/test--teardown))) - -(ert-deftest test-normal-multiple-files-in-subdirectories () - "Test finding multiple files at the root directory." - (cj/test--setup) - (unwind-protect - (cj/create-directory-or-file-ensuring-parents "one/file1.txt" "Content in File 1") - (cj/create-directory-or-file-ensuring-parents "two/file2.org" "Content in File 2") - (cj/create-directory-or-file-ensuring-parents "three/file3.md" "Content in File 3") - (let ((file-infos (cj/list-directory-recursive cj/test-base-dir))) - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file1.txt")) file-infos)) - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file2.org")) file-infos)) - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "file3.md")) file-infos))) - (cj/test--teardown))) - -(ert-deftest test-recursive-excludes-hidden-by-default () - "Test that hidden files are excluded by default in recursive listing. -Verify that files beginning with a dot, hidden directories, and files -within hidden directories are all excluded when include-hidden is nil." - (cj/test--setup) - (unwind-protect - (progn - ;; Create test assets including hidden files at various levels - (cj/create-directory-or-file-ensuring-parents ".hiddenfile" "Hidden content") - (cj/create-directory-or-file-ensuring-parents ".hiddendir/visible-in-hidden.txt" "File in hidden dir") - (cj/create-directory-or-file-ensuring-parents "visible/normal.txt" "Normal file") - (cj/create-directory-or-file-ensuring-parents "visible/.hidden-in-visible.txt" "Hidden in visible dir") - - ;; Get all files recursively (default excludes hidden) - (let ((file-infos (cj/list-directory-recursive cj/test-base-dir))) - ;; Should not see .hiddenfile at root - (should-not (cl-some (lambda (fi) - (string= (f-filename (plist-get fi :path)) ".hiddenfile")) - file-infos)) - ;; Should not see .hiddendir directory - (should-not (cl-some (lambda (fi) - (string= (f-filename (plist-get fi :path)) ".hiddendir")) - file-infos)) - ;; Should not see files inside hidden directory - (should-not (cl-some (lambda (fi) - (string= (f-filename (plist-get fi :path)) "visible-in-hidden.txt")) - file-infos)) - ;; Should not see hidden file in visible directory - (should-not (cl-some (lambda (fi) - (string= (f-filename (plist-get fi :path)) ".hidden-in-visible.txt")) - file-infos)) - ;; Should see normal visible file - (should (cl-some (lambda (fi) - (string= (f-filename (plist-get fi :path)) "normal.txt")) - file-infos)))) - (cj/test--teardown))) - -(ert-deftest test-recursive-includes-hidden-with-flag () - "Non-nil means hidden files are included. -Verifies that files beginning with a dot, hidden directories, and files -within hidden directories are all included when include-hidden is t." - (cj/test--setup) - (unwind-protect - (progn - ;; Create test assets including hidden files at various levels - (cj/create-directory-or-file-ensuring-parents ".hiddenfile" "Hidden content") - (cj/create-directory-or-file-ensuring-parents ".hiddendir/visible-in-hidden.txt" "File in hidden dir") - (cj/create-directory-or-file-ensuring-parents "visible/normal.txt" "Normal file") - (cj/create-directory-or-file-ensuring-parents "visible/.hidden-in-visible.txt" "Hidden in visible dir") - - ;; Get all files recursively with include-hidden = t - (let ((file-infos (cj/list-directory-recursive cj/test-base-dir t))) - ;; Should see .hiddenfile at root - (should (cl-some (lambda (fi) - (string= (f-filename (plist-get fi :path)) ".hiddenfile")) file-infos)) - ;; Should see .hiddendir directory - (should (cl-some (lambda (fi) (and (plist-get fi :directory) - (string= (f-filename (plist-get fi :path)) ".hiddendir"))) file-infos)) - ;; Should see files inside hidden directory - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "visible-in-hidden.txt")) file-infos)) - ;; Should see hidden file in visible directory - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) ".hidden-in-visible.txt")) file-infos)) - ;; Should still see normal visible file - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "normal.txt")) file-infos)))) - (cj/test--teardown))) - -(ert-deftest test-normal-deeply-nested-structure () - "Tests with deeply nested directory trees." - (cj/test--setup) - (unwind-protect - (progn - (cj/create-directory-or-file-ensuring-parents - "one/two/three/four/five/six/seven/eight/nine/ten/eleven/twelve/13.txt" "thirteen") - (cj/create-directory-or-file-ensuring-parents - "1/2/3/4/5/6/7/8/9/10/11/12/13/14/15/16/17/18/19/20/21/22/23/24/25/26/27/28/29/thirty.txt" "30") - (let ((file-infos (cj/list-directory-recursive cj/test-base-dir))) - ;; validate the files - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "13.txt")) file-infos)) - (should (cl-some (lambda (fi) (string= (f-filename (plist-get fi :path)) "thirty.txt")) file-infos)))) - (cj/test--teardown))) - -(ert-deftest test-normal-only-directory-entries () - "Tests with deeply nested directory trees without files." - (cj/test--setup) - (unwind-protect - (progn - (cj/create-directory-or-file-ensuring-parents - "one/two/three/four/five/six/seven/eight/nine/ten/eleven/twelve/thirteen/") - (cj/create-directory-or-file-ensuring-parents - "1/2/3/4/5/6/7/8/9/10/11/12/13/14/15/16/17/18/19/20/21/22/23/24/25/26/27/28/29/30/") - (let ((file-infos (cj/list-directory-recursive cj/test-base-dir))) - ;; validate the directories - (should (cl-some (lambda (fi) - (and (string= (f-filename (plist-get fi :path)) "thirteen") - (plist-get fi :directory) - (file-directory-p (plist-get fi :path)))) - file-infos)) - - (should (cl-some (lambda (fi) - (and (string= (f-filename (plist-get fi :path)) "30") - (plist-get fi :directory) - (file-directory-p (plist-get fi :path)))) - file-infos)))) - (cj/test--teardown))) - -;; 5. =test-normal-filter-by-extension= - Filter predicate correctly filters .org files - - -;; 6. =test-normal-filter-by-size= - Filter predicate filters files > 1KB -;; 7. =test-normal-filter-excludes-directories= - Filter can exclude directories themselves -;; 8. =test-normal-max-depth-one= - Respects max-depth=1 (only immediate children) -;; 9. =test-normal-max-depth-three= - Respects max-depth=3 limit -;; 11. =test-normal-executable-files= - Correctly identifies executable files -;; 12. =test-normal-file-info-plist-structure= - Verifies correct plist keys/values returned - -;;;; Boundary Cases -;; 1. =test-boundary-empty-directory= - Empty directory returns empty list -;; 2. =test-boundary-single-empty-subdirectory= - Directory with only empty subdirectory -;; 3. =test-boundary-unicode-filenames= - Files with unicode characters (emoji, Chinese, etc.) -;; 4. =test-boundary-spaces-in-names= - Files/dirs with spaces in names -;; 5. =test-boundary-special-characters= - Files with special chars (@#$%^&*()_+) -;; 6. =test-boundary-very-long-filename= - File with 255 character name -;; 8. =test-boundary-many-files= - Directory with 1000+ files -;; 9. =test-boundary-max-depth-zero= - max-depth=0 (unlimited) works correctly -;; 10. =test-boundary-symlinks= - How it handles symbolic links -;; 11. =test-boundary-filter-returns-all-nil= - Filter that rejects everything -;; 12. =test-boundary-filter-returns-all-true= - Filter that accepts everything - -;;;; Error Cases -;; 1. =test-error-nonexistent-path= - Path that doesn't exist -;; 2. =test-error-file-not-directory= - PATH is a file, not directory -;; 3. =test-error-permission-denied= - Directory without read permissions -;; 4. =test-error-permission-denied-subdirectory= - Subdirectory without permissions -;; 5. =test-error-invalid-max-depth= - Negative max-depth value -;; 6. =test-error-filter-predicate-errors= - Filter function that throws error -;; 7. =test-error-circular-symlinks= - Circular symbolic link reference -;; 8. =test-error-path-outside-home= - Attempt to access system directories (if restricted) -;; 9. =test-error-nil-path= - PATH is nil -;; 10. =test-error-empty-string-path= - PATH is empty string - -(provide 'test-testutil-filesystem-directory-entries) -;;; test-testutil-filesystem-directory-entries.el ends here. diff --git a/tests/test-theme-theme-persistence.el.disabled b/tests/test-theme-theme-persistence.el.disabled deleted file mode 100644 index e0b2f9e3..00000000 --- a/tests/test-theme-theme-persistence.el.disabled +++ /dev/null @@ -1,135 +0,0 @@ -;;; test-theme-theme-persistence.el --- Tests theme persistence mechanism -*- lexical-binding: t; -*- - -;;; Commentary: -;; Unit tests for the persistence of the chosen theme - -;;; Code: - -(add-to-list 'load-path (concat user-emacs-directory "modules")) -(require 'ui-theme) - -;; ------------------------ Constants / Setup / Teardown ----------------------- - -(defvar cj/original-theme-name nil) -(defvar cj/original-newline-setting nil) - -(defun cj/test-setup () - "Required settings and save state before each test." - - ;; save the current theme for restoration - (setq cj/original-theme-name (symbol-name (car custom-enabled-themes))) - (setq cj/original-newline-setting mode-require-final-newline) - - ;; unload all themes before starting test - (mapcar #'disable-theme custom-enabled-themes) - - ;; no EOF newlines - (custom-set-variables - '(require-final-newline nil)) - (setq mode-require-final-newline nil)) - -(defun cj/test-teardown () - "Restore the state before each test." - ;; restore newline setting - (setq require-final-newline cj/original-newline-setting) - - ;; if there wasn't an original theme, remove all themes - (if (string= cj/original-theme-name "nil") - (mapcar #'disable-theme custom-enabled-themes) - ;; otherwise, restore it - (load-theme (intern cj/original-theme-name)))) - -;; ----------------------------------- Tests ----------------------------------- - -(ert-deftest test-write-file-contents () - "Normal Case: Uses function to write a string, reads it back, and compares." - (cj/test-setup) - (let ((teststring "testing123") - (testfilename "test-write-file-contents.txt")) - ;; call the function - (should (equal (cj/write-file-contents teststring testfilename) - 't)) - ;; Read the file and check it's contents - (should (equal (with-temp-buffer(insert-file-contents testfilename) - (buffer-string)) - teststring)) - ;; clean up test file - (delete-file testfilename)) - (cj/test-teardown)) - -(ert-deftest test-write-file-not-writable () - "Test writing to a non-writable file." - (cl-flet ((file-writeable-p (file) nil)) - (let* ((non-writable-file (make-temp-file "test-non-writable")) - (should (equal (cj/write-file-contents "cowabunga" non-writable-file) 'nil))) - (delete-file non-writable-file)))) - -(ert-deftest test-read-file-contents () - "Normal Case: Writes string to file and reads contents using function." - (cj/test-setup) - (let ((teststring "testing123") - (testfilename "test-read-file-contents.txt")) - ;; write the file - (with-temp-buffer - (insert teststring) - (write-file testfilename)) - ;; call the function - (should (equal (cj/read-file-contents testfilename) - teststring)) - ;; clean up test file - (delete-file testfilename)) - (cj/test-teardown)) - -(ert-deftest test-read-file-nonexistent () - "Test reading from a non-existent file returns nil." - (cj/test-setup) - (let* ((filename (concat (number-to-string (random 99999999)) "nonexistent-file.txt")) - (result (cj/read-file-contents filename))) - (should (equal result nil))) - (cj/test-teardown)) - -(ert-deftest test-get-active-theme () - (cj/test-setup) - "Normal Case: Sets theme, gets theme-name, and compares." - (let ((expected "wombat")) - (load-theme (intern expected)) - (should (string= (cj/get-active-theme-name) expected)) - (cj/test-teardown))) - -(ert-deftest test-get-active-theme () - (cj/test-setup) - "Normal Case: Sets theme, gets theme-name, and compares." - (let ((expected "nil")) - (mapcar #'disable-theme custom-enabled-themes) - (should (equal (cj/get-active-theme-name) expected)) - (cj/test-teardown))) - -(ert-deftest test-save-theme-to-file () - "Normal case: sets theme, saves it, reads from file, and compares." - (cj/test-setup) - (let ((expected "wombat")) - (load-theme (intern expected)) - (cj/save-theme-to-file) - (should (equal (cj/read-file-contents theme-file) expected)) - (cj/test-teardown))) - -(ert-deftest test-load-theme-from-file () - "Normal case: saves new theme to file, loads it from file, and compares." - (cj/test-setup) - (let ((expected "wombat")) ;; the ui theme that test-setup uses. - (cj/write-file-contents expected theme-file) - (cj/load-theme-from-file) - (should (equal expected (cj/get-active-theme-name)))) - (cj/test-teardown)) - -(ert-deftest test-load-nil-theme () - "Corner case: saves 'nil as theme name to file, loads it, and compares to not having a theme." - (cj/test-setup) - (let ((expected "nil")) ;; the ui theme that test-setup uses. - (cj/write-file-contents expected theme-file) - (cj/load-theme-from-file) - (should (equal expected (cj/get-active-theme-name)))) - (cj/test-teardown)) - -(provide 'test-theme-theme-persistence) -;;; test-theme-theme-persistence.el ends here. diff --git a/tests/test-title-case-region.el.disabled b/tests/test-title-case-region.el.disabled deleted file mode 100644 index ffab0c24..00000000 --- a/tests/test-title-case-region.el.disabled +++ /dev/null @@ -1,44 +0,0 @@ -;;; test-title-case-region.el --- -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests for the title-case region function in custom-functions.el - -;; Note on Title Case -;; Title case is a capitalization convention where major words are -;; capitalized,and most minor words are lowercase. Nouns,verbs (including -;; linking verbs), adjectives, adverbs,pronouns,and all words of four letters or -;; more are considered major words. Short (i.e., three letters or fewer) -;; conjunctions, short prepositions,and all articles are considered minor -;; words." - -;; positive case (single line, all lowercase, no skip words) -;; positive case (six lines, mixed case, skip words) -;; negative case (single line, all skip-words) -;; negative case (a long empty string) - - -;;; Code: - -(require 'ert) -(add-to-list 'load-path (concat user-emacs-directory "modules")) -(require 'custom-functions) - -(ert-deftest test-cj/fixup-whitespace-positive-first-line-only () - "Test a positive case with two lines. -Both lines have whitespace at the beginning and the end. This tests that when -this function is called on the first line, only that line is affected." - (let ((testdata " Hello, world! \n Foo bar ") - (expected "Hello, world!\n Foo bar ") - (actual)) - (with-temp-buffer - (insert testdata) - (goto-char (point-min)) - (cj/fixup-whitespace-line-or-region) - (setq actual (buffer-string)) - (should (string= actual expected))))) - - - - -(provide 'test-title-case-region) -;;; test-title-case-region.el ends here. diff --git a/tests/test-transcription-audio-file.el b/tests/test-transcription-audio-file.el new file mode 100644 index 00000000..ac4ff452 --- /dev/null +++ b/tests/test-transcription-audio-file.el @@ -0,0 +1,88 @@ +;;; test-transcription-audio-file.el --- Tests for audio file detection -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for cj/--audio-file-p function +;; Categories: Normal cases, Boundary cases, Error cases + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +(require 'transcription-config) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-cj/--audio-file-p-m4a () + "Test that .m4a files are recognized as audio." + (should (cj/--audio-file-p "meeting.m4a"))) + +(ert-deftest test-cj/--audio-file-p-mp3 () + "Test that .mp3 files are recognized as audio." + (should (cj/--audio-file-p "podcast.mp3"))) + +(ert-deftest test-cj/--audio-file-p-wav () + "Test that .wav files are recognized as audio." + (should (cj/--audio-file-p "recording.wav"))) + +(ert-deftest test-cj/--audio-file-p-flac () + "Test that .flac files are recognized as audio." + (should (cj/--audio-file-p "music.flac"))) + +(ert-deftest test-cj/--audio-file-p-with-path () + "Test audio file recognition with full path." + (should (cj/--audio-file-p "/home/user/recordings/meeting.m4a"))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-cj/--audio-file-p-uppercase-extension () + "Test that uppercase extensions are recognized." + (should (cj/--audio-file-p "MEETING.M4A"))) + +(ert-deftest test-cj/--audio-file-p-mixed-case () + "Test that mixed case extensions are recognized." + (should (cj/--audio-file-p "podcast.Mp3"))) + +(ert-deftest test-cj/--audio-file-p-no-extension () + "Test that files without extension are not recognized." + (should-not (cj/--audio-file-p "meeting"))) + +(ert-deftest test-cj/--audio-file-p-empty-string () + "Test that empty string is not recognized as audio." + (should-not (cj/--audio-file-p ""))) + +(ert-deftest test-cj/--audio-file-p-dotfile () + "Test that dotfiles without proper extension are not recognized." + (should-not (cj/--audio-file-p ".hidden"))) + +(ert-deftest test-cj/--audio-file-p-multiple-dots () + "Test file with multiple dots but audio extension." + (should (cj/--audio-file-p "meeting.2025-11-04.final.m4a"))) + +;; ------------------------------ Error Cases ---------------------------------- + +(ert-deftest test-cj/--audio-file-p-not-audio () + "Test that non-audio files are not recognized." + (should-not (cj/--audio-file-p "document.pdf"))) + +(ert-deftest test-cj/--audio-file-p-text-file () + "Test that text files are not recognized as audio." + (should-not (cj/--audio-file-p "notes.txt"))) + +(ert-deftest test-cj/--audio-file-p-org-file () + "Test that org files are not recognized as audio." + (should-not (cj/--audio-file-p "tasks.org"))) + +(ert-deftest test-cj/--audio-file-p-video-file () + "Test that video files are not recognized as audio." + (should-not (cj/--audio-file-p "video.mp4"))) + +(ert-deftest test-cj/--audio-file-p-nil () + "Test that nil input returns nil." + (should-not (cj/--audio-file-p nil))) + +(provide 'test-transcription-audio-file) +;;; test-transcription-audio-file.el ends here diff --git a/tests/test-transcription-config--transcription-script-path.el b/tests/test-transcription-config--transcription-script-path.el new file mode 100644 index 00000000..a56cb05c --- /dev/null +++ b/tests/test-transcription-config--transcription-script-path.el @@ -0,0 +1,106 @@ +;;; test-transcription-config--transcription-script-path.el --- Tests for cj/--transcription-script-path -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--transcription-script-path function from transcription-config.el +;; +;; This function returns the absolute path to the transcription script based on +;; the current value of cj/transcribe-backend. + +;;; Code: + +(require 'ert) + +;; 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.") + +;; Stub notification function +(unless (fboundp 'notifications-notify) + (defun notifications-notify (&rest _args) + "Stub notification function for testing." + nil)) + +;; Now load the actual production module +(require 'transcription-config) + +;;; Setup and Teardown + +(defun test-transcription-script-path-setup () + "Set up test environment." + ;; Save original backend setting + (setq test-transcription-original-backend cj/transcribe-backend)) + +(defun test-transcription-script-path-teardown () + "Clean up test environment." + ;; Restore original backend setting + (setq cj/transcribe-backend test-transcription-original-backend)) + +;;; Normal Cases + +(ert-deftest test-transcription-config--transcription-script-path-normal-openai-api-returns-oai-transcribe () + "Should return oai-transcribe script path for openai-api backend." + (test-transcription-script-path-setup) + (unwind-protect + (progn + (setq cj/transcribe-backend 'openai-api) + (let ((result (cj/--transcription-script-path))) + (should (stringp result)) + (should (string-suffix-p "scripts/oai-transcribe" result)) + (should (string-prefix-p (expand-file-name user-emacs-directory) result)))) + (test-transcription-script-path-teardown))) + +(ert-deftest test-transcription-config--transcription-script-path-normal-assemblyai-returns-assemblyai-transcribe () + "Should return assemblyai-transcribe script path for assemblyai backend." + (test-transcription-script-path-setup) + (unwind-protect + (progn + (setq cj/transcribe-backend 'assemblyai) + (let ((result (cj/--transcription-script-path))) + (should (stringp result)) + (should (string-suffix-p "scripts/assemblyai-transcribe" result)) + (should (string-prefix-p (expand-file-name user-emacs-directory) result)))) + (test-transcription-script-path-teardown))) + +(ert-deftest test-transcription-config--transcription-script-path-normal-local-whisper-returns-local-whisper () + "Should return local-whisper script path for local-whisper backend." + (test-transcription-script-path-setup) + (unwind-protect + (progn + (setq cj/transcribe-backend 'local-whisper) + (let ((result (cj/--transcription-script-path))) + (should (stringp result)) + (should (string-suffix-p "scripts/local-whisper" result)) + (should (string-prefix-p (expand-file-name user-emacs-directory) result)))) + (test-transcription-script-path-teardown))) + +(ert-deftest test-transcription-config--transcription-script-path-normal-returns-absolute-path () + "Should return absolute path starting with user-emacs-directory." + (test-transcription-script-path-setup) + (unwind-protect + (progn + (setq cj/transcribe-backend 'openai-api) + (let ((result (cj/--transcription-script-path))) + (should (file-name-absolute-p result)) + (should (string-prefix-p "/" result)))) + (test-transcription-script-path-teardown))) + +;;; Boundary Cases + +(ert-deftest test-transcription-config--transcription-script-path-boundary-path-format-consistent () + "Should return paths in consistent format across backends." + (test-transcription-script-path-setup) + (unwind-protect + (let (paths) + (dolist (backend '(openai-api assemblyai local-whisper)) + (setq cj/transcribe-backend backend) + (push (cj/--transcription-script-path) paths)) + ;; All paths should have same structure: <emacs-dir>/scripts/<name> + (should (= (length paths) 3)) + (should (seq-every-p (lambda (p) (string-match-p "/scripts/[^/]+$" p)) paths))) + (test-transcription-script-path-teardown))) + +(provide 'test-transcription-config--transcription-script-path) +;;; test-transcription-config--transcription-script-path.el ends here diff --git a/tests/test-transcription-counter.el b/tests/test-transcription-counter.el new file mode 100644 index 00000000..dd4df7dc --- /dev/null +++ b/tests/test-transcription-counter.el @@ -0,0 +1,103 @@ +;;; test-transcription-counter.el --- Tests for active transcription counting -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for cj/--count-active-transcriptions and modeline integration +;; Categories: Normal cases, Boundary cases + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +(require 'transcription-config) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-cj/--count-active-transcriptions-empty () + "Test count when no transcriptions are active." + (let ((cj/transcriptions-list '())) + (should (= 0 (cj/--count-active-transcriptions))))) + +(ert-deftest test-cj/--count-active-transcriptions-one-running () + "Test count with one running transcription." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running)))) + (should (= 1 (cj/--count-active-transcriptions))))) + +(ert-deftest test-cj/--count-active-transcriptions-multiple-running () + "Test count with multiple running transcriptions." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running) + (proc2 "file2.m4a" nil running) + (proc3 "file3.m4a" nil running)))) + (should (= 3 (cj/--count-active-transcriptions))))) + +(ert-deftest test-cj/--count-active-transcriptions-mixed-status () + "Test count excludes completed/errored transcriptions." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running) + (proc2 "file2.m4a" nil complete) + (proc3 "file3.m4a" nil running) + (proc4 "file4.m4a" nil error)))) + (should (= 2 (cj/--count-active-transcriptions))))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-cj/--count-active-transcriptions-only-complete () + "Test count when all transcriptions are complete." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil complete) + (proc2 "file2.m4a" nil complete)))) + (should (= 0 (cj/--count-active-transcriptions))))) + +(ert-deftest test-cj/--count-active-transcriptions-only-error () + "Test count when all transcriptions errored." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil error) + (proc2 "file2.m4a" nil error)))) + (should (= 0 (cj/--count-active-transcriptions))))) + +;; ----------------------------- Modeline Tests -------------------------------- + +(ert-deftest test-cj/--transcription-modeline-string-none-active () + "Test modeline string when no transcriptions active." + (let ((cj/transcriptions-list '())) + (should-not (cj/--transcription-modeline-string)))) + +(ert-deftest test-cj/--transcription-modeline-string-one-active () + "Test modeline string with one active transcription." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running)))) + (let ((result (cj/--transcription-modeline-string))) + (should result) + (should (string-match-p "⏺1" result))))) + +(ert-deftest test-cj/--transcription-modeline-string-multiple-active () + "Test modeline string with multiple active transcriptions." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running) + (proc2 "file2.m4a" nil running) + (proc3 "file3.m4a" nil running)))) + (let ((result (cj/--transcription-modeline-string))) + (should result) + (should (string-match-p "⏺3" result))))) + +(ert-deftest test-cj/--transcription-modeline-string-has-help-echo () + "Test that modeline string has help-echo property." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running)))) + (let ((result (cj/--transcription-modeline-string))) + (should (get-text-property 0 'help-echo result))))) + +(ert-deftest test-cj/--transcription-modeline-string-has-face () + "Test that modeline string has warning face." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running)))) + (let ((result (cj/--transcription-modeline-string))) + (should (eq 'warning (get-text-property 0 'face result)))))) + +(provide 'test-transcription-counter) +;;; test-transcription-counter.el ends here diff --git a/tests/test-transcription-duration.el b/tests/test-transcription-duration.el new file mode 100644 index 00000000..4f4e9a75 --- /dev/null +++ b/tests/test-transcription-duration.el @@ -0,0 +1,63 @@ +;;; test-transcription-duration.el --- Tests for duration calculation -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for cj/--transcription-duration function +;; Categories: Normal cases, Boundary cases + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +(require 'transcription-config) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-cj/--transcription-duration-zero-seconds () + "Test duration calculation for current time (should be 00:00)." + (let ((now (current-time))) + (should (string= (cj/--transcription-duration now) "00:00")))) + +(ert-deftest test-cj/--transcription-duration-30-seconds () + "Test duration calculation for 30 seconds ago." + (let ((start-time (time-subtract (current-time) (seconds-to-time 30)))) + (should (string= (cj/--transcription-duration start-time) "00:30")))) + +(ert-deftest test-cj/--transcription-duration-1-minute () + "Test duration calculation for 1 minute ago." + (let ((start-time (time-subtract (current-time) (seconds-to-time 60)))) + (should (string= (cj/--transcription-duration start-time) "01:00")))) + +(ert-deftest test-cj/--transcription-duration-2-minutes-30-seconds () + "Test duration calculation for 2:30 ago." + (let ((start-time (time-subtract (current-time) (seconds-to-time 150)))) + (should (string= (cj/--transcription-duration start-time) "02:30")))) + +(ert-deftest test-cj/--transcription-duration-10-minutes () + "Test duration calculation for 10 minutes ago." + (let ((start-time (time-subtract (current-time) (seconds-to-time 600)))) + (should (string= (cj/--transcription-duration start-time) "10:00")))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-cj/--transcription-duration-59-seconds () + "Test duration just before 1 minute." + (let ((start-time (time-subtract (current-time) (seconds-to-time 59)))) + (should (string= (cj/--transcription-duration start-time) "00:59")))) + +(ert-deftest test-cj/--transcription-duration-1-hour () + "Test duration for 1 hour (60 minutes)." + (let ((start-time (time-subtract (current-time) (seconds-to-time 3600)))) + (should (string= (cj/--transcription-duration start-time) "60:00")))) + +(ert-deftest test-cj/--transcription-duration-format () + "Test that duration is always in MM:SS format with zero-padding." + (let ((start-time (time-subtract (current-time) (seconds-to-time 65)))) + (let ((result (cj/--transcription-duration start-time))) + (should (string-match-p "^[0-9][0-9]:[0-9][0-9]$" result))))) + +(provide 'test-transcription-duration) +;;; test-transcription-duration.el ends here diff --git a/tests/test-transcription-log-cleanup.el b/tests/test-transcription-log-cleanup.el new file mode 100644 index 00000000..251e5ef9 --- /dev/null +++ b/tests/test-transcription-log-cleanup.el @@ -0,0 +1,49 @@ +;;; test-transcription-log-cleanup.el --- Tests for log cleanup logic -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for cj/--should-keep-log function +;; Categories: Normal cases, Boundary cases + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +(require 'transcription-config) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-cj/--should-keep-log-success-keep-disabled () + "Test that logs are deleted on success when keep-log is nil." + (let ((cj/transcription-keep-log-when-done nil)) + (should-not (cj/--should-keep-log t)))) + +(ert-deftest test-cj/--should-keep-log-success-keep-enabled () + "Test that logs are kept on success when keep-log is t." + (let ((cj/transcription-keep-log-when-done t)) + (should (cj/--should-keep-log t)))) + +(ert-deftest test-cj/--should-keep-log-error-keep-disabled () + "Test that logs are always kept on error, even if keep-log is nil." + (let ((cj/transcription-keep-log-when-done nil)) + (should (cj/--should-keep-log nil)))) + +(ert-deftest test-cj/--should-keep-log-error-keep-enabled () + "Test that logs are kept on error when keep-log is t." + (let ((cj/transcription-keep-log-when-done t)) + (should (cj/--should-keep-log nil)))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-cj/--should-keep-log-default-behavior () + "Test default behavior (should not keep on success)." + ;; Default is nil based on defcustom + (let ((cj/transcription-keep-log-when-done nil)) + (should-not (cj/--should-keep-log t)) + (should (cj/--should-keep-log nil)))) + +(provide 'test-transcription-log-cleanup) +;;; test-transcription-log-cleanup.el ends here diff --git a/tests/test-transcription-paths.el b/tests/test-transcription-paths.el new file mode 100644 index 00000000..69dc27e7 --- /dev/null +++ b/tests/test-transcription-paths.el @@ -0,0 +1,85 @@ +;;; test-transcription-paths.el --- Tests for transcription file path logic -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for cj/--transcription-output-files and cj/--transcription-script-path +;; Categories: Normal cases, Boundary cases, Error cases + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +(require 'transcription-config) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-cj/--transcription-output-files-simple () + "Test output file paths for simple filename." + (let ((result (cj/--transcription-output-files "meeting.m4a"))) + (should (string= (car result) "meeting.txt")) + (should (string= (cdr result) "meeting.log")))) + +(ert-deftest test-cj/--transcription-output-files-with-path () + "Test output file paths with full path." + (let ((result (cj/--transcription-output-files "/home/user/audio/podcast.mp3"))) + (should (string= (car result) "/home/user/audio/podcast.txt")) + (should (string= (cdr result) "/home/user/audio/podcast.log")))) + +(ert-deftest test-cj/--transcription-output-files-different-extensions () + "Test output files for various audio extensions." + (dolist (ext '("m4a" "mp3" "wav" "flac" "ogg")) + (let* ((input (format "audio.%s" ext)) + (result (cj/--transcription-output-files input))) + (should (string= (car result) "audio.txt")) + (should (string= (cdr result) "audio.log"))))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-cj/--transcription-output-files-multiple-dots () + "Test output files for filename with multiple dots." + (let ((result (cj/--transcription-output-files "meeting.2025-11-04.final.m4a"))) + (should (string= (car result) "meeting.2025-11-04.final.txt")) + (should (string= (cdr result) "meeting.2025-11-04.final.log")))) + +(ert-deftest test-cj/--transcription-output-files-no-extension () + "Test output files for filename without extension." + (let ((result (cj/--transcription-output-files "meeting"))) + (should (string= (car result) "meeting.txt")) + (should (string= (cdr result) "meeting.log")))) + +(ert-deftest test-cj/--transcription-output-files-spaces-in-name () + "Test output files for filename with spaces." + (let ((result (cj/--transcription-output-files "team meeting 2025.m4a"))) + (should (string= (car result) "team meeting 2025.txt")) + (should (string= (cdr result) "team meeting 2025.log")))) + +(ert-deftest test-cj/--transcription-output-files-special-chars () + "Test output files for filename with special characters." + (let ((result (cj/--transcription-output-files "meeting_(final).m4a"))) + (should (string= (car result) "meeting_(final).txt")) + (should (string= (cdr result) "meeting_(final).log")))) + +;; ----------------------------- Script Path Tests ----------------------------- + +(ert-deftest test-cj/--transcription-script-path-local-whisper () + "Test script path for local-whisper backend." + (let ((cj/transcribe-backend 'local-whisper)) + (should (string-suffix-p "scripts/local-whisper" + (cj/--transcription-script-path))))) + +(ert-deftest test-cj/--transcription-script-path-openai-api () + "Test script path for openai-api backend." + (let ((cj/transcribe-backend 'openai-api)) + (should (string-suffix-p "scripts/oai-transcribe" + (cj/--transcription-script-path))))) + +(ert-deftest test-cj/--transcription-script-path-absolute () + "Test that script path is absolute." + (let ((path (cj/--transcription-script-path))) + (should (file-name-absolute-p path)))) + +(provide 'test-transcription-paths) +;;; test-transcription-paths.el ends here diff --git a/tests/test-ui-buffer-status-colors.el b/tests/test-ui-buffer-status-colors.el new file mode 100644 index 00000000..bb905ad4 --- /dev/null +++ b/tests/test-ui-buffer-status-colors.el @@ -0,0 +1,221 @@ +;;; test-ui-buffer-status-colors.el --- Tests for buffer status colors -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for buffer status color system. +;; Tests the state detection logic used by both cursor color and modeline. + +;;; Code: + +(require 'ert) +(require 'user-constants) +(require 'ui-config) +(require 'modeline-config) + +;;; Color Constant Tests + +(ert-deftest test-buffer-status-colors-has-all-states () + "Test that all required states are defined in color alist." + (should (alist-get 'read-only cj/buffer-status-colors)) + (should (alist-get 'overwrite cj/buffer-status-colors)) + (should (alist-get 'modified cj/buffer-status-colors)) + (should (alist-get 'unmodified cj/buffer-status-colors))) + +(ert-deftest test-buffer-status-colors-values-are-strings () + "Test that all color values are strings (hex colors)." + (dolist (entry cj/buffer-status-colors) + (should (stringp (cdr entry))) + ;; Check if it looks like a hex color + (should (string-match-p "^#[0-9a-fA-F]\\{6\\}$" (cdr entry))))) + +;;; Cursor Color State Detection Tests + +(ert-deftest test-cursor-color-state-read-only-buffer () + "Test state detection for read-only buffer." + (with-temp-buffer + (setq buffer-read-only t) + (let* ((state (cond + (buffer-read-only 'read-only) + (overwrite-mode 'overwrite) + ((buffer-modified-p) 'modified) + (t 'unmodified)))) + (should (eq state 'read-only))))) + +(ert-deftest test-cursor-color-state-overwrite-mode () + "Test state detection for overwrite mode." + (with-temp-buffer + (setq buffer-read-only nil) + (overwrite-mode 1) + (let* ((state (cond + (buffer-read-only 'read-only) + (overwrite-mode 'overwrite) + ((buffer-modified-p) 'modified) + (t 'unmodified)))) + (should (eq state 'overwrite))))) + +(ert-deftest test-cursor-color-state-modified-buffer () + "Test state detection for modified buffer." + (with-temp-buffer + (setq buffer-read-only nil) + (insert "test") + (set-buffer-modified-p t) + (let* ((state (cond + (buffer-read-only 'read-only) + (overwrite-mode 'overwrite) + ((buffer-modified-p) 'modified) + (t 'unmodified)))) + (should (eq state 'modified))))) + +(ert-deftest test-cursor-color-state-unmodified-buffer () + "Test state detection for unmodified buffer." + (with-temp-buffer + (setq buffer-read-only nil) + (set-buffer-modified-p nil) + (let* ((state (cond + (buffer-read-only 'read-only) + (overwrite-mode 'overwrite) + ((buffer-modified-p) 'modified) + (t 'unmodified)))) + (should (eq state 'unmodified))))) + +(ert-deftest test-cursor-color-state-priority-read-only-over-modified () + "Test that read-only state takes priority over modified state." + (with-temp-buffer + (insert "test") + (set-buffer-modified-p t) + (setq buffer-read-only t) + (let* ((state (cond + (buffer-read-only 'read-only) + (overwrite-mode 'overwrite) + ((buffer-modified-p) 'modified) + (t 'unmodified)))) + (should (eq state 'read-only))))) + +(ert-deftest test-cursor-color-state-priority-overwrite-over-modified () + "Test that overwrite mode takes priority over modified state." + (with-temp-buffer + (insert "test") + (set-buffer-modified-p t) + (overwrite-mode 1) + (let* ((state (cond + (buffer-read-only 'read-only) + (overwrite-mode 'overwrite) + ((buffer-modified-p) 'modified) + (t 'unmodified)))) + (should (eq state 'overwrite))))) + +;;; Integration Tests - Cursor Color Function + +(ert-deftest test-cursor-color-function-exists () + "Test that cursor color function is defined." + (should (fboundp 'cj/set-cursor-color-according-to-mode))) + +(ert-deftest test-cursor-color-returns-correct-color-for-read-only () + "Test cursor color function returns red for read-only buffer." + (with-temp-buffer + (setq buffer-read-only t) + (let* ((state (cond + (buffer-read-only 'read-only) + (overwrite-mode 'overwrite) + ((buffer-modified-p) 'modified) + (t 'unmodified))) + (color (alist-get state cj/buffer-status-colors))) + (should (equal color "#f06a3f"))))) + +(ert-deftest test-cursor-color-returns-correct-color-for-overwrite () + "Test cursor color function returns gold for overwrite mode." + (with-temp-buffer + (overwrite-mode 1) + (let* ((state (cond + (buffer-read-only 'read-only) + (overwrite-mode 'overwrite) + ((buffer-modified-p) 'modified) + (t 'unmodified))) + (color (alist-get state cj/buffer-status-colors))) + (should (equal color "#c48702"))))) + +(ert-deftest test-cursor-color-returns-correct-color-for-modified () + "Test cursor color function returns green for modified buffer." + (with-temp-buffer + (insert "test") + (set-buffer-modified-p t) + (let* ((state (cond + (buffer-read-only 'read-only) + (overwrite-mode 'overwrite) + ((buffer-modified-p) 'modified) + (t 'unmodified))) + (color (alist-get state cj/buffer-status-colors))) + (should (equal color "#64aa0f"))))) + +(ert-deftest test-cursor-color-returns-correct-color-for-unmodified () + "Test cursor color function returns white for unmodified buffer." + (with-temp-buffer + (set-buffer-modified-p nil) + (let* ((state (cond + (buffer-read-only 'read-only) + (overwrite-mode 'overwrite) + ((buffer-modified-p) 'modified) + (t 'unmodified))) + (color (alist-get state cj/buffer-status-colors))) + (should (equal color "#ffffff"))))) + +;;; Modeline Integration Tests + +(ert-deftest test-modeline-buffer-name-variable-exists () + "Test that modeline buffer name variable is defined." + (should (boundp 'cj/modeline-buffer-name))) + +(ert-deftest test-modeline-buffer-name-is-mode-line-construct () + "Test that modeline buffer name is a valid mode-line construct." + (should (listp cj/modeline-buffer-name)) + (should (eq (car cj/modeline-buffer-name) :eval))) + +;;; Edge Cases + +(ert-deftest test-buffer-status-new-buffer-starts-unmodified () + "Test that new buffer starts in unmodified state." + (with-temp-buffer + (let* ((state (cond + (buffer-read-only 'read-only) + (overwrite-mode 'overwrite) + ((buffer-modified-p) 'modified) + (t 'unmodified)))) + (should (eq state 'unmodified))))) + +(ert-deftest test-buffer-status-insert-makes-modified () + "Test that inserting text changes state to modified." + (with-temp-buffer + ;; Initially unmodified + (set-buffer-modified-p nil) + (let ((state1 (cond + (buffer-read-only 'read-only) + (overwrite-mode 'overwrite) + ((buffer-modified-p) 'modified) + (t 'unmodified)))) + (should (eq state1 'unmodified))) + + ;; Insert text + (insert "test") + (let ((state2 (cond + (buffer-read-only 'read-only) + (overwrite-mode 'overwrite) + ((buffer-modified-p) 'modified) + (t 'unmodified)))) + (should (eq state2 'modified))))) + +(ert-deftest test-buffer-status-explicit-unmodify () + "Test that explicitly setting unmodified works." + (with-temp-buffer + (insert "test") + (should (buffer-modified-p)) + + ;; Explicitly set unmodified + (set-buffer-modified-p nil) + (let ((state (cond + (buffer-read-only 'read-only) + (overwrite-mode 'overwrite) + ((buffer-modified-p) 'modified) + (t 'unmodified)))) + (should (eq state 'unmodified))))) + +(provide 'test-ui-buffer-status-colors) +;;; test-ui-buffer-status-colors.el ends here diff --git a/tests/test-ui-cursor-color-integration.el b/tests/test-ui-cursor-color-integration.el new file mode 100644 index 00000000..00b7f57b --- /dev/null +++ b/tests/test-ui-cursor-color-integration.el @@ -0,0 +1,164 @@ +;;; test-ui-cursor-color-integration.el --- Integration tests for cursor color -*- lexical-binding: t; -*- + +;;; Commentary: +;; Integration tests for cursor color hook behavior. +;; Tests that cursor color actually updates when switching buffers, +;; modifying files, etc. + +;;; Code: + +(require 'ert) +(require 'user-constants) +(require 'ui-config) + +;;; Hook Integration Tests + +(ert-deftest test-cursor-color-integration-post-command-hook-installed () + "Test that post-command-hook is installed." + (should (member 'cj/set-cursor-color-according-to-mode post-command-hook))) + +(ert-deftest test-cursor-color-integration-function-runs-without-error () + "Test that cursor color function runs without error in various buffers." + (with-temp-buffer + (should-not (condition-case err + (progn + (cj/set-cursor-color-according-to-mode) + nil) + (error err)))) + + (with-temp-buffer + (setq buffer-read-only t) + (should-not (condition-case err + (progn + (cj/set-cursor-color-according-to-mode) + nil) + (error err))))) + +(ert-deftest test-cursor-color-integration-internal-buffers-ignored () + "Test that internal buffers (starting with space) are ignored." + (let ((internal-buf (get-buffer-create " *test-internal*")) + (cj/-cursor-last-color nil) + (cj/-cursor-last-buffer nil)) + (unwind-protect + (with-current-buffer internal-buf + (cj/set-cursor-color-according-to-mode) + ;; Cursor state should not have been updated + (should-not cj/-cursor-last-buffer)) + (kill-buffer internal-buf)))) + +(ert-deftest test-cursor-color-integration-normal-buffers-processed () + "Test that normal buffers (not starting with space) are processed." + (let ((normal-buf (get-buffer-create "test-normal")) + (cj/-cursor-last-color nil) + (cj/-cursor-last-buffer nil)) + (unwind-protect + (with-current-buffer normal-buf + (cj/set-cursor-color-according-to-mode) + ;; Cursor state should have been updated + (should (equal cj/-cursor-last-buffer "test-normal"))) + (kill-buffer normal-buf)))) + +(ert-deftest test-cursor-color-integration-cache-prevents-redundant-updates () + "Test that cache prevents redundant cursor color updates." + (let* ((normal-buf (generate-new-buffer "test-cache")) + (call-count 0) + (advice-fn (lambda (&rest _) (setq call-count (1+ call-count))))) + (unwind-protect + (progn + (advice-add 'set-cursor-color :before advice-fn) + (with-current-buffer normal-buf + ;; First call - cache matches, no update + (let ((cj/-cursor-last-color "#ffffff") + (cj/-cursor-last-buffer (buffer-name))) + (cj/set-cursor-color-according-to-mode) + (should (= call-count 0))) ; Cached, no update needed + + ;; Modify buffer and clear cache - should update + (insert "test") + (let ((cj/-cursor-last-buffer nil)) ; Force update + (cj/set-cursor-color-according-to-mode) + (should (= call-count 1))))) ; New state, should update + (advice-remove 'set-cursor-color advice-fn) + (kill-buffer normal-buf)))) + +(ert-deftest test-cursor-color-integration-different-buffers-different-colors () + "Test that switching between buffers with different states updates cursor." + (let ((buf1 (generate-new-buffer "test1")) + (buf2 (generate-new-buffer "test2")) + (cj/-cursor-last-color nil) + (cj/-cursor-last-buffer nil)) + (unwind-protect + (progn + ;; Set buf1 to read-only + (with-current-buffer buf1 + (setq buffer-read-only t) + (cj/set-cursor-color-according-to-mode) + (should (equal cj/-cursor-last-color "#f06a3f"))) ; Red + + ;; Set buf2 to normal + (with-current-buffer buf2 + (setq buffer-read-only nil) + (set-buffer-modified-p nil) + (cj/set-cursor-color-according-to-mode) + (should (equal cj/-cursor-last-color "#ffffff")))) ; White + (kill-buffer buf1) + (kill-buffer buf2)))) + +(ert-deftest test-cursor-color-integration-buffer-modification-changes-color () + "Test that modifying a buffer changes cursor from white to green." + (let ((normal-buf (generate-new-buffer "test-mod")) + (cj/-cursor-last-color nil) + (cj/-cursor-last-buffer nil)) + (unwind-protect + (with-current-buffer normal-buf + ;; Start unmodified + (set-buffer-modified-p nil) + (cj/set-cursor-color-according-to-mode) + (should (equal cj/-cursor-last-color "#ffffff")) ; White + + ;; Modify buffer + (insert "test") + (should (buffer-modified-p)) + ;; Reset last buffer to force update + (setq cj/-cursor-last-buffer nil) + (cj/set-cursor-color-according-to-mode) + (should (equal cj/-cursor-last-color "#64aa0f"))) ; Green + (kill-buffer normal-buf)))) + +(ert-deftest test-cursor-color-integration-save-changes-color-back () + "Test that saving a modified buffer changes cursor from green to white." + (let ((test-file (make-temp-file "test-cursor-")) + (cj/-cursor-last-color nil) + (cj/-cursor-last-buffer nil)) + (unwind-protect + (progn + ;; Create and modify file + (with-current-buffer (find-file-noselect test-file) + (insert "test") + (should (buffer-modified-p)) + (cj/set-cursor-color-according-to-mode) + (should (equal cj/-cursor-last-color "#64aa0f")) ; Green + + ;; Save file + (save-buffer) + (should-not (buffer-modified-p)) + (cj/set-cursor-color-according-to-mode) + (should (equal cj/-cursor-last-color "#ffffff")) ; White + (kill-buffer))) + (delete-file test-file)))) + +;;; Performance Tests + +(ert-deftest test-cursor-color-integration-multiple-calls-efficient () + "Test that multiple rapid calls don't cause performance issues." + (with-temp-buffer + (let ((start-time (current-time))) + ;; Call 1000 times + (dotimes (_ 1000) + (cj/set-cursor-color-according-to-mode)) + (let ((elapsed (float-time (time-subtract (current-time) start-time)))) + ;; Should complete in less than 1 second (cache makes this very fast) + (should (< elapsed 1.0)))))) + +(provide 'test-ui-cursor-color-integration) +;;; test-ui-cursor-color-integration.el ends here diff --git a/tests/test-undead-buffers.el b/tests/test-undead-buffers.el index 606972be..d08649b7 100644 --- a/tests/test-undead-buffers.el +++ b/tests/test-undead-buffers.el @@ -1,7 +1,16 @@ ;;; test-undead-buffers.el --- -*- coding: utf-8; lexical-binding: t; -*- ;;; Commentary: -;; +;; ERT tests for undead-buffers.el. +;; Exercises kill vs bury decisions driven by cj/undead-buffer-list +;; and window-management helpers. +;; Coverage: +;; - cj/kill-buffer-or-bury-alive: kills non-listed buffers; buries listed; C-u adds to list +;; - cj/kill-buffer-and-window: deletes selected window, then kill/bury buffer as appropriate +;; - cj/kill-other-window: deletes the other window, then kill/bury that buffer +;; - cj/kill-all-other-buffers-and-windows: keeps only current window/buffer +;; Tests isolate state with temporary buffers/windows and restore cj/undead-buffer-list. +;; Note: bury-buffer does not delete windows; tests assert buffer liveness, not window removal. ;;; Code: @@ -10,200 +19,99 @@ (require 'undead-buffers) (ert-deftest undead-buffers/kill-or-bury-when-not-in-list-kills () - "cj/kill-buffer-or-bury-alive should kill a buffer not in `cj/buffer-bury-alive-list'." + "cj/kill-buffer-or-bury-alive should kill a buffer not in `cj/undead-buffer-list'." (let* ((buf (generate-new-buffer "test-not-in-list")) - (orig (copy-sequence cj/buffer-bury-alive-list))) - (unwind-protect - (progn - (should (buffer-live-p buf)) - (cj/kill-buffer-or-bury-alive (buffer-name buf)) - (should-not (buffer-live-p buf))) - (setq cj/buffer-bury-alive-list orig) - (when (buffer-live-p buf) (kill-buffer buf))))) + (orig (copy-sequence cj/undead-buffer-list))) + (unwind-protect + (progn + (should (buffer-live-p buf)) + (cj/kill-buffer-or-bury-alive (buffer-name buf)) + (should-not (buffer-live-p buf))) + (setq cj/undead-buffer-list orig) + (when (buffer-live-p buf) (kill-buffer buf))))) (ert-deftest undead-buffers/kill-or-bury-when-in-list-buries () "cj/kill-buffer-or-bury-alive should bury (not kill) a buffer in the list." (let* ((name "*dashboard*") ; an element already in the default list - (buf (generate-new-buffer name)) - (orig (copy-sequence cj/buffer-bury-alive-list)) - win-was) - (unwind-protect - (progn - (add-to-list 'cj/buffer-bury-alive-list name) - ;; show it in a temporary window so we can detect bury - (setq win-was (display-buffer buf)) - (cj/kill-buffer-or-bury-alive name) - ;; bury should leave it alive - (should (buffer-live-p buf)) - ;; note: Emacs’s `bury-buffer` does not delete windows by default, - ;; so we no longer assert that no window shows it. - ) - ;; cleanup - (setq cj/buffer-bury-alive-list orig) - (delete-windows-on buf) - (kill-buffer buf)))) + (buf (generate-new-buffer name)) + (orig (copy-sequence cj/undead-buffer-list)) + win-was) + (unwind-protect + (progn + (add-to-list 'cj/undead-buffer-list name) + ;; show it in a temporary window so we can detect bury + (setq win-was (display-buffer buf)) + (cj/kill-buffer-or-bury-alive name) + ;; bury should leave it alive + (should (buffer-live-p buf)) + ;; note: Emacs's `bury-buffer` does not delete windows by default, + ;; so we no longer assert that no window shows it. + ) + ;; cleanup + (setq cj/undead-buffer-list orig) + (delete-windows-on buf) + (kill-buffer buf)))) (ert-deftest undead-buffers/kill-or-bury-adds-to-list-with-prefix () "Calling `cj/kill-buffer-or-bury-alive' with a prefix arg should add the buffer to the list." (let* ((buf (generate-new-buffer "test-add-prefix")) - (orig (copy-sequence cj/buffer-bury-alive-list))) - (unwind-protect - (progn - (let ((current-prefix-arg '(4))) - (cj/kill-buffer-or-bury-alive (buffer-name buf))) - (should (member (buffer-name buf) cj/buffer-bury-alive-list))) - (setq cj/buffer-bury-alive-list orig) - (kill-buffer buf)))) + (orig (copy-sequence cj/undead-buffer-list))) + (unwind-protect + (progn + (let ((current-prefix-arg '(4))) + (cj/kill-buffer-or-bury-alive (buffer-name buf))) + (should (member (buffer-name buf) cj/undead-buffer-list))) + (setq cj/undead-buffer-list orig) + (kill-buffer buf)))) (ert-deftest undead-buffers/kill-buffer-and-window-removes-window () "cj/kill-buffer-and-window should delete the current window and kill/bury its buffer." (let* ((buf (generate-new-buffer "test-kill-and-win")) - (orig (copy-sequence cj/buffer-bury-alive-list))) - (split-window) ; now two windows - (let ((win (next-window))) - (set-window-buffer win buf) - (select-window win) - (cj/kill-buffer-and-window) - (should-not (window-live-p win)) - (unless (member (buffer-name buf) orig) - (should-not (buffer-live-p buf)))) - (setq cj/buffer-bury-alive-list orig))) + (orig (copy-sequence cj/undead-buffer-list))) + (split-window) ; now two windows + (let ((win (next-window))) + (set-window-buffer win buf) + (select-window win) + (cj/kill-buffer-and-window) + (should-not (window-live-p win)) + (unless (member (buffer-name buf) orig) + (should-not (buffer-live-p buf)))) + (setq cj/undead-buffer-list orig))) (ert-deftest undead-buffers/kill-other-window-deletes-that-window () "cj/kill-other-window should delete the *other* window and kill/bury its buffer." (let* ((buf1 (current-buffer)) - (buf2 (generate-new-buffer "test-other-window")) - (orig (copy-sequence cj/buffer-bury-alive-list))) - (split-window) - (let* ((win1 (selected-window)) - (win2 (next-window win1))) - (set-window-buffer win2 buf2) - ;; stay on the original window - (select-window win1) - (cj/kill-other-window) - (should-not (window-live-p win2)) - (unless (member (buffer-name buf2) orig) - (should-not (buffer-live-p buf2)))) - (setq cj/buffer-bury-alive-list orig))) + (buf2 (generate-new-buffer "test-other-window")) + (orig (copy-sequence cj/undead-buffer-list))) + (split-window) + (let* ((win1 (selected-window)) + (win2 (next-window win1))) + (set-window-buffer win2 buf2) + ;; stay on the original window + (select-window win1) + (cj/kill-other-window) + (should-not (window-live-p win2)) + (unless (member (buffer-name buf2) orig) + (should-not (buffer-live-p buf2)))) + (setq cj/undead-buffer-list orig))) (ert-deftest undead-buffers/kill-all-other-buffers-and-windows-keeps-only-current () "cj/kill-all-other-buffers-and-windows should delete other windows and kill/bury all other buffers." (let* ((main (current-buffer)) - (extra (generate-new-buffer "test-all-others")) - (orig (copy-sequence cj/buffer-bury-alive-list))) - (split-window) - (set-window-buffer (next-window) extra) - (cj/kill-all-other-buffers-and-windows) - (should (one-window-p)) - ;; main buffer still exists - (should (buffer-live-p main)) - ;; extra buffer either buried or killed - (unless (member (buffer-name extra) orig) - (should-not (buffer-live-p extra))) - ;; cleanup - (setq cj/buffer-bury-alive-list orig) - (when (buffer-live-p extra) (kill-buffer extra)))) - -;; --------------------------------- ERT Tests --------------------------------- -;; Run these tests with M-x ert RET t RET - -(require 'ert) -(require 'cl-lib) - -(ert-deftest undead-buffers/kill-or-bury-when-not-in-list-kills () - "cj/kill-buffer-or-bury-alive should kill a buffer not in `cj/buffer-bury-alive-list'." - (let* ((buf (generate-new-buffer "test-not-in-list")) - (orig (copy-sequence cj/buffer-bury-alive-list))) - (unwind-protect - (progn - (should (buffer-live-p buf)) - (cj/kill-buffer-or-bury-alive (buffer-name buf)) - (should-not (buffer-live-p buf))) - (setq cj/buffer-bury-alive-list orig) - (when (buffer-live-p buf) (kill-buffer buf))))) - -(ert-deftest undead-buffers/kill-or-bury-when-in-list-buries () - "cj/kill-buffer-or-bury-alive should bury (not kill) a buffer in the list." - (let* ((name "*dashboard*") ; an element already in the default list - (buf (generate-new-buffer name)) - (orig (copy-sequence cj/buffer-bury-alive-list)) - win-was) - (unwind-protect - (progn - (add-to-list 'cj/buffer-bury-alive-list name) - ;; show it in a temporary window so we can detect bury - (setq win-was (display-buffer buf)) - (cj/kill-buffer-or-bury-alive name) - ;; bury should leave it alive - (should (buffer-live-p buf)) - ;; note: Emacs’s `bury-buffer` does not delete windows by default, - ;; so we no longer assert that no window shows it. - ) - ;; cleanup - (setq cj/buffer-bury-alive-list orig) - (delete-windows-on buf) - (kill-buffer buf)))) - -(ert-deftest undead-buffers/kill-or-bury-adds-to-list-with-prefix () - "Calling `cj/kill-buffer-or-bury-alive' with a prefix arg should add the buffer to the list." - (let* ((buf (generate-new-buffer "test-add-prefix")) - (orig (copy-sequence cj/buffer-bury-alive-list))) - (unwind-protect - (progn - (let ((current-prefix-arg '(4))) - (cj/kill-buffer-or-bury-alive (buffer-name buf))) - (should (member (buffer-name buf) cj/buffer-bury-alive-list))) - (setq cj/buffer-bury-alive-list orig) - (kill-buffer buf)))) - -(ert-deftest undead-buffers/kill-buffer-and-window-removes-window () - "cj/kill-buffer-and-window should delete the current window and kill/bury its buffer." - (let* ((buf (generate-new-buffer "test-kill-and-win")) - (orig (copy-sequence cj/buffer-bury-alive-list))) - (split-window) ; now two windows - (let ((win (next-window))) - (set-window-buffer win buf) - (select-window win) - (cj/kill-buffer-and-window) - (should-not (window-live-p win)) - (unless (member (buffer-name buf) orig) - (should-not (buffer-live-p buf)))) - (setq cj/buffer-bury-alive-list orig))) - -(ert-deftest undead-buffers/kill-other-window-deletes-that-window () - "cj/kill-other-window should delete the *other* window and kill/bury its buffer." - (let* ((buf1 (current-buffer)) - (buf2 (generate-new-buffer "test-other-window")) - (orig (copy-sequence cj/buffer-bury-alive-list))) - (split-window) - (let* ((win1 (selected-window)) - (win2 (next-window win1))) - (set-window-buffer win2 buf2) - ;; stay on the original window - (select-window win1) - (cj/kill-other-window) - (should-not (window-live-p win2)) - (unless (member (buffer-name buf2) orig) - (should-not (buffer-live-p buf2)))) - (setq cj/buffer-bury-alive-list orig))) - -(ert-deftest undead-buffers/kill-all-other-buffers-and-windows-keeps-only-current () - "cj/kill-all-other-buffers-and-windows should delete other windows and kill/bury all other buffers." - (let* ((main (current-buffer)) - (extra (generate-new-buffer "test-all-others")) - (orig (copy-sequence cj/buffer-bury-alive-list))) - (split-window) - (set-window-buffer (next-window) extra) - (cj/kill-all-other-buffers-and-windows) - (should (one-window-p)) - ;; main buffer still exists - (should (buffer-live-p main)) - ;; extra buffer either buried or killed - (unless (member (buffer-name extra) orig) - (should-not (buffer-live-p extra))) - ;; cleanup - (setq cj/buffer-bury-alive-list orig) - (when (buffer-live-p extra) (kill-buffer extra)))) + (extra (generate-new-buffer "test-all-others")) + (orig (copy-sequence cj/undead-buffer-list))) + (split-window) + (set-window-buffer (next-window) extra) + (cj/kill-all-other-buffers-and-windows) + (should (one-window-p)) + ;; main buffer still exists + (should (buffer-live-p main)) + ;; extra buffer either buried or killed + (unless (member (buffer-name extra) orig) + (should-not (buffer-live-p extra))) + ;; cleanup + (setq cj/undead-buffer-list orig) + (when (buffer-live-p extra) (kill-buffer extra)))) (provide 'test-undead-buffers) ;;; test-undead-buffers.el ends here. diff --git a/tests/test-video-audio-recording-check-ffmpeg.el b/tests/test-video-audio-recording-check-ffmpeg.el new file mode 100644 index 00000000..5c264b64 --- /dev/null +++ b/tests/test-video-audio-recording-check-ffmpeg.el @@ -0,0 +1,46 @@ +;;; test-video-audio-recording-check-ffmpeg.el --- Tests for cj/recording-check-ffmpeg -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-check-ffmpeg function. +;; Tests detection of ffmpeg availability. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-check-ffmpeg-normal-ffmpeg-found-returns-t () + "Test that function returns t when ffmpeg is found." + (cl-letf (((symbol-function 'executable-find) + (lambda (cmd) + (when (equal cmd "ffmpeg") "/usr/bin/ffmpeg")))) + (let ((result (cj/recording-check-ffmpeg))) + (should (eq t result))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-check-ffmpeg-error-ffmpeg-not-found-signals-error () + "Test that function signals user-error when ffmpeg is not found." + (cl-letf (((symbol-function 'executable-find) + (lambda (_cmd) nil))) + (should-error (cj/recording-check-ffmpeg) :type 'user-error))) + +(ert-deftest test-video-audio-recording-check-ffmpeg-error-message-mentions-pacman () + "Test that error message includes installation command." + (cl-letf (((symbol-function 'executable-find) + (lambda (_cmd) nil))) + (condition-case err + (cj/recording-check-ffmpeg) + (user-error + (should (string-match-p "pacman -S ffmpeg" (error-message-string err))))))) + +(provide 'test-video-audio-recording-check-ffmpeg) +;;; test-video-audio-recording-check-ffmpeg.el ends here diff --git a/tests/test-video-audio-recording-ffmpeg-functions.el b/tests/test-video-audio-recording-ffmpeg-functions.el new file mode 100644 index 00000000..e82614e2 --- /dev/null +++ b/tests/test-video-audio-recording-ffmpeg-functions.el @@ -0,0 +1,361 @@ +;;; test-video-audio-recording-ffmpeg-functions.el --- Tests for ffmpeg recording functions -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/ffmpeg-record-video, cj/ffmpeg-record-audio, +;; cj/video-recording-stop, and cj/audio-recording-stop functions. +;; Tests process creation, sentinel attachment, and cleanup. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Stub directory variables +(defvar video-recordings-dir "/tmp/video-recordings/") +(defvar audio-recordings-dir "/tmp/audio-recordings/") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Setup and Teardown + +(defun test-ffmpeg-setup () + "Reset all variables before each test." + (setq cj/video-recording-ffmpeg-process nil) + (setq cj/audio-recording-ffmpeg-process nil) + (setq cj/recording-mic-device "test-mic-device") + (setq cj/recording-system-device "test-monitor-device") + (setq cj/recording-mic-boost 2.0) + (setq cj/recording-system-volume 0.5)) + +(defun test-ffmpeg-teardown () + "Clean up after each test." + (when cj/video-recording-ffmpeg-process + (ignore-errors (delete-process cj/video-recording-ffmpeg-process))) + (when cj/audio-recording-ffmpeg-process + (ignore-errors (delete-process cj/audio-recording-ffmpeg-process))) + (setq cj/video-recording-ffmpeg-process nil) + (setq cj/audio-recording-ffmpeg-process nil) + (setq cj/recording-mic-device nil) + (setq cj/recording-system-device nil)) + +;;; Video Recording - Normal Cases + +(ert-deftest test-video-audio-recording-ffmpeg-record-video-normal-creates-process () + "Test that video recording creates a process." + (test-ffmpeg-setup) + (unwind-protect + (let ((process-created nil)) + (cl-letf (((symbol-function 'start-process-shell-command) + (lambda (_name _buffer _command) + (setq process-created t) + (make-process :name "fake-video" :command '("sleep" "1000"))))) + (cj/ffmpeg-record-video video-recordings-dir) + (should process-created) + (should cj/video-recording-ffmpeg-process))) + (test-ffmpeg-teardown))) + +(ert-deftest test-video-audio-recording-ffmpeg-record-video-normal-attaches-sentinel () + "Test that video recording attaches sentinel to process." + (test-ffmpeg-setup) + (unwind-protect + (let ((sentinel-attached nil)) + (cl-letf (((symbol-function 'start-process-shell-command) + (lambda (_name _buffer _command) + (make-process :name "fake-video" :command '("sleep" "1000")))) + ((symbol-function 'set-process-sentinel) + (lambda (_proc sentinel) + (should (eq sentinel #'cj/recording-process-sentinel)) + (setq sentinel-attached t)))) + (cj/ffmpeg-record-video video-recordings-dir) + (should sentinel-attached))) + (test-ffmpeg-teardown))) + +(ert-deftest test-video-audio-recording-ffmpeg-record-video-normal-updates-modeline () + "Test that video recording triggers modeline update." + (test-ffmpeg-setup) + (unwind-protect + (let ((update-called nil)) + (cl-letf (((symbol-function 'start-process-shell-command) + (lambda (_name _buffer _command) + (make-process :name "fake-video" :command '("sleep" "1000")))) + ((symbol-function 'force-mode-line-update) + (lambda (&optional _all) (setq update-called t)))) + (cj/ffmpeg-record-video video-recordings-dir) + (should update-called))) + (test-ffmpeg-teardown))) + +(ert-deftest test-video-audio-recording-ffmpeg-record-video-normal-uses-device-settings () + "Test that video recording uses configured devices and volume settings." + (test-ffmpeg-setup) + (unwind-protect + (let ((command nil)) + (cl-letf (((symbol-function 'start-process-shell-command) + (lambda (_name _buffer cmd) + (setq command cmd) + (make-process :name "fake-video" :command '("sleep" "1000"))))) + (cj/ffmpeg-record-video video-recordings-dir) + (should (string-match-p "test-mic-device" command)) + (should (string-match-p "test-monitor-device" command)) + (should (string-match-p "2\\.0" command)) ; mic boost + (should (string-match-p "0\\.5" command)))) ; system volume + (test-ffmpeg-teardown))) + +;;; Audio Recording - Normal Cases + +(ert-deftest test-video-audio-recording-ffmpeg-record-audio-normal-creates-process () + "Test that audio recording creates a process." + (test-ffmpeg-setup) + (unwind-protect + (let ((process-created nil)) + (cl-letf (((symbol-function 'start-process-shell-command) + (lambda (_name _buffer _command) + (setq process-created t) + (make-process :name "fake-audio" :command '("sleep" "1000"))))) + (cj/ffmpeg-record-audio audio-recordings-dir) + (should process-created) + (should cj/audio-recording-ffmpeg-process))) + (test-ffmpeg-teardown))) + +(ert-deftest test-video-audio-recording-ffmpeg-record-audio-normal-attaches-sentinel () + "Test that audio recording attaches sentinel to process." + (test-ffmpeg-setup) + (unwind-protect + (let ((sentinel-attached nil)) + (cl-letf (((symbol-function 'start-process-shell-command) + (lambda (_name _buffer _command) + (make-process :name "fake-audio" :command '("sleep" "1000")))) + ((symbol-function 'set-process-sentinel) + (lambda (_proc sentinel) + (should (eq sentinel #'cj/recording-process-sentinel)) + (setq sentinel-attached t)))) + (cj/ffmpeg-record-audio audio-recordings-dir) + (should sentinel-attached))) + (test-ffmpeg-teardown))) + +(ert-deftest test-video-audio-recording-ffmpeg-record-audio-normal-updates-modeline () + "Test that audio recording triggers modeline update." + (test-ffmpeg-setup) + (unwind-protect + (let ((update-called nil)) + (cl-letf (((symbol-function 'start-process-shell-command) + (lambda (_name _buffer _command) + (make-process :name "fake-audio" :command '("sleep" "1000")))) + ((symbol-function 'force-mode-line-update) + (lambda (&optional _all) (setq update-called t)))) + (cj/ffmpeg-record-audio audio-recordings-dir) + (should update-called))) + (test-ffmpeg-teardown))) + +(ert-deftest test-video-audio-recording-ffmpeg-record-audio-normal-creates-m4a-file () + "Test that audio recording creates .m4a file." + (test-ffmpeg-setup) + (unwind-protect + (let ((command nil)) + (cl-letf (((symbol-function 'start-process-shell-command) + (lambda (_name _buffer cmd) + (setq command cmd) + (make-process :name "fake-audio" :command '("sleep" "1000"))))) + (cj/ffmpeg-record-audio audio-recordings-dir) + (should (string-match-p "\\.m4a" command)))) + (test-ffmpeg-teardown))) + +;;; Stop Functions - Normal Cases + +(ert-deftest test-video-audio-recording-video-stop-normal-interrupts-process () + "Test that stopping video recording interrupts the process." + (test-ffmpeg-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-video" :command '("sleep" "1000"))) + (interrupt-called nil)) + (setq cj/video-recording-ffmpeg-process fake-process) + (cl-letf (((symbol-function 'interrupt-process) + (lambda (_proc) (setq interrupt-called t)))) + (cj/video-recording-stop) + (should interrupt-called)) + (delete-process fake-process)) + (test-ffmpeg-teardown))) + +(ert-deftest test-video-audio-recording-video-stop-normal-clears-variable () + "Test that stopping video recording clears the process variable." + (test-ffmpeg-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-video" :command '("sleep" "1000")))) + (setq cj/video-recording-ffmpeg-process fake-process) + (cj/video-recording-stop) + (should (null cj/video-recording-ffmpeg-process)) + (delete-process fake-process)) + (test-ffmpeg-teardown))) + +(ert-deftest test-video-audio-recording-video-stop-normal-updates-modeline () + "Test that stopping video recording updates modeline." + (test-ffmpeg-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-video" :command '("sleep" "1000"))) + (update-called nil)) + (setq cj/video-recording-ffmpeg-process fake-process) + (cl-letf (((symbol-function 'force-mode-line-update) + (lambda (&optional _all) (setq update-called t)))) + (cj/video-recording-stop) + (should update-called)) + (delete-process fake-process)) + (test-ffmpeg-teardown))) + +(ert-deftest test-video-audio-recording-audio-stop-normal-interrupts-process () + "Test that stopping audio recording interrupts the process." + (test-ffmpeg-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000"))) + (interrupt-called nil)) + (setq cj/audio-recording-ffmpeg-process fake-process) + (cl-letf (((symbol-function 'interrupt-process) + (lambda (_proc) (setq interrupt-called t)))) + (cj/audio-recording-stop) + (should interrupt-called)) + (delete-process fake-process)) + (test-ffmpeg-teardown))) + +(ert-deftest test-video-audio-recording-audio-stop-normal-clears-variable () + "Test that stopping audio recording clears the process variable." + (test-ffmpeg-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000")))) + (setq cj/audio-recording-ffmpeg-process fake-process) + (cj/audio-recording-stop) + (should (null cj/audio-recording-ffmpeg-process)) + (delete-process fake-process)) + (test-ffmpeg-teardown))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-video-stop-boundary-no-process-displays-message () + "Test that stopping when no video recording shows message." + (test-ffmpeg-setup) + (unwind-protect + (let ((message-text nil)) + (setq cj/video-recording-ffmpeg-process nil) + (cl-letf (((symbol-function 'message) + (lambda (fmt &rest args) (setq message-text (apply #'format fmt args))))) + (cj/video-recording-stop) + (should (string-match-p "No video recording" message-text)))) + (test-ffmpeg-teardown))) + +(ert-deftest test-video-audio-recording-audio-stop-boundary-no-process-displays-message () + "Test that stopping when no audio recording shows message." + (test-ffmpeg-setup) + (unwind-protect + (let ((message-text nil)) + (setq cj/audio-recording-ffmpeg-process nil) + (cl-letf (((symbol-function 'message) + (lambda (fmt &rest args) (setq message-text (apply #'format fmt args))))) + (cj/audio-recording-stop) + (should (string-match-p "No audio recording" message-text)))) + (test-ffmpeg-teardown))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-video-stop-error-interrupt-process-fails () + "Test that video stop handles interrupt-process failure gracefully." + (test-ffmpeg-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-video" :command '("sleep" "1000"))) + (error-raised nil)) + (setq cj/video-recording-ffmpeg-process fake-process) + (cl-letf (((symbol-function 'interrupt-process) + (lambda (_proc) (error "Interrupt failed")))) + ;; Should handle the error without crashing + (condition-case err + (cj/video-recording-stop) + (error (setq error-raised t))) + ;; Error should propagate (function doesn't catch it) + (should error-raised)) + (delete-process fake-process)) + (test-ffmpeg-teardown))) + +(ert-deftest test-video-audio-recording-audio-stop-error-interrupt-process-fails () + "Test that audio stop handles interrupt-process failure gracefully." + (test-ffmpeg-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000"))) + (error-raised nil)) + (setq cj/audio-recording-ffmpeg-process fake-process) + (cl-letf (((symbol-function 'interrupt-process) + (lambda (_proc) (error "Interrupt failed")))) + ;; Should handle the error without crashing + (condition-case err + (cj/audio-recording-stop) + (error (setq error-raised t))) + ;; Error should propagate (function doesn't catch it) + (should error-raised)) + (delete-process fake-process)) + (test-ffmpeg-teardown))) + +(ert-deftest test-video-audio-recording-video-stop-error-dead-process-raises-error () + "Test that video stop raises error if process is already dead. +This documents current behavior - interrupt-process on dead process errors. +The sentinel should clear the variable before this happens in practice." + (test-ffmpeg-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-video" :command '("sleep" "1000")))) + (setq cj/video-recording-ffmpeg-process fake-process) + ;; Kill process before calling stop + (delete-process fake-process) + (sit-for 0.1) + ;; Calling stop on dead process raises error + (should-error (cj/video-recording-stop))) + (test-ffmpeg-teardown))) + +(ert-deftest test-video-audio-recording-audio-stop-error-dead-process-raises-error () + "Test that audio stop raises error if process is already dead. +This documents current behavior - interrupt-process on dead process errors. +The sentinel should clear the variable before this happens in practice." + (test-ffmpeg-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000")))) + (setq cj/audio-recording-ffmpeg-process fake-process) + ;; Kill process before calling stop + (delete-process fake-process) + (sit-for 0.1) + ;; Calling stop on dead process raises error + (should-error (cj/audio-recording-stop))) + (test-ffmpeg-teardown))) + +(ert-deftest test-video-audio-recording-ffmpeg-record-video-boundary-skips-if-already-recording () + "Test that video recording skips if already in progress." + (test-ffmpeg-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-video" :command '("sleep" "1000"))) + (start-called nil)) + (setq cj/video-recording-ffmpeg-process fake-process) + (cl-letf (((symbol-function 'start-process-shell-command) + (lambda (_name _buffer _command) + (setq start-called t) + (make-process :name "fake-video2" :command '("sleep" "1000"))))) + (cj/ffmpeg-record-video video-recordings-dir) + ;; Should NOT start a new process + (should-not start-called)) + (delete-process fake-process)) + (test-ffmpeg-teardown))) + +(ert-deftest test-video-audio-recording-ffmpeg-record-audio-boundary-skips-if-already-recording () + "Test that audio recording skips if already in progress." + (test-ffmpeg-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000"))) + (start-called nil)) + (setq cj/audio-recording-ffmpeg-process fake-process) + (cl-letf (((symbol-function 'start-process-shell-command) + (lambda (_name _buffer _command) + (setq start-called t) + (make-process :name "fake-audio2" :command '("sleep" "1000"))))) + (cj/ffmpeg-record-audio audio-recordings-dir) + ;; Should NOT start a new process + (should-not start-called)) + (delete-process fake-process)) + (test-ffmpeg-teardown))) + +(provide 'test-video-audio-recording-ffmpeg-functions) +;;; test-video-audio-recording-ffmpeg-functions.el ends here diff --git a/tests/test-video-audio-recording-friendly-state.el b/tests/test-video-audio-recording-friendly-state.el new file mode 100644 index 00000000..91b47998 --- /dev/null +++ b/tests/test-video-audio-recording-friendly-state.el @@ -0,0 +1,65 @@ +;;; test-video-audio-recording-friendly-state.el --- Tests for cj/recording-friendly-state -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-friendly-state function. +;; Tests conversion of technical pactl state names to user-friendly labels. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-friendly-state-normal-suspended-returns-ready () + "Test that SUSPENDED state converts to Ready." + (should (string= "Ready" (cj/recording-friendly-state "SUSPENDED")))) + +(ert-deftest test-video-audio-recording-friendly-state-normal-running-returns-active () + "Test that RUNNING state converts to Active." + (should (string= "Active" (cj/recording-friendly-state "RUNNING")))) + +(ert-deftest test-video-audio-recording-friendly-state-normal-idle-returns-ready () + "Test that IDLE state converts to Ready." + (should (string= "Ready" (cj/recording-friendly-state "IDLE")))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-friendly-state-boundary-empty-string-returns-empty () + "Test that empty string passes through unchanged." + (should (string= "" (cj/recording-friendly-state "")))) + +(ert-deftest test-video-audio-recording-friendly-state-boundary-lowercase-suspended-returns-unchanged () + "Test that lowercase 'suspended' is not converted (case-sensitive)." + (should (string= "suspended" (cj/recording-friendly-state "suspended")))) + +(ert-deftest test-video-audio-recording-friendly-state-boundary-mixed-case-returns-unchanged () + "Test that mixed case 'Running' passes through unchanged." + (should (string= "Running" (cj/recording-friendly-state "Running")))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-friendly-state-error-unknown-state-returns-unchanged () + "Test that unknown state passes through unchanged." + (should (string= "UNKNOWN" (cj/recording-friendly-state "UNKNOWN")))) + +(ert-deftest test-video-audio-recording-friendly-state-error-random-string-returns-unchanged () + "Test that random string passes through unchanged." + (should (string= "foobar" (cj/recording-friendly-state "foobar")))) + +(ert-deftest test-video-audio-recording-friendly-state-error-numeric-string-returns-unchanged () + "Test that numeric string passes through unchanged." + (should (string= "12345" (cj/recording-friendly-state "12345")))) + +(ert-deftest test-video-audio-recording-friendly-state-error-special-chars-returns-unchanged () + "Test that string with special characters passes through unchanged." + (should (string= "!@#$%" (cj/recording-friendly-state "!@#$%")))) + +(provide 'test-video-audio-recording-friendly-state) +;;; test-video-audio-recording-friendly-state.el ends here diff --git a/tests/test-video-audio-recording-get-devices.el b/tests/test-video-audio-recording-get-devices.el new file mode 100644 index 00000000..ba7d95b9 --- /dev/null +++ b/tests/test-video-audio-recording-get-devices.el @@ -0,0 +1,190 @@ +;;; test-video-audio-recording-get-devices.el --- Tests for cj/recording-get-devices -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-get-devices function. +;; Tests device prompting and validation workflow. +;; +;; NOTE: This function was refactored to use interactive prompts instead of +;; auto-detection. It now prompts the user with y-or-n-p and calls either +;; cj/recording-quick-setup-for-calls or cj/recording-select-devices. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Setup and Teardown + +(defun test-get-devices-setup () + "Reset device variables before each test." + (setq cj/recording-mic-device nil) + (setq cj/recording-system-device nil)) + +(defun test-get-devices-teardown () + "Clean up device variables after each test." + (setq cj/recording-mic-device nil) + (setq cj/recording-system-device nil)) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-get-devices-normal-returns-preset-devices () + "Test that already-configured devices are returned without prompting." + (test-get-devices-setup) + (unwind-protect + (progn + (setq cj/recording-mic-device "preset-mic") + (setq cj/recording-system-device "preset-monitor") + (let ((result (cj/recording-get-devices))) + (should (consp result)) + (should (equal "preset-mic" (car result))) + (should (equal "preset-monitor" (cdr result))))) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-normal-prompts-when-not-configured () + "Test that function prompts user when devices not configured." + (test-get-devices-setup) + (unwind-protect + (let ((prompt-called nil)) + (cl-letf (((symbol-function 'y-or-n-p) + (lambda (_prompt) (setq prompt-called t) t)) + ((symbol-function 'cj/recording-quick-setup-for-calls) + (lambda () + (setq cj/recording-mic-device "quick-mic") + (setq cj/recording-system-device "quick-monitor")))) + (cj/recording-get-devices) + (should prompt-called))) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-normal-calls-quick-setup-on-yes () + "Test that function calls quick setup when user answers yes." + (test-get-devices-setup) + (unwind-protect + (let ((quick-setup-called nil)) + (cl-letf (((symbol-function 'y-or-n-p) + (lambda (_prompt) t)) + ((symbol-function 'cj/recording-quick-setup-for-calls) + (lambda () + (setq quick-setup-called t) + (setq cj/recording-mic-device "quick-mic") + (setq cj/recording-system-device "quick-monitor")))) + (cj/recording-get-devices) + (should quick-setup-called))) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-normal-calls-select-devices-on-no () + "Test that function calls manual selection when user answers no." + (test-get-devices-setup) + (unwind-protect + (let ((select-called nil)) + (cl-letf (((symbol-function 'y-or-n-p) + (lambda (_prompt) nil)) + ((symbol-function 'cj/recording-select-devices) + (lambda () + (setq select-called t) + (setq cj/recording-mic-device "manual-mic") + (setq cj/recording-system-device "manual-monitor")))) + (cj/recording-get-devices) + (should select-called))) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-normal-returns-cons-cell () + "Test that function returns (mic . monitor) cons cell." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'y-or-n-p) + (lambda (_prompt) t)) + ((symbol-function 'cj/recording-quick-setup-for-calls) + (lambda () + (setq cj/recording-mic-device "test-mic") + (setq cj/recording-system-device "test-monitor")))) + (let ((result (cj/recording-get-devices))) + (should (consp result)) + (should (equal "test-mic" (car result))) + (should (equal "test-monitor" (cdr result))))) + (test-get-devices-teardown))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-get-devices-boundary-only-mic-set-prompts () + "Test that function prompts even when only mic is set." + (test-get-devices-setup) + (unwind-protect + (progn + (setq cj/recording-mic-device "preset-mic") + (setq cj/recording-system-device nil) + (let ((prompt-called nil)) + (cl-letf (((symbol-function 'y-or-n-p) + (lambda (_prompt) (setq prompt-called t) t)) + ((symbol-function 'cj/recording-quick-setup-for-calls) + (lambda () + (setq cj/recording-mic-device "new-mic") + (setq cj/recording-system-device "new-monitor")))) + (cj/recording-get-devices) + (should prompt-called)))) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-boundary-only-monitor-set-prompts () + "Test that function prompts even when only monitor is set." + (test-get-devices-setup) + (unwind-protect + (progn + (setq cj/recording-mic-device nil) + (setq cj/recording-system-device "preset-monitor") + (let ((prompt-called nil)) + (cl-letf (((symbol-function 'y-or-n-p) + (lambda (_prompt) (setq prompt-called t) t)) + ((symbol-function 'cj/recording-quick-setup-for-calls) + (lambda () + (setq cj/recording-mic-device "new-mic") + (setq cj/recording-system-device "new-monitor")))) + (cj/recording-get-devices) + (should prompt-called)))) + (test-get-devices-teardown))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-get-devices-error-setup-fails-signals-error () + "Test that function signals error when setup fails to set devices." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'y-or-n-p) + (lambda (_prompt) t)) + ((symbol-function 'cj/recording-quick-setup-for-calls) + (lambda () nil))) ;; Setup fails - doesn't set devices + (should-error (cj/recording-get-devices) :type 'user-error)) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-error-message-mentions-setup-commands () + "Test that error message guides user to setup commands." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'y-or-n-p) + (lambda (_prompt) t)) + ((symbol-function 'cj/recording-quick-setup-for-calls) + (lambda () nil))) + (condition-case err + (cj/recording-get-devices) + (user-error + (should (string-match-p "C-; r c" (error-message-string err))) + (should (string-match-p "C-; r s" (error-message-string err)))))) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-error-select-devices-fails () + "Test that function signals error when manual selection fails." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'y-or-n-p) + (lambda (_prompt) nil)) + ((symbol-function 'cj/recording-select-devices) + (lambda () nil))) ;; Manual selection fails + (should-error (cj/recording-get-devices) :type 'user-error)) + (test-get-devices-teardown))) + +(provide 'test-video-audio-recording-get-devices) +;;; test-video-audio-recording-get-devices.el ends here diff --git a/tests/test-video-audio-recording-group-devices-by-hardware.el b/tests/test-video-audio-recording-group-devices-by-hardware.el new file mode 100644 index 00000000..0abe5f6c --- /dev/null +++ b/tests/test-video-audio-recording-group-devices-by-hardware.el @@ -0,0 +1,194 @@ +;;; test-video-audio-recording-group-devices-by-hardware.el --- Tests for cj/recording-group-devices-by-hardware -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-group-devices-by-hardware function. +;; Tests grouping of audio sources by physical hardware device. +;; Critical test: Bluetooth MAC address normalization (colons vs underscores). +;; +;; This function is used by the quick setup command to automatically pair +;; microphone and monitor devices from the same hardware. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Test Fixtures Helper + +(defun test-load-fixture (filename) + "Load fixture file FILENAME from tests/fixtures directory." + (let ((fixture-path (expand-file-name + (concat "tests/fixtures/" filename) + user-emacs-directory))) + (with-temp-buffer + (insert-file-contents fixture-path) + (buffer-string)))) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-all-types-grouped () + "Test grouping of all three device types (built-in, USB, Bluetooth). +This is the key test validating the complete grouping logic." + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (= 3 (length result))) + ;; Check that we have all three device types + (let ((names (mapcar #'car result))) + (should (member "Built-in Laptop Audio" names)) + (should (member "Bluetooth Headset" names)) + (should (member "Jabra SPEAK 510 USB" names))) + ;; Verify each device has both mic and monitor + (dolist (device result) + (should (stringp (car device))) ; friendly name + (should (stringp (cadr device))) ; mic device + (should (stringp (cddr device))) ; monitor device + (should-not (string-suffix-p ".monitor" (cadr device))) ; mic not monitor + (should (string-suffix-p ".monitor" (cddr device)))))))) ; monitor has suffix + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-built-in-paired () + "Test that built-in laptop audio devices are correctly paired." + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let* ((result (cj/recording-group-devices-by-hardware)) + (built-in (assoc "Built-in Laptop Audio" result))) + (should built-in) + (should (string-match-p "pci-0000_00_1f" (cadr built-in))) + (should (string-match-p "pci-0000_00_1f" (cddr built-in))) + (should (equal "alsa_input.pci-0000_00_1f.3.analog-stereo" (cadr built-in))) + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" (cddr built-in))))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-usb-paired () + "Test that USB devices (Jabra) are correctly paired." + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let* ((result (cj/recording-group-devices-by-hardware)) + (jabra (assoc "Jabra SPEAK 510 USB" result))) + (should jabra) + (should (string-match-p "Jabra" (cadr jabra))) + (should (string-match-p "Jabra" (cddr jabra))))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-bluetooth-paired () + "Test that Bluetooth devices are correctly paired. +CRITICAL: Tests MAC address normalization (colons in input, underscores in output)." + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let* ((result (cj/recording-group-devices-by-hardware)) + (bluetooth (assoc "Bluetooth Headset" result))) + (should bluetooth) + ;; Input has colons: bluez_input.00:1B:66:C0:91:6D + (should (equal "bluez_input.00:1B:66:C0:91:6D" (cadr bluetooth))) + ;; Output has underscores: bluez_output.00_1B_66_C0_91_6D.1.monitor + ;; But they should still be grouped together (MAC address normalized) + (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" (cddr bluetooth))))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-empty-returns-empty () + "Test that empty pactl output returns empty list." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) ""))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (null result))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-only-inputs-returns-empty () + "Test that only input devices (no monitors) returns empty list. +Devices must have BOTH mic and monitor to be included." + (let ((output (test-load-fixture "pactl-output-inputs-only.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-only-monitors-returns-empty () + "Test that only monitor devices (no inputs) returns empty list." + (let ((output (test-load-fixture "pactl-output-monitors-only.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-single-complete-device () + "Test that single device with both mic and monitor is returned." + (let ((output "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (= 1 (length result))) + (should (equal "Built-in Laptop Audio" (caar result))))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-mixed-complete-incomplete () + "Test that only devices with BOTH mic and monitor are included. +Incomplete devices (only mic or only monitor) are filtered out." + (let ((output (concat + ;; Complete device (built-in) + "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + ;; Incomplete: USB mic with no monitor + "100\talsa_input.usb-device.mono-fallback\tPipeWire\ts16le 1ch 16000Hz\tSUSPENDED\n" + ;; Incomplete: Bluetooth monitor with no mic + "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + ;; Only the complete built-in device should be returned + (should (= 1 (length result))) + (should (equal "Built-in Laptop Audio" (caar result))))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-malformed-output-returns-empty () + "Test that malformed pactl output returns empty list." + (let ((output (test-load-fixture "pactl-output-malformed.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-unknown-device-type () + "Test that unknown device types get generic 'USB Audio Device' name." + (let ((output (concat + "100\talsa_input.usb-unknown_device-00.analog-stereo\tPipeWire\ts16le 2ch 16000Hz\tSUSPENDED\n" + "99\talsa_output.usb-unknown_device-00.analog-stereo.monitor\tPipeWire\ts16le 2ch 48000Hz\tSUSPENDED\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (= 1 (length result))) + ;; Should get generic USB name (not matching Jabra pattern) + (should (equal "USB Audio Device" (caar result))))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-bluetooth-mac-case-variations () + "Test that Bluetooth MAC addresses work with different formatting. +Tests the normalization logic handles various MAC address formats." + (let ((output (concat + ;; Input with colons (typical) + "79\tbluez_input.AA:BB:CC:DD:EE:FF\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n" + ;; Output with underscores (typical) + "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (= 1 (length result))) + (should (equal "Bluetooth Headset" (caar result))) + ;; Verify both devices paired despite different MAC formats + (let ((device (car result))) + (should (string-match-p "AA:BB:CC" (cadr device))) + (should (string-match-p "AA_BB_CC" (cddr device)))))))) + +(provide 'test-video-audio-recording-group-devices-by-hardware) +;;; test-video-audio-recording-group-devices-by-hardware.el ends here diff --git a/tests/test-video-audio-recording-modeline-indicator.el b/tests/test-video-audio-recording-modeline-indicator.el new file mode 100644 index 00000000..f7f3bbff --- /dev/null +++ b/tests/test-video-audio-recording-modeline-indicator.el @@ -0,0 +1,134 @@ +;;; test-video-audio-recording-modeline-indicator.el --- Tests for cj/recording-modeline-indicator -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-modeline-indicator function. +;; Tests modeline indicator display based on active recording processes. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Setup and Teardown + +(defun test-modeline-indicator-setup () + "Reset process variables before each test." + (setq cj/audio-recording-ffmpeg-process nil) + (setq cj/video-recording-ffmpeg-process nil)) + +(defun test-modeline-indicator-teardown () + "Clean up process variables after each test." + (setq cj/audio-recording-ffmpeg-process nil) + (setq cj/video-recording-ffmpeg-process nil)) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-modeline-indicator-normal-no-processes-returns-empty () + "Test that indicator returns empty string when no processes are active." + (test-modeline-indicator-setup) + (unwind-protect + (let ((result (cj/recording-modeline-indicator))) + (should (stringp result)) + (should (equal "" result))) + (test-modeline-indicator-teardown))) + +(ert-deftest test-video-audio-recording-modeline-indicator-normal-audio-only-shows-audio () + "Test that indicator shows audio when only audio process is active." + (test-modeline-indicator-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000")))) + (setq cj/audio-recording-ffmpeg-process fake-process) + (let ((result (cj/recording-modeline-indicator))) + (should (equal " 🔴Audio " result))) + (delete-process fake-process)) + (test-modeline-indicator-teardown))) + +(ert-deftest test-video-audio-recording-modeline-indicator-normal-video-only-shows-video () + "Test that indicator shows video when only video process is active." + (test-modeline-indicator-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-video" :command '("sleep" "1000")))) + (setq cj/video-recording-ffmpeg-process fake-process) + (let ((result (cj/recording-modeline-indicator))) + (should (equal " 🔴Video " result))) + (delete-process fake-process)) + (test-modeline-indicator-teardown))) + +(ert-deftest test-video-audio-recording-modeline-indicator-normal-both-shows-combined () + "Test that indicator shows A+V when both processes are active." + (test-modeline-indicator-setup) + (unwind-protect + (let ((audio-proc (make-process :name "test-audio" :command '("sleep" "1000"))) + (video-proc (make-process :name "test-video" :command '("sleep" "1000")))) + (setq cj/audio-recording-ffmpeg-process audio-proc) + (setq cj/video-recording-ffmpeg-process video-proc) + (let ((result (cj/recording-modeline-indicator))) + (should (equal " 🔴A+V " result))) + (delete-process audio-proc) + (delete-process video-proc)) + (test-modeline-indicator-teardown))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-modeline-indicator-boundary-dead-audio-process-returns-empty () + "Test that indicator returns empty string when audio process variable is set but process is dead." + (test-modeline-indicator-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000")))) + (setq cj/audio-recording-ffmpeg-process fake-process) + ;; Kill the process + (delete-process fake-process) + ;; Wait for process to be fully dead + (sit-for 0.1) + (let ((result (cj/recording-modeline-indicator))) + (should (equal "" result)))) + (test-modeline-indicator-teardown))) + +(ert-deftest test-video-audio-recording-modeline-indicator-boundary-dead-video-process-returns-empty () + "Test that indicator returns empty string when video process variable is set but process is dead." + (test-modeline-indicator-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-video" :command '("sleep" "1000")))) + (setq cj/video-recording-ffmpeg-process fake-process) + ;; Kill the process + (delete-process fake-process) + ;; Wait for process to be fully dead + (sit-for 0.1) + (let ((result (cj/recording-modeline-indicator))) + (should (equal "" result)))) + (test-modeline-indicator-teardown))) + +(ert-deftest test-video-audio-recording-modeline-indicator-boundary-one-dead-one-alive-shows-alive () + "Test that only the alive process shows when one is dead and one is alive." + (test-modeline-indicator-setup) + (unwind-protect + (let ((dead-proc (make-process :name "test-dead" :command '("sleep" "1000"))) + (alive-proc (make-process :name "test-alive" :command '("sleep" "1000")))) + (setq cj/audio-recording-ffmpeg-process dead-proc) + (setq cj/video-recording-ffmpeg-process alive-proc) + (delete-process dead-proc) + (sit-for 0.1) + (let ((result (cj/recording-modeline-indicator))) + (should (equal " 🔴Video " result))) + (delete-process alive-proc)) + (test-modeline-indicator-teardown))) + +(ert-deftest test-video-audio-recording-modeline-indicator-boundary-nil-process-variables () + "Test that nil process variables are handled gracefully." + (test-modeline-indicator-setup) + (unwind-protect + (progn + (setq cj/audio-recording-ffmpeg-process nil) + (setq cj/video-recording-ffmpeg-process nil) + (let ((result (cj/recording-modeline-indicator))) + (should (equal "" result)))) + (test-modeline-indicator-teardown))) + +(provide 'test-video-audio-recording-modeline-indicator) +;;; test-video-audio-recording-modeline-indicator.el ends here diff --git a/tests/test-video-audio-recording-parse-pactl-output.el b/tests/test-video-audio-recording-parse-pactl-output.el new file mode 100644 index 00000000..db49a897 --- /dev/null +++ b/tests/test-video-audio-recording-parse-pactl-output.el @@ -0,0 +1,157 @@ +;;; test-video-audio-recording-parse-pactl-output.el --- Tests for cj/recording--parse-pactl-output -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording--parse-pactl-output function. +;; Tests parsing of pactl sources output into structured data. +;; Uses fixture files with sample pactl output for reproducible testing. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Test Fixtures Helper + +(defun test-load-fixture (filename) + "Load fixture file FILENAME from tests/fixtures directory." + (let ((fixture-path (expand-file-name + (concat "tests/fixtures/" filename) + user-emacs-directory))) + (with-temp-buffer + (insert-file-contents fixture-path) + (buffer-string)))) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-parse-pactl-output-normal-all-devices-returns-list () + "Test parsing normal pactl output with all device types." + (let* ((output (test-load-fixture "pactl-output-normal.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (= 6 (length result))) + ;; Check first device (built-in monitor) + (should (equal '("alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" + "PipeWire" + "SUSPENDED") + (nth 0 result))) + ;; Check Bluetooth input + (should (equal '("bluez_input.00:1B:66:C0:91:6D" + "PipeWire" + "SUSPENDED") + (nth 2 result))) + ;; Check USB device + (should (equal '("alsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback" + "PipeWire" + "SUSPENDED") + (nth 5 result))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-normal-single-device-returns-list () + "Test parsing output with single device." + (let* ((output (test-load-fixture "pactl-output-single.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (= 1 (length result))) + (should (equal '("alsa_input.pci-0000_00_1f.3.analog-stereo" + "PipeWire" + "SUSPENDED") + (car result))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-normal-monitors-only-returns-list () + "Test parsing output with only monitor devices." + (let* ((output (test-load-fixture "pactl-output-monitors-only.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (= 3 (length result))) + ;; All should end with .monitor + (dolist (device result) + (should (string-suffix-p ".monitor" (car device)))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-normal-inputs-only-returns-list () + "Test parsing output with only input devices." + (let* ((output (test-load-fixture "pactl-output-inputs-only.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (= 3 (length result))) + ;; None should end with .monitor + (dolist (device result) + (should-not (string-suffix-p ".monitor" (car device)))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-empty-string-returns-empty-list () + "Test parsing empty string returns empty list." + (let ((result (cj/recording--parse-pactl-output ""))) + (should (listp result)) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-empty-file-returns-empty-list () + "Test parsing empty file returns empty list." + (let* ((output (test-load-fixture "pactl-output-empty.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-whitespace-only-returns-empty-list () + "Test parsing whitespace-only string returns empty list." + (let ((result (cj/recording--parse-pactl-output " \n\t\n "))) + (should (listp result)) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-single-newline-returns-empty-list () + "Test parsing single newline returns empty list." + (let ((result (cj/recording--parse-pactl-output "\n"))) + (should (listp result)) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-device-with-running-state-parsed () + "Test that RUNNING state (not just SUSPENDED) is parsed correctly." + (let* ((output "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n") + (result (cj/recording--parse-pactl-output output))) + (should (= 1 (length result))) + (should (equal "RUNNING" (nth 2 (car result)))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-device-with-idle-state-parsed () + "Test that IDLE state is parsed correctly." + (let* ((output "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tIDLE\n") + (result (cj/recording--parse-pactl-output output))) + (should (= 1 (length result))) + (should (equal "IDLE" (nth 2 (car result)))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-parse-pactl-output-error-malformed-lines-ignored () + "Test that malformed lines are silently ignored." + (let* ((output (test-load-fixture "pactl-output-malformed.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (null result)))) ; All lines malformed, so empty list + +(ert-deftest test-video-audio-recording-parse-pactl-output-error-mixed-valid-invalid-returns-valid () + "Test that mix of valid and invalid lines returns only valid ones." + (let* ((output (concat "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "This is invalid\n" + "79\tbluez_input.00:1B:66:C0:91:6D\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n" + "Also invalid\n")) + (result (cj/recording--parse-pactl-output output))) + (should (= 2 (length result))) + (should (equal "alsa_input.pci-0000_00_1f.3.analog-stereo" (car (nth 0 result)))) + (should (equal "bluez_input.00:1B:66:C0:91:6D" (car (nth 1 result)))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-error-missing-fields-ignored () + "Test that lines with missing fields are ignored." + (let* ((output "50\tincomplete-line\tPipeWire\n") ; Missing state and format + (result (cj/recording--parse-pactl-output output))) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-error-nil-input-returns-error () + "Test that nil input signals an error." + (should-error (cj/recording--parse-pactl-output nil))) + +(provide 'test-video-audio-recording-parse-pactl-output) +;;; test-video-audio-recording-parse-pactl-output.el ends here diff --git a/tests/test-video-audio-recording-parse-sources.el b/tests/test-video-audio-recording-parse-sources.el new file mode 100644 index 00000000..d6d445b5 --- /dev/null +++ b/tests/test-video-audio-recording-parse-sources.el @@ -0,0 +1,98 @@ +;;; test-video-audio-recording-parse-sources.el --- Tests for cj/recording-parse-sources -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-parse-sources function. +;; Tests the wrapper that calls pactl and delegates to internal parser. +;; Mocks shell-command-to-string to avoid system dependencies. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Test Fixtures Helper + +(defun test-load-fixture (filename) + "Load fixture file FILENAME from tests/fixtures directory." + (let ((fixture-path (expand-file-name + (concat "tests/fixtures/" filename) + user-emacs-directory))) + (with-temp-buffer + (insert-file-contents fixture-path) + (buffer-string)))) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-parse-sources-normal-calls-pactl-and-parses () + "Test that parse-sources calls shell command and returns parsed list." + (let ((fixture-output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) fixture-output))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (= 6 (length result))) + ;; Verify it returns structured data + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" + (car (nth 0 result)))) + (should (equal "PipeWire" (nth 1 (nth 0 result)))) + (should (equal "SUSPENDED" (nth 2 (nth 0 result)))))))) + +(ert-deftest test-video-audio-recording-parse-sources-normal-single-device-returns-list () + "Test parse-sources with single device." + (let ((fixture-output (test-load-fixture "pactl-output-single.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) fixture-output))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (= 1 (length result))))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-parse-sources-boundary-empty-output-returns-empty-list () + "Test that empty pactl output returns empty list." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) ""))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (null result))))) + +(ert-deftest test-video-audio-recording-parse-sources-boundary-whitespace-output-returns-empty-list () + "Test that whitespace-only output returns empty list." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) " \n\t\n "))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (null result))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-parse-sources-error-malformed-output-returns-empty-list () + "Test that malformed output is handled gracefully." + (let ((fixture-output (test-load-fixture "pactl-output-malformed.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) fixture-output))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-parse-sources-error-mixed-valid-invalid-returns-valid-only () + "Test that mix of valid and invalid lines returns only valid entries." + (let ((mixed-output (concat + "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "invalid line\n" + "79\tbluez_input.00:1B:66:C0:91:6D\tPipeWire\tfloat32le 1ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) mixed-output))) + (let ((result (cj/recording-parse-sources))) + (should (= 2 (length result))) + (should (equal "alsa_input.pci-0000_00_1f.3.analog-stereo" (car (nth 0 result)))) + (should (equal "bluez_input.00:1B:66:C0:91:6D" (car (nth 1 result)))))))) + +(provide 'test-video-audio-recording-parse-sources) +;;; test-video-audio-recording-parse-sources.el ends here diff --git a/tests/test-video-audio-recording-process-sentinel.el b/tests/test-video-audio-recording-process-sentinel.el new file mode 100644 index 00000000..37a7f94d --- /dev/null +++ b/tests/test-video-audio-recording-process-sentinel.el @@ -0,0 +1,190 @@ +;;; test-video-audio-recording-process-sentinel.el --- Tests for cj/recording-process-sentinel -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-process-sentinel function. +;; Tests process cleanup and modeline update when recording processes exit. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Setup and Teardown + +(defun test-sentinel-setup () + "Reset process variables before each test." + (setq cj/audio-recording-ffmpeg-process nil) + (setq cj/video-recording-ffmpeg-process nil)) + +(defun test-sentinel-teardown () + "Clean up process variables after each test." + (setq cj/audio-recording-ffmpeg-process nil) + (setq cj/video-recording-ffmpeg-process nil)) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-process-sentinel-normal-audio-exit-clears-variable () + "Test that sentinel clears audio process variable when process exits." + (test-sentinel-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-audio" :command '("sh" "-c" "exit 0")))) + (setq cj/audio-recording-ffmpeg-process fake-process) + ;; Mock process-status to return 'exit + (cl-letf (((symbol-function 'process-status) + (lambda (_proc) 'exit))) + ;; Call sentinel with exit status + (cj/recording-process-sentinel fake-process "finished\n") + ;; Variable should be cleared + (should (null cj/audio-recording-ffmpeg-process)))) + (test-sentinel-teardown))) + +(ert-deftest test-video-audio-recording-process-sentinel-normal-video-exit-clears-variable () + "Test that sentinel clears video process variable when process exits." + (test-sentinel-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-video" :command '("sh" "-c" "exit 0")))) + (setq cj/video-recording-ffmpeg-process fake-process) + ;; Mock process-status to return 'exit + (cl-letf (((symbol-function 'process-status) + (lambda (_proc) 'exit))) + ;; Call sentinel with exit status + (cj/recording-process-sentinel fake-process "finished\n") + ;; Variable should be cleared + (should (null cj/video-recording-ffmpeg-process)))) + (test-sentinel-teardown))) + +(ert-deftest test-video-audio-recording-process-sentinel-normal-signal-status-clears-variable () + "Test that sentinel clears variable on signal status (killed)." + (test-sentinel-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000")))) + (setq cj/audio-recording-ffmpeg-process fake-process) + (delete-process fake-process) + ;; Call sentinel with signal status + (cj/recording-process-sentinel fake-process "killed\n") + ;; Variable should be cleared + (should (null cj/audio-recording-ffmpeg-process))) + (test-sentinel-teardown))) + +(ert-deftest test-video-audio-recording-process-sentinel-normal-modeline-update-called () + "Test that sentinel triggers modeline update." + (test-sentinel-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-audio" :command '("sh" "-c" "exit 0"))) + (update-called nil)) + (setq cj/audio-recording-ffmpeg-process fake-process) + ;; Mock force-mode-line-update to track if it's called + (cl-letf (((symbol-function 'force-mode-line-update) + (lambda (&optional _all) (setq update-called t)))) + (cj/recording-process-sentinel fake-process "finished\n") + (should update-called))) + (test-sentinel-teardown))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-process-sentinel-boundary-run-status-ignored () + "Test that sentinel ignores processes in 'run status (still running)." + (test-sentinel-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000")))) + (setq cj/audio-recording-ffmpeg-process fake-process) + ;; Mock process-status to return 'run + (cl-letf (((symbol-function 'process-status) + (lambda (_proc) 'run))) + (cj/recording-process-sentinel fake-process "run") + ;; Variable should NOT be cleared + (should (eq fake-process cj/audio-recording-ffmpeg-process))) + (delete-process fake-process)) + (test-sentinel-teardown))) + +(ert-deftest test-video-audio-recording-process-sentinel-boundary-open-status-ignored () + "Test that sentinel ignores processes in 'open status." + (test-sentinel-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000")))) + (setq cj/audio-recording-ffmpeg-process fake-process) + (cl-letf (((symbol-function 'process-status) + (lambda (_proc) 'open))) + (cj/recording-process-sentinel fake-process "open") + ;; Variable should NOT be cleared + (should (eq fake-process cj/audio-recording-ffmpeg-process))) + (delete-process fake-process)) + (test-sentinel-teardown))) + +(ert-deftest test-video-audio-recording-process-sentinel-boundary-event-trimmed () + "Test that event string is trimmed in message." + (test-sentinel-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-audio" :command '("sh" "-c" "exit 0"))) + (message-text nil)) + (setq cj/audio-recording-ffmpeg-process fake-process) + ;; Mock message to capture output + (cl-letf (((symbol-function 'message) + (lambda (fmt &rest args) (setq message-text (apply #'format fmt args))))) + (cj/recording-process-sentinel fake-process " finished \n") + ;; Message should contain trimmed event + (should (string-match-p "finished" message-text)) + ;; Should not have extra whitespace + (should-not (string-match-p " finished " message-text)))) + (test-sentinel-teardown))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-process-sentinel-error-unknown-process-ignored () + "Test that sentinel handles unknown process (not audio or video) gracefully." + (test-sentinel-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-unknown" :command '("sh" "-c" "exit 0"))) + (audio-proc (make-process :name "test-audio" :command '("sleep" "1000"))) + (video-proc (make-process :name "test-video" :command '("sleep" "1000")))) + (setq cj/audio-recording-ffmpeg-process audio-proc) + (setq cj/video-recording-ffmpeg-process video-proc) + ;; Call sentinel with unknown process + (cj/recording-process-sentinel fake-process "finished\n") + ;; Audio and video variables should NOT be cleared + (should (eq audio-proc cj/audio-recording-ffmpeg-process)) + (should (eq video-proc cj/video-recording-ffmpeg-process)) + (delete-process audio-proc) + (delete-process video-proc)) + (test-sentinel-teardown))) + +(ert-deftest test-video-audio-recording-process-sentinel-error-nil-event-handled () + "Test that sentinel handles nil event string gracefully." + (test-sentinel-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-audio" :command '("sh" "-c" "exit 0")))) + (setq cj/audio-recording-ffmpeg-process fake-process) + ;; Mock process-status to return 'exit + (cl-letf (((symbol-function 'process-status) + (lambda (_proc) 'exit))) + ;; Should not crash with nil event (string-trim will error, but that's caught) + ;; The function uses string-trim without protection, so this will error + ;; Testing that it doesn't crash means we expect an error + (should-error + (cj/recording-process-sentinel fake-process nil)))) + (test-sentinel-teardown))) + +(ert-deftest test-video-audio-recording-process-sentinel-error-empty-event-handled () + "Test that sentinel handles empty event string gracefully." + (test-sentinel-setup) + (unwind-protect + (let ((fake-process (make-process :name "test-audio" :command '("sh" "-c" "exit 0")))) + (setq cj/audio-recording-ffmpeg-process fake-process) + ;; Mock process-status to return 'exit + (cl-letf (((symbol-function 'process-status) + (lambda (_proc) 'exit))) + ;; Empty string is fine - string-trim handles it + ;; No error should be raised + (cj/recording-process-sentinel fake-process "") + ;; Variable should be cleared + (should (null cj/audio-recording-ffmpeg-process)))) + (test-sentinel-teardown))) + +(provide 'test-video-audio-recording-process-sentinel) +;;; test-video-audio-recording-process-sentinel.el ends here diff --git a/tests/test-video-audio-recording-quick-setup-for-calls.el b/tests/test-video-audio-recording-quick-setup-for-calls.el new file mode 100644 index 00000000..0d3fe53a --- /dev/null +++ b/tests/test-video-audio-recording-quick-setup-for-calls.el @@ -0,0 +1,144 @@ +;;; test-video-audio-recording-quick-setup-for-calls.el --- Tests for cj/recording-quick-setup-for-calls -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-quick-setup-for-calls function. +;; Tests quick device setup workflow for call recording. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Setup and Teardown + +(defun test-quick-setup-setup () + "Reset device variables before each test." + (setq cj/recording-mic-device nil) + (setq cj/recording-system-device nil)) + +(defun test-quick-setup-teardown () + "Clean up device variables after each test." + (setq cj/recording-mic-device nil) + (setq cj/recording-system-device nil)) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-quick-setup-for-calls-normal-sets-both-devices () + "Test that function sets both mic and system device variables." + (test-quick-setup-setup) + (unwind-protect + (let ((grouped-devices '(("Bluetooth Headset" . ("bluez_input.00:1B:66" . "bluez_output.00_1B_66.monitor"))))) + (cl-letf (((symbol-function 'cj/recording-group-devices-by-hardware) + (lambda () grouped-devices)) + ((symbol-function 'completing-read) + (lambda (_prompt _choices &rest _args) "Bluetooth Headset"))) + (cj/recording-quick-setup-for-calls) + (should (equal "bluez_input.00:1B:66" cj/recording-mic-device)) + (should (equal "bluez_output.00_1B_66.monitor" cj/recording-system-device)))) + (test-quick-setup-teardown))) + +(ert-deftest test-video-audio-recording-quick-setup-for-calls-normal-presents-friendly-names () + "Test that function presents friendly device names to user." + (test-quick-setup-setup) + (unwind-protect + (let ((grouped-devices '(("Jabra SPEAK 510 USB" . ("usb-input" . "usb-monitor")) + ("Built-in Laptop Audio" . ("pci-input" . "pci-monitor")))) + (presented-choices nil)) + (cl-letf (((symbol-function 'cj/recording-group-devices-by-hardware) + (lambda () grouped-devices)) + ((symbol-function 'completing-read) + (lambda (_prompt choices &rest _args) + (setq presented-choices choices) + (car choices)))) + (cj/recording-quick-setup-for-calls) + (should (member "Jabra SPEAK 510 USB" presented-choices)) + (should (member "Built-in Laptop Audio" presented-choices)))) + (test-quick-setup-teardown))) + +(ert-deftest test-video-audio-recording-quick-setup-for-calls-normal-displays-confirmation () + "Test that function displays confirmation message with device details." + (test-quick-setup-setup) + (unwind-protect + (let ((grouped-devices '(("Bluetooth Headset" . ("bluez_input.00:1B:66" . "bluez_output.00_1B_66.monitor")))) + (message-text nil)) + (cl-letf (((symbol-function 'cj/recording-group-devices-by-hardware) + (lambda () grouped-devices)) + ((symbol-function 'completing-read) + (lambda (_prompt _choices &rest _args) "Bluetooth Headset")) + ((symbol-function 'message) + (lambda (fmt &rest args) (setq message-text (apply #'format fmt args))))) + (cj/recording-quick-setup-for-calls) + (should (string-match-p "Call recording ready" message-text)) + (should (string-match-p "Bluetooth Headset" message-text)))) + (test-quick-setup-teardown))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-quick-setup-for-calls-boundary-single-device-no-prompt () + "Test that with single device, selection still happens." + (test-quick-setup-setup) + (unwind-protect + (let ((grouped-devices '(("Built-in Laptop Audio" . ("pci-input" . "pci-monitor"))))) + (cl-letf (((symbol-function 'cj/recording-group-devices-by-hardware) + (lambda () grouped-devices)) + ((symbol-function 'completing-read) + (lambda (_prompt _choices &rest _args) "Built-in Laptop Audio"))) + (cj/recording-quick-setup-for-calls) + (should (equal "pci-input" cj/recording-mic-device)) + (should (equal "pci-monitor" cj/recording-system-device)))) + (test-quick-setup-teardown))) + +(ert-deftest test-video-audio-recording-quick-setup-for-calls-boundary-device-name-with-special-chars () + "Test that device names with special characters are handled correctly." + (test-quick-setup-setup) + (unwind-protect + (let ((grouped-devices '(("Device (USB-C)" . ("special-input" . "special-monitor"))))) + (cl-letf (((symbol-function 'cj/recording-group-devices-by-hardware) + (lambda () grouped-devices)) + ((symbol-function 'completing-read) + (lambda (_prompt _choices &rest _args) "Device (USB-C)"))) + (cj/recording-quick-setup-for-calls) + (should (equal "special-input" cj/recording-mic-device)) + (should (equal "special-monitor" cj/recording-system-device)))) + (test-quick-setup-teardown))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-quick-setup-for-calls-error-no-devices-signals-error () + "Test that function signals user-error when no complete devices are found." + (test-quick-setup-setup) + (unwind-protect + (cl-letf (((symbol-function 'cj/recording-group-devices-by-hardware) + (lambda () nil))) + (should-error (cj/recording-quick-setup-for-calls) :type 'user-error)) + (test-quick-setup-teardown))) + +(ert-deftest test-video-audio-recording-quick-setup-for-calls-error-message-mentions-both-devices () + "Test that error message mentions need for both mic and monitor." + (test-quick-setup-setup) + (unwind-protect + (cl-letf (((symbol-function 'cj/recording-group-devices-by-hardware) + (lambda () nil))) + (condition-case err + (cj/recording-quick-setup-for-calls) + (user-error + (should (string-match-p "both mic and monitor" (error-message-string err)))))) + (test-quick-setup-teardown))) + +(ert-deftest test-video-audio-recording-quick-setup-for-calls-error-empty-device-list () + "Test that empty device list from grouping is handled gracefully." + (test-quick-setup-setup) + (unwind-protect + (cl-letf (((symbol-function 'cj/recording-group-devices-by-hardware) + (lambda () '()))) + (should-error (cj/recording-quick-setup-for-calls) :type 'user-error)) + (test-quick-setup-teardown))) + +(provide 'test-video-audio-recording-quick-setup-for-calls) +;;; test-video-audio-recording-quick-setup-for-calls.el ends here diff --git a/tests/test-video-audio-recording-select-device.el b/tests/test-video-audio-recording-select-device.el new file mode 100644 index 00000000..53b1e665 --- /dev/null +++ b/tests/test-video-audio-recording-select-device.el @@ -0,0 +1,165 @@ +;;; test-video-audio-recording-select-device.el --- Tests for cj/recording-select-device -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-select-device function. +;; Tests interactive device selection with filtering. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-select-device-normal-returns-selected-mic () + "Test that function returns selected microphone device." + (let ((sources '(("alsa_input.pci-device" "PipeWire" "SUSPENDED") + ("alsa_output.pci-device.monitor" "PipeWire" "SUSPENDED")))) + (cl-letf (((symbol-function 'cj/recording-parse-sources) + (lambda () sources)) + ((symbol-function 'completing-read) + (lambda (_prompt choices &rest _args) + ;; Select the first choice + (caar choices)))) + (let ((result (cj/recording-select-device "Select mic: " 'mic))) + (should (stringp result)) + (should (equal "alsa_input.pci-device" result)))))) + +(ert-deftest test-video-audio-recording-select-device-normal-returns-selected-monitor () + "Test that function returns selected monitor device." + (let ((sources '(("alsa_input.pci-device" "PipeWire" "SUSPENDED") + ("alsa_output.pci-device.monitor" "PipeWire" "SUSPENDED")))) + (cl-letf (((symbol-function 'cj/recording-parse-sources) + (lambda () sources)) + ((symbol-function 'completing-read) + (lambda (_prompt choices &rest _args) + (caar choices)))) + (let ((result (cj/recording-select-device "Select monitor: " 'monitor))) + (should (stringp result)) + (should (equal "alsa_output.pci-device.monitor" result)))))) + +(ert-deftest test-video-audio-recording-select-device-normal-filters-monitors-for-mic () + "Test that function filters out monitor devices when selecting mic." + (let ((sources '(("alsa_input.pci-device" "PipeWire" "SUSPENDED") + ("alsa_output.pci-device.monitor" "PipeWire" "SUSPENDED") + ("bluez_input.00:1B:66" "PipeWire" "RUNNING"))) + (presented-choices nil)) + (cl-letf (((symbol-function 'cj/recording-parse-sources) + (lambda () sources)) + ((symbol-function 'completing-read) + (lambda (_prompt choices &rest _args) + (setq presented-choices choices) + (caar choices)))) + (cj/recording-select-device "Select mic: " 'mic) + ;; Should have 2 mic devices (not the monitor) + (should (= 2 (length presented-choices))) + (should-not (cl-some (lambda (choice) (string-match-p "\\.monitor" (car choice))) + presented-choices))))) + +(ert-deftest test-video-audio-recording-select-device-normal-filters-non-monitors-for-monitor () + "Test that function filters out non-monitor devices when selecting monitor." + (let ((sources '(("alsa_input.pci-device" "PipeWire" "SUSPENDED") + ("alsa_output.pci-device.monitor" "PipeWire" "SUSPENDED") + ("bluez_output.00_1B_66.1.monitor" "PipeWire" "RUNNING"))) + (presented-choices nil)) + (cl-letf (((symbol-function 'cj/recording-parse-sources) + (lambda () sources)) + ((symbol-function 'completing-read) + (lambda (_prompt choices &rest _args) + (setq presented-choices choices) + (caar choices)))) + (cj/recording-select-device "Select monitor: " 'monitor) + ;; Should have 2 monitor devices (not the input) + (should (= 2 (length presented-choices))) + (should (cl-every (lambda (choice) (string-match-p "\\.monitor" (car choice))) + presented-choices))))) + +(ert-deftest test-video-audio-recording-select-device-normal-shows-friendly-state () + "Test that function shows friendly state in choices." + (let ((sources '(("alsa_input.pci-device" "PipeWire" "SUSPENDED"))) + (presented-choices nil)) + (cl-letf (((symbol-function 'cj/recording-parse-sources) + (lambda () sources)) + ((symbol-function 'completing-read) + (lambda (_prompt choices &rest _args) + (setq presented-choices choices) + (caar choices)))) + (cj/recording-select-device "Select mic: " 'mic) + ;; Choice should contain "Ready" (friendly for SUSPENDED) + (should (string-match-p "Ready" (caar presented-choices)))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-select-device-boundary-single-device () + "Test that function works with single device." + (let ((sources '(("alsa_input.pci-device" "PipeWire" "SUSPENDED")))) + (cl-letf (((symbol-function 'cj/recording-parse-sources) + (lambda () sources)) + ((symbol-function 'completing-read) + (lambda (_prompt choices &rest _args) + (caar choices)))) + (let ((result (cj/recording-select-device "Select mic: " 'mic))) + (should (equal "alsa_input.pci-device" result)))))) + +(ert-deftest test-video-audio-recording-select-device-boundary-multiple-states () + "Test that function handles devices in different states." + (let ((sources '(("alsa_input.device1" "PipeWire" "SUSPENDED") + ("alsa_input.device2" "PipeWire" "RUNNING") + ("alsa_input.device3" "PipeWire" "IDLE"))) + (presented-choices nil)) + (cl-letf (((symbol-function 'cj/recording-parse-sources) + (lambda () sources)) + ((symbol-function 'completing-read) + (lambda (_prompt choices &rest _args) + (setq presented-choices choices) + (caar choices)))) + (cj/recording-select-device "Select mic: " 'mic) + ;; All three should be presented + (should (= 3 (length presented-choices))) + ;; Check that friendly states appear + (let ((choice-text (mapconcat #'car presented-choices " "))) + (should (string-match-p "Ready\\|Active" choice-text)))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-select-device-error-no-mic-devices-signals-error () + "Test that function signals user-error when no mic devices found." + (let ((sources '(("alsa_output.pci-device.monitor" "PipeWire" "SUSPENDED")))) + (cl-letf (((symbol-function 'cj/recording-parse-sources) + (lambda () sources))) + (should-error (cj/recording-select-device "Select mic: " 'mic) :type 'user-error)))) + +(ert-deftest test-video-audio-recording-select-device-error-no-monitor-devices-signals-error () + "Test that function signals user-error when no monitor devices found." + (let ((sources '(("alsa_input.pci-device" "PipeWire" "SUSPENDED")))) + (cl-letf (((symbol-function 'cj/recording-parse-sources) + (lambda () sources))) + (should-error (cj/recording-select-device "Select monitor: " 'monitor) :type 'user-error)))) + +(ert-deftest test-video-audio-recording-select-device-error-empty-source-list () + "Test that function signals user-error when source list is empty." + (cl-letf (((symbol-function 'cj/recording-parse-sources) + (lambda () nil))) + (should-error (cj/recording-select-device "Select mic: " 'mic) :type 'user-error))) + +(ert-deftest test-video-audio-recording-select-device-error-message-mentions-device-type () + "Test that error message mentions the device type being searched for." + (cl-letf (((symbol-function 'cj/recording-parse-sources) + (lambda () nil))) + (condition-case err + (cj/recording-select-device "Select mic: " 'mic) + (user-error + (should (string-match-p "input" (error-message-string err))))) + (condition-case err + (cj/recording-select-device "Select monitor: " 'monitor) + (user-error + (should (string-match-p "monitor" (error-message-string err))))))) + +(provide 'test-video-audio-recording-select-device) +;;; test-video-audio-recording-select-device.el ends here diff --git a/tests/test-video-audio-recording-test-mic.el b/tests/test-video-audio-recording-test-mic.el new file mode 100644 index 00000000..5aa794bb --- /dev/null +++ b/tests/test-video-audio-recording-test-mic.el @@ -0,0 +1,147 @@ +;;; test-video-audio-recording-test-mic.el --- Tests for cj/recording-test-mic -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-test-mic function. +;; Tests microphone testing functionality. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Setup and Teardown + +(defun test-mic-setup () + "Reset device variables before each test." + (setq cj/recording-mic-device nil)) + +(defun test-mic-teardown () + "Clean up device variables after each test." + (setq cj/recording-mic-device nil)) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-test-mic-normal-creates-temp-wav-file () + "Test that function creates temp file with .wav extension." + (test-mic-setup) + (unwind-protect + (progn + (setq cj/recording-mic-device "test-mic-device") + (let ((temp-file nil)) + ;; Mock make-temp-file to capture filename + (cl-letf (((symbol-function 'make-temp-file) + (lambda (prefix _dir-flag suffix) + (setq temp-file (concat prefix "12345" suffix)) + temp-file)) + ((symbol-function 'shell-command) + (lambda (_cmd) 0))) + (cj/recording-test-mic) + (should (string-match-p "\\.wav$" temp-file))))) + (test-mic-teardown))) + +(ert-deftest test-video-audio-recording-test-mic-normal-runs-ffmpeg-command () + "Test that function runs ffmpeg command with configured mic device." + (test-mic-setup) + (unwind-protect + (progn + (setq cj/recording-mic-device "test-mic-device") + (let ((commands nil)) + ;; Mock shell-command to capture all commands + (cl-letf (((symbol-function 'shell-command) + (lambda (cmd) (push cmd commands) 0))) + (cj/recording-test-mic) + (should (= 2 (length commands))) + ;; First command should be ffmpeg (stored last in list due to push) + (let ((ffmpeg-cmd (cadr commands))) + (should (stringp ffmpeg-cmd)) + (should (string-match-p "ffmpeg" ffmpeg-cmd)) + (should (string-match-p "test-mic-device" ffmpeg-cmd)) + (should (string-match-p "-t 5" ffmpeg-cmd)))))) + (test-mic-teardown))) + +(ert-deftest test-video-audio-recording-test-mic-normal-runs-ffplay-for-playback () + "Test that function runs ffplay for playback." + (test-mic-setup) + (unwind-protect + (progn + (setq cj/recording-mic-device "test-mic-device") + (let ((commands nil)) + ;; Capture all shell commands + (cl-letf (((symbol-function 'shell-command) + (lambda (cmd) (push cmd commands) 0))) + (cj/recording-test-mic) + (should (= 2 (length commands))) + ;; Second command should be ffplay + (should (string-match-p "ffplay" (car commands))) + (should (string-match-p "-autoexit" (car commands)))))) + (test-mic-teardown))) + +(ert-deftest test-video-audio-recording-test-mic-normal-displays-messages () + "Test that function displays appropriate messages to user." + (test-mic-setup) + (unwind-protect + (progn + (setq cj/recording-mic-device "test-mic-device") + (let ((messages nil)) + ;; Capture messages + (cl-letf (((symbol-function 'message) + (lambda (fmt &rest args) (push (apply #'format fmt args) messages))) + ((symbol-function 'shell-command) + (lambda (_cmd) 0))) + (cj/recording-test-mic) + (should (>= (length messages) 3)) + ;; Check for recording message + (should (cl-some (lambda (msg) (string-match-p "Recording.*SPEAK NOW" msg)) messages)) + ;; Check for playback message + (should (cl-some (lambda (msg) (string-match-p "Playing back" msg)) messages)) + ;; Check for complete message + (should (cl-some (lambda (msg) (string-match-p "complete" msg)) messages))))) + (test-mic-teardown))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-test-mic-error-no-mic-configured-signals-error () + "Test that function signals user-error when mic device is not configured." + (test-mic-setup) + (unwind-protect + (progn + (setq cj/recording-mic-device nil) + (should-error (cj/recording-test-mic) :type 'user-error)) + (test-mic-teardown))) + +(ert-deftest test-video-audio-recording-test-mic-error-message-mentions-setup () + "Test that error message guides user to run setup." + (test-mic-setup) + (unwind-protect + (progn + (setq cj/recording-mic-device nil) + (condition-case err + (cj/recording-test-mic) + (user-error + (should (string-match-p "C-; r c" (error-message-string err)))))) + (test-mic-teardown))) + +(ert-deftest test-video-audio-recording-test-mic-error-ffmpeg-failure-handled () + "Test that ffmpeg command failure is handled gracefully." + (test-mic-setup) + (unwind-protect + (progn + (setq cj/recording-mic-device "test-mic-device") + ;; Mock shell-command to fail + (cl-letf (((symbol-function 'shell-command) + (lambda (_cmd) 1))) ;; Non-zero exit code + ;; Should complete without crashing (ffmpeg errors are ignored) + ;; No error is raised - function just completes + (cj/recording-test-mic) + ;; Test passes if we get here + (should t))) + (test-mic-teardown))) + +(provide 'test-video-audio-recording-test-mic) +;;; test-video-audio-recording-test-mic.el ends here diff --git a/tests/test-video-audio-recording-test-monitor.el b/tests/test-video-audio-recording-test-monitor.el new file mode 100644 index 00000000..f1476577 --- /dev/null +++ b/tests/test-video-audio-recording-test-monitor.el @@ -0,0 +1,148 @@ +;;; test-video-audio-recording-test-monitor.el --- Tests for cj/recording-test-monitor -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-test-monitor function. +;; Tests system audio monitor testing functionality. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Setup and Teardown + +(defun test-monitor-setup () + "Reset device variables before each test." + (setq cj/recording-system-device nil)) + +(defun test-monitor-teardown () + "Clean up device variables after each test." + (setq cj/recording-system-device nil)) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-test-monitor-normal-creates-temp-wav-file () + "Test that function creates temp file with .wav extension." + (test-monitor-setup) + (unwind-protect + (progn + (setq cj/recording-system-device "test-monitor-device") + (let ((temp-file nil)) + ;; Mock make-temp-file to capture filename + (cl-letf (((symbol-function 'make-temp-file) + (lambda (prefix _dir-flag suffix) + (setq temp-file (concat prefix "12345" suffix)) + temp-file)) + ((symbol-function 'shell-command) + (lambda (_cmd) 0))) + (cj/recording-test-monitor) + (should (string-match-p "monitor-test-" temp-file)) + (should (string-match-p "\\.wav$" temp-file))))) + (test-monitor-teardown))) + +(ert-deftest test-video-audio-recording-test-monitor-normal-runs-ffmpeg-command () + "Test that function runs ffmpeg command with configured monitor device." + (test-monitor-setup) + (unwind-protect + (progn + (setq cj/recording-system-device "test-monitor-device") + (let ((commands nil)) + ;; Mock shell-command to capture all commands + (cl-letf (((symbol-function 'shell-command) + (lambda (cmd) (push cmd commands) 0))) + (cj/recording-test-monitor) + (should (= 2 (length commands))) + ;; First command should be ffmpeg (stored last in list due to push) + (let ((ffmpeg-cmd (cadr commands))) + (should (stringp ffmpeg-cmd)) + (should (string-match-p "ffmpeg" ffmpeg-cmd)) + (should (string-match-p "test-monitor-device" ffmpeg-cmd)) + (should (string-match-p "-t 5" ffmpeg-cmd)))))) + (test-monitor-teardown))) + +(ert-deftest test-video-audio-recording-test-monitor-normal-runs-ffplay-for-playback () + "Test that function runs ffplay for playback." + (test-monitor-setup) + (unwind-protect + (progn + (setq cj/recording-system-device "test-monitor-device") + (let ((commands nil)) + ;; Capture all shell commands + (cl-letf (((symbol-function 'shell-command) + (lambda (cmd) (push cmd commands) 0))) + (cj/recording-test-monitor) + (should (= 2 (length commands))) + ;; Second command should be ffplay + (should (string-match-p "ffplay" (car commands))) + (should (string-match-p "-autoexit" (car commands)))))) + (test-monitor-teardown))) + +(ert-deftest test-video-audio-recording-test-monitor-normal-displays-messages () + "Test that function displays appropriate messages to user." + (test-monitor-setup) + (unwind-protect + (progn + (setq cj/recording-system-device "test-monitor-device") + (let ((messages nil)) + ;; Capture messages + (cl-letf (((symbol-function 'message) + (lambda (fmt &rest args) (push (apply #'format fmt args) messages))) + ((symbol-function 'shell-command) + (lambda (_cmd) 0))) + (cj/recording-test-monitor) + (should (>= (length messages) 3)) + ;; Check for recording message + (should (cl-some (lambda (msg) (string-match-p "Recording.*PLAY SOMETHING" msg)) messages)) + ;; Check for playback message + (should (cl-some (lambda (msg) (string-match-p "Playing back" msg)) messages)) + ;; Check for complete message + (should (cl-some (lambda (msg) (string-match-p "complete" msg)) messages))))) + (test-monitor-teardown))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-test-monitor-error-no-monitor-configured-signals-error () + "Test that function signals user-error when monitor device is not configured." + (test-monitor-setup) + (unwind-protect + (progn + (setq cj/recording-system-device nil) + (should-error (cj/recording-test-monitor) :type 'user-error)) + (test-monitor-teardown))) + +(ert-deftest test-video-audio-recording-test-monitor-error-message-mentions-setup () + "Test that error message guides user to run setup." + (test-monitor-setup) + (unwind-protect + (progn + (setq cj/recording-system-device nil) + (condition-case err + (cj/recording-test-monitor) + (user-error + (should (string-match-p "C-; r c" (error-message-string err)))))) + (test-monitor-teardown))) + +(ert-deftest test-video-audio-recording-test-monitor-error-ffmpeg-failure-handled () + "Test that ffmpeg command failure is handled gracefully." + (test-monitor-setup) + (unwind-protect + (progn + (setq cj/recording-system-device "test-monitor-device") + ;; Mock shell-command to fail + (cl-letf (((symbol-function 'shell-command) + (lambda (_cmd) 1))) ;; Non-zero exit code + ;; Should complete without crashing (ffmpeg errors are ignored) + ;; No error is raised - function just completes + (cj/recording-test-monitor) + ;; Test passes if we get here + (should t))) + (test-monitor-teardown))) + +(provide 'test-video-audio-recording-test-monitor) +;;; test-video-audio-recording-test-monitor.el ends here diff --git a/tests/test-video-audio-recording-toggle-functions.el b/tests/test-video-audio-recording-toggle-functions.el new file mode 100644 index 00000000..2355ab4f --- /dev/null +++ b/tests/test-video-audio-recording-toggle-functions.el @@ -0,0 +1,185 @@ +;;; test-video-audio-recording-toggle-functions.el --- Tests for toggle functions -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/video-recording-toggle and cj/audio-recording-toggle functions. +;; Tests start/stop toggle behavior for recording processes. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Stub directory variables +(defvar video-recordings-dir "/tmp/video-recordings/") +(defvar audio-recordings-dir "/tmp/audio-recordings/") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Setup and Teardown + +(defun test-toggle-setup () + "Reset process variables before each test." + (setq cj/video-recording-ffmpeg-process nil) + (setq cj/audio-recording-ffmpeg-process nil) + (setq cj/recording-mic-device "test-mic") + (setq cj/recording-system-device "test-monitor")) + +(defun test-toggle-teardown () + "Clean up process variables after each test." + (when cj/video-recording-ffmpeg-process + (ignore-errors (delete-process cj/video-recording-ffmpeg-process))) + (when cj/audio-recording-ffmpeg-process + (ignore-errors (delete-process cj/audio-recording-ffmpeg-process))) + (setq cj/video-recording-ffmpeg-process nil) + (setq cj/audio-recording-ffmpeg-process nil) + (setq cj/recording-mic-device nil) + (setq cj/recording-system-device nil)) + +;;; Video Toggle - Normal Cases + +(ert-deftest test-video-audio-recording-video-toggle-normal-starts-when-not-recording () + "Test that video toggle starts recording when not currently recording." + (test-toggle-setup) + (unwind-protect + (let ((start-called nil)) + (cl-letf (((symbol-function 'cj/ffmpeg-record-video) + (lambda (_dir) (setq start-called t)))) + (cj/video-recording-toggle nil) + (should start-called))) + (test-toggle-teardown))) + +(ert-deftest test-video-audio-recording-video-toggle-normal-stops-when-recording () + "Test that video toggle stops recording when currently recording." + (test-toggle-setup) + (unwind-protect + (let ((stop-called nil) + (fake-process (make-process :name "test-video" :command '("sleep" "1000")))) + (setq cj/video-recording-ffmpeg-process fake-process) + (cl-letf (((symbol-function 'cj/video-recording-stop) + (lambda () (setq stop-called t)))) + (cj/video-recording-toggle nil) + (should stop-called)) + (ignore-errors (delete-process fake-process))) + (test-toggle-teardown))) + +(ert-deftest test-video-audio-recording-video-toggle-normal-uses-default-directory () + "Test that video toggle uses default directory when no prefix arg." + (test-toggle-setup) + (unwind-protect + (let ((recorded-dir nil)) + (cl-letf (((symbol-function 'cj/ffmpeg-record-video) + (lambda (dir) (setq recorded-dir dir)))) + (cj/video-recording-toggle nil) + (should (equal video-recordings-dir recorded-dir)))) + (test-toggle-teardown))) + +(ert-deftest test-video-audio-recording-video-toggle-normal-prompts-for-location-with-prefix () + "Test that video toggle prompts for location with prefix arg." + (test-toggle-setup) + (unwind-protect + (let ((prompt-called nil) + (recorded-dir nil)) + (cl-letf (((symbol-function 'read-directory-name) + (lambda (_prompt) (setq prompt-called t) "/custom/path/")) + ((symbol-function 'file-directory-p) + (lambda (_dir) t)) ; Directory exists + ((symbol-function 'cj/ffmpeg-record-video) + (lambda (dir) (setq recorded-dir dir)))) + (cj/video-recording-toggle t) + (should prompt-called) + (should (equal "/custom/path/" recorded-dir)))) + (test-toggle-teardown))) + +;;; Audio Toggle - Normal Cases + +(ert-deftest test-video-audio-recording-audio-toggle-normal-starts-when-not-recording () + "Test that audio toggle starts recording when not currently recording." + (test-toggle-setup) + (unwind-protect + (let ((start-called nil)) + (cl-letf (((symbol-function 'cj/ffmpeg-record-audio) + (lambda (_dir) (setq start-called t)))) + (cj/audio-recording-toggle nil) + (should start-called))) + (test-toggle-teardown))) + +(ert-deftest test-video-audio-recording-audio-toggle-normal-stops-when-recording () + "Test that audio toggle stops recording when currently recording." + (test-toggle-setup) + (unwind-protect + (let ((stop-called nil) + (fake-process (make-process :name "test-audio" :command '("sleep" "1000")))) + (setq cj/audio-recording-ffmpeg-process fake-process) + (cl-letf (((symbol-function 'cj/audio-recording-stop) + (lambda () (setq stop-called t)))) + (cj/audio-recording-toggle nil) + (should stop-called)) + (ignore-errors (delete-process fake-process))) + (test-toggle-teardown))) + +(ert-deftest test-video-audio-recording-audio-toggle-normal-uses-default-directory () + "Test that audio toggle uses default directory when no prefix arg." + (test-toggle-setup) + (unwind-protect + (let ((recorded-dir nil)) + (cl-letf (((symbol-function 'cj/ffmpeg-record-audio) + (lambda (dir) (setq recorded-dir dir)))) + (cj/audio-recording-toggle nil) + (should (equal audio-recordings-dir recorded-dir)))) + (test-toggle-teardown))) + +(ert-deftest test-video-audio-recording-audio-toggle-normal-prompts-for-location-with-prefix () + "Test that audio toggle prompts for location with prefix arg." + (test-toggle-setup) + (unwind-protect + (let ((prompt-called nil) + (recorded-dir nil)) + (cl-letf (((symbol-function 'read-directory-name) + (lambda (_prompt) (setq prompt-called t) "/custom/path/")) + ((symbol-function 'file-directory-p) + (lambda (_dir) t)) ; Directory exists + ((symbol-function 'cj/ffmpeg-record-audio) + (lambda (dir) (setq recorded-dir dir)))) + (cj/audio-recording-toggle t) + (should prompt-called) + (should (equal "/custom/path/" recorded-dir)))) + (test-toggle-teardown))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-video-toggle-boundary-creates-directory () + "Test that video toggle creates directory if it doesn't exist." + (test-toggle-setup) + (unwind-protect + (let ((mkdir-called nil)) + (cl-letf (((symbol-function 'file-directory-p) + (lambda (_dir) nil)) + ((symbol-function 'make-directory) + (lambda (_dir _parents) (setq mkdir-called t))) + ((symbol-function 'cj/ffmpeg-record-video) + (lambda (_dir) nil))) + (cj/video-recording-toggle nil) + (should mkdir-called))) + (test-toggle-teardown))) + +(ert-deftest test-video-audio-recording-audio-toggle-boundary-creates-directory () + "Test that audio toggle creates directory if it doesn't exist." + (test-toggle-setup) + (unwind-protect + (let ((mkdir-called nil)) + (cl-letf (((symbol-function 'file-directory-p) + (lambda (_dir) nil)) + ((symbol-function 'make-directory) + (lambda (_dir _parents) (setq mkdir-called t))) + ((symbol-function 'cj/ffmpeg-record-audio) + (lambda (_dir) nil))) + (cj/audio-recording-toggle nil) + (should mkdir-called))) + (test-toggle-teardown))) + +(provide 'test-video-audio-recording-toggle-functions) +;;; test-video-audio-recording-toggle-functions.el ends here diff --git a/tests/testutil-calendar-sync.el b/tests/testutil-calendar-sync.el new file mode 100644 index 00000000..d1a94b01 --- /dev/null +++ b/tests/testutil-calendar-sync.el @@ -0,0 +1,198 @@ +;;; testutil-calendar-sync.el --- Test utilities for calendar-sync -*- lexical-binding: t; -*- + +;;; Commentary: +;; Utilities for testing calendar-sync module, especially dynamic timestamp generation. +;; Following quality-engineer.org guidelines: no hardcoded dates! + +;;; Code: + +(require 'calendar) + +;;; Test Environment Setup + +;; Provide stub for cj/log-silently if not already defined +;; This function is defined in system-lib.el but tests should run standalone +(unless (fboundp 'cj/log-silently) + (defun cj/log-silently (format-string &rest args) + "Stub for testing: silently ignore log messages." + nil)) + +;;; Dynamic Timestamp Generation + +(defun test-calendar-sync-time-today-at (hour minute) + "Generate time for today at HOUR:MINUTE. +Returns (year month day hour minute) list suitable for tests." + (let* ((now (decode-time)) + (year (nth 5 now)) + (month (nth 4 now)) + (day (nth 3 now))) + (list year month day hour minute))) + +(defun test-calendar-sync-time-tomorrow-at (hour minute) + "Generate time for tomorrow at HOUR:MINUTE." + (let* ((tomorrow (time-add (current-time) (* 24 3600))) + (decoded (decode-time tomorrow)) + (year (nth 5 decoded)) + (month (nth 4 decoded)) + (day (nth 3 decoded))) + (list year month day hour minute))) + +(defun test-calendar-sync-time-days-from-now (days hour minute) + "Generate time for DAYS from now at HOUR:MINUTE." + (let* ((future (time-add (current-time) (* days 24 3600))) + (decoded (decode-time future)) + (year (nth 5 decoded)) + (month (nth 4 decoded)) + (day (nth 3 decoded))) + (list year month day hour minute))) + +(defun test-calendar-sync-time-days-ago (days hour minute) + "Generate time for DAYS ago at HOUR:MINUTE." + (let* ((past (time-subtract (current-time) (* days 24 3600))) + (decoded (decode-time past)) + (year (nth 5 decoded)) + (month (nth 4 decoded)) + (day (nth 3 decoded))) + (list year month day hour minute))) + +(defun test-calendar-sync-time-date-only (offset-days) + "Generate date-only timestamp for OFFSET-DAYS from now. +Returns (year month day) list for all-day events and UNTIL dates." + (let* ((future (time-add (current-time) (* offset-days 24 3600))) + (decoded (decode-time future)) + (year (nth 5 decoded)) + (month (nth 4 decoded)) + (day (nth 3 decoded))) + (list year month day))) + +(defun test-calendar-sync-time-date-only-ago (offset-days) + "Generate date-only timestamp for OFFSET-DAYS ago. +Returns (year month day) list for UNTIL dates in the past." + (let* ((past (time-subtract (current-time) (* offset-days 24 3600))) + (decoded (decode-time past)) + (year (nth 5 decoded)) + (month (nth 4 decoded)) + (day (nth 3 decoded))) + (list year month day))) + +(defun test-calendar-sync-date-only-from-datetime (datetime) + "Extract date-only (year month day) from DATETIME list. +DATETIME is (year month day hour minute). +Returns (year month day) suitable for UNTIL dates." + (list (nth 0 datetime) (nth 1 datetime) (nth 2 datetime))) + +;;; .ics Test Data Generation + +(defun test-calendar-sync-ics-datetime (time-list) + "Convert TIME-LIST to iCal DATETIME format. +TIME-LIST is (year month day hour minute). +Returns string like '20251116T140000Z'." + (format "%04d%02d%02dT%02d%02d00Z" + (nth 0 time-list) + (nth 1 time-list) + (nth 2 time-list) + (nth 3 time-list) + (nth 4 time-list))) + +(defun test-calendar-sync-ics-date (time-list) + "Convert TIME-LIST to iCal DATE format. +TIME-LIST is (year month day). +Returns string like '20251116'." + (format "%04d%02d%02d" + (nth 0 time-list) + (nth 1 time-list) + (nth 2 time-list))) + +(defun test-calendar-sync-make-vevent (summary start end &optional description location) + "Create a VEVENT block for testing. +START and END are time lists from test-calendar-sync-time-* functions. +Returns .ics formatted VEVENT string." + (let* ((dtstart (if (= (length start) 5) + (test-calendar-sync-ics-datetime start) + (test-calendar-sync-ics-date start))) + (dtend (when end + (if (= (length end) 5) + (test-calendar-sync-ics-datetime end) + (test-calendar-sync-ics-date end))))) + (concat "BEGIN:VEVENT\n" + "SUMMARY:" summary "\n" + "DTSTART:" dtstart "\n" + (when dtend (concat "DTEND:" dtend "\n")) + (when description (concat "DESCRIPTION:" description "\n")) + (when location (concat "LOCATION:" location "\n")) + "END:VEVENT"))) + +(defun test-calendar-sync-make-ics (&rest events) + "Create complete .ics file with EVENTS. +Each event should be a VEVENT string from `test-calendar-sync-make-vevent'." + (concat "BEGIN:VCALENDAR\n" + "VERSION:2.0\n" + "PRODID:-//Test//Test//EN\n" + (string-join events "\n") + "\nEND:VCALENDAR")) + +;;; Property Test Helpers + +(defun test-calendar-sync-random-future-date () + "Generate random date 1-180 days in future with random time. +Returns (year month day hour minute) list." + (test-calendar-sync-time-days-from-now + (1+ (random 180)) + (random 24) + (random 60))) + +(defun test-calendar-sync-random-past-date () + "Generate random date 1-90 days in past with random time. +Returns (year month day hour minute) list." + (test-calendar-sync-time-days-ago + (1+ (random 90)) + (random 24) + (random 60))) + +(defun test-calendar-sync-random-weekday-subset () + "Generate random non-empty subset of weekdays. +Returns list of weekday strings like (\"MO\" \"WE\" \"FR\")." + (let ((days '("MO" "TU" "WE" "TH" "FR" "SA" "SU")) + (result '())) + (dolist (day days) + (when (zerop (random 2)) + (push day result))) + ;; Ensure non-empty + (or result (list (nth (random 7) days))))) + +(defun test-calendar-sync-random-freq () + "Return random RRULE frequency symbol." + (nth (random 4) '(daily weekly monthly yearly))) + +(defun test-calendar-sync-days-between (date1 date2) + "Calculate days between DATE1 and DATE2. +Both dates are (year month day ...) lists. +Returns float number of days (positive if date2 > date1)." + (let ((t1 (calendar-sync--date-to-time (list (nth 0 date1) (nth 1 date1) (nth 2 date1)))) + (t2 (calendar-sync--date-to-time (list (nth 0 date2) (nth 1 date2) (nth 2 date2))))) + (/ (float-time (time-subtract t2 t1)) 86400.0))) + +(defun test-calendar-sync-wide-range () + "Generate wide date range: 90 days past to 365 days future. +Returns (start-time end-time) suitable for expansion functions." + (list (time-subtract (current-time) (* 90 86400)) + (time-add (current-time) (* 365 86400)))) + +(defun test-calendar-sync-narrow-range () + "Generate narrow date range: today to 30 days future. +Returns (start-time end-time) suitable for expansion functions." + (list (current-time) + (time-add (current-time) (* 30 86400)))) + +(defun test-calendar-sync-date-to-time-value (date) + "Convert DATE list to Emacs time value. +DATE is (year month day) or (year month day hour minute)." + (let ((year (nth 0 date)) + (month (nth 1 date)) + (day (nth 2 date)) + (hour (or (nth 3 date) 0)) + (minute (or (nth 4 date) 0))) + (encode-time 0 minute hour day month year))) + +(provide 'testutil-calendar-sync) +;;; testutil-calendar-sync.el ends here diff --git a/tests/testutil-general.el b/tests/testutil-general.el index d3c08135..b7222d1a 100644 --- a/tests/testutil-general.el +++ b/tests/testutil-general.el @@ -81,31 +81,6 @@ Return the full created path." (cj/create--directory-ensuring-parents path) (cj/create--file-ensuring-parents path content executable)))) - -;; (defun cj/create-file-with-content-ensuring-parents (filepath content &optional executable) -;; "Create a file at FILEPATH with CONTENT, ensuring parent directories exist. -;; FILEPATH will be relative to `cj/test-base-dir'. -;; Signals an error if the file already exists. -;; If EXECUTABLE is non-nil, set executable permission on the file. -;; Errors if the resulting path is outside `cj/test-base-dir`." -;; (let* ((base (file-name-as-directory cj/test-base-dir)) -;; (fullpath (if (file-name-absolute-p filepath) -;; (expand-file-name filepath) -;; (expand-file-name filepath base)))) -;; (unless (string-prefix-p base fullpath) -;; (error "File path %s is outside base test directory %s" fullpath base)) -;; (let ((parent-dir (file-name-directory fullpath))) -;; (when (file-exists-p fullpath) -;; (error "File already exists: %s" fullpath)) -;; (unless (file-directory-p parent-dir) -;; (make-directory parent-dir t)) -;; (with-temp-buffer -;; (insert content) -;; (write-file fullpath)) -;; (when executable -;; (chmod fullpath #o755)) -;; fullpath))) - (defun cj/fix-permissions-recursively (dir) "Recursively set read/write permissions for user under DIR. Directories get user read, write, and execute permissions to allow recursive |
