diff options
Diffstat (limited to 'tests')
45 files changed, 13475 insertions, 0 deletions
diff --git a/tests/Makefile b/tests/Makefile new file mode 100644 index 0000000..18df12e --- /dev/null +++ b/tests/Makefile @@ -0,0 +1,244 @@ +# Makefile for chime.el test suite +# Usage: +# make test - Run all tests +# make test-file FILE=overdue - Run tests in one file +# make test-one TEST=name - Run one specific test +# make test-unit - Run unit tests only +# make test-integration - Run integration tests only +# make clean - Remove byte-compiled files + +# Configuration +EMACS ?= emacs +EMACSFLAGS = --batch -Q +TESTFLAGS = -l ert + +# Dependency paths (adjust if needed) +ELPA_DIR = $(HOME)/.emacs.d/elpa +DASH_DIR = $(shell find $(ELPA_DIR) -maxdepth 1 -name 'dash-*' -type d 2>/dev/null | head -1) +ALERT_DIR = $(shell find $(ELPA_DIR) -maxdepth 1 -name 'alert-*' -type d 2>/dev/null | head -1) +ASYNC_DIR = $(shell find $(ELPA_DIR) -maxdepth 1 -name 'async-*' -type d 2>/dev/null | head -1) + +# Build load path +LOADPATH = -L $(DASH_DIR) -L $(ALERT_DIR) -L $(ASYNC_DIR) + +# Test files +ALL_TESTS = $(wildcard test-*.el) +UNIT_TESTS = $(filter-out test-chime-gcal% test-chime-notifications.el test-chime-process-notifications.el,$(ALL_TESTS)) +INTEGRATION_TESTS = test-chime-notifications.el test-chime-process-notifications.el + +# Colors for output (if terminal supports it) +RED = \033[0;31m +GREEN = \033[0;32m +YELLOW = \033[1;33m +NC = \033[0m # No Color + +.PHONY: all test test-file test-one test-unit test-integration validate lint clean help check-deps + +# Default target +all: test + +# Check if dependencies are available +check-deps: + @if [ -z "$(DASH_DIR)" ]; then \ + echo "$(RED)Error: dash package not found in $(ELPA_DIR)$(NC)"; \ + exit 1; \ + fi + @if [ -z "$(ALERT_DIR)" ]; then \ + echo "$(RED)Error: alert package not found in $(ELPA_DIR)$(NC)"; \ + exit 1; \ + fi + @if [ -z "$(ASYNC_DIR)" ]; then \ + echo "$(RED)Error: async package not found in $(ELPA_DIR)$(NC)"; \ + exit 1; \ + fi + @echo "$(GREEN)✓ All dependencies found$(NC)" + +# Run all tests +test: check-deps + @echo "$(YELLOW)Running all tests ($(words $(ALL_TESTS)) files, ~339 tests)...$(NC)" + @$(EMACS) $(EMACSFLAGS) $(LOADPATH) $(TESTFLAGS) \ + --eval "(dolist (f (directory-files \".\" t \"^test-.*\\\\.el$$\")) (load f))" \ + --eval '(ert-run-tests-batch-and-exit)' 2>&1 | tee test-output.log + @if [ $$? -eq 0 ]; then \ + echo "$(GREEN)✓ All tests passed!$(NC)"; \ + else \ + echo "$(RED)✗ Some tests failed. See test-output.log for details.$(NC)"; \ + exit 1; \ + fi + +# Run tests in one file +test-file: check-deps +ifndef FILE + @echo "$(RED)Error: FILE not specified$(NC)" + @echo "Usage: make test-file FILE=overdue" + @echo " make test-file FILE=test-chime-overdue-todos.el" + @exit 1 +endif + @TESTFILE=$$(find . -maxdepth 1 -name "*$(FILE)*.el" -type f | head -1); \ + if [ -z "$$TESTFILE" ]; then \ + echo "$(RED)Error: No test file matching '$(FILE)' found$(NC)"; \ + exit 1; \ + fi; \ + echo "$(YELLOW)Running tests in $$TESTFILE...$(NC)"; \ + $(EMACS) $(EMACSFLAGS) $(LOADPATH) $(TESTFLAGS) -l "$$TESTFILE" \ + --eval '(ert-run-tests-batch-and-exit)' 2>&1 | tee test-file-output.log; \ + if [ $$? -eq 0 ]; then \ + echo "$(GREEN)✓ All tests in $$TESTFILE passed!$(NC)"; \ + else \ + echo "$(RED)✗ Some tests failed.$(NC)"; \ + exit 1; \ + fi + +# Run one specific test +test-one: check-deps +ifndef TEST + @echo "$(RED)Error: TEST not specified$(NC)" + @echo "Usage: make test-one TEST=pilot" + @echo " make test-one TEST=test-overdue-has-passed-time-today-all-day" + @exit 1 +endif + @echo "$(YELLOW)Searching for test matching '$(TEST)'...$(NC)" + @TESTFILE=$$(grep -l "ert-deftest.*$(TEST)" test-*.el 2>/dev/null | head -1); \ + if [ -z "$$TESTFILE" ]; then \ + echo "$(RED)Error: No test matching '$(TEST)' found$(NC)"; \ + exit 1; \ + fi; \ + TESTNAME=$$(grep "ert-deftest.*$(TEST)" "$$TESTFILE" | sed 's/^(ert-deftest \([^ ]*\).*/\1/' | head -1); \ + echo "$(YELLOW)Running test '$$TESTNAME' in $$TESTFILE...$(NC)"; \ + $(EMACS) $(EMACSFLAGS) $(LOADPATH) $(TESTFLAGS) -l "$$TESTFILE" \ + --eval "(ert-run-tests-batch-and-exit \"$$TESTNAME\")" 2>&1; \ + if [ $$? -eq 0 ]; then \ + echo "$(GREEN)✓ Test $$TESTNAME passed!$(NC)"; \ + else \ + echo "$(RED)✗ Test $$TESTNAME failed.$(NC)"; \ + exit 1; \ + fi + +# Run only unit tests +test-unit: check-deps + @echo "$(YELLOW)Running unit tests ($(words $(UNIT_TESTS)) files)...$(NC)" + @for testfile in $(UNIT_TESTS); do \ + echo " Testing $$testfile..."; \ + done + @$(EMACS) $(EMACSFLAGS) $(LOADPATH) $(TESTFLAGS) \ + $(foreach file,$(UNIT_TESTS),-l $(file)) \ + --eval '(ert-run-tests-batch-and-exit)' 2>&1 | tee test-unit-output.log + @if [ $$? -eq 0 ]; then \ + echo "$(GREEN)✓ All unit tests passed!$(NC)"; \ + else \ + echo "$(RED)✗ Some unit tests failed.$(NC)"; \ + exit 1; \ + fi + +# Run only integration tests +test-integration: check-deps + @echo "$(YELLOW)Running integration tests ($(words $(INTEGRATION_TESTS)) files)...$(NC)" + @$(EMACS) $(EMACSFLAGS) $(LOADPATH) $(TESTFLAGS) \ + $(foreach file,$(INTEGRATION_TESTS),-l $(file)) \ + --eval '(ert-run-tests-batch-and-exit)' 2>&1 | tee test-integration-output.log + @if [ $$? -eq 0 ]; then \ + echo "$(GREEN)✓ All integration tests passed!$(NC)"; \ + else \ + echo "$(RED)✗ Some integration tests failed.$(NC)"; \ + exit 1; \ + fi + +# Count tests +count: + @echo "Test file inventory:" + @for f in $(ALL_TESTS); do \ + count=$$(grep -c "^(ert-deftest" "$$f" 2>/dev/null || echo 0); \ + printf "%3d tests - %s\n" "$$count" "$$f"; \ + done | sort -rn + @total=$$(find . -name "test-*.el" -exec grep -c "^(ert-deftest" {} \; | awk '{sum+=$$1} END {print sum}'); \ + echo "$(GREEN)Total: $$total tests across $(words $(ALL_TESTS)) files$(NC)" + +# List all available tests +list: + @echo "Available tests:" + @grep -h "^(ert-deftest" test-*.el | sed 's/^(ert-deftest \([^ ]*\).*/ \1/' | sort + +# Validate Emacs Lisp syntax +validate: + @echo "$(YELLOW)Validating Emacs Lisp syntax...$(NC)" + @failed=0; \ + total=0; \ + for file in ../chime.el test-*.el testutil-*.el; do \ + if [ -f "$$file" ] && [ ! -d "$$file" ]; then \ + total=$$((total + 1)); \ + output=$$($(EMACS) --batch $(LOADPATH) --eval "(progn \ + (setq byte-compile-error-on-warn nil) \ + (find-file \"$$file\") \ + (condition-case err \ + (progn \ + (check-parens) \ + (message \"✓ $$file - parentheses balanced\")) \ + (error \ + (message \"✗ $$file: %s\" (error-message-string err)) \ + (kill-emacs 1))))" 2>&1 | grep -E '(✓|✗)'); \ + if [ $$? -eq 0 ]; then \ + echo "$(GREEN)$$output$(NC)"; \ + else \ + echo "$(RED)$$output$(NC)"; \ + failed=$$((failed + 1)); \ + fi; \ + fi; \ + done; \ + if [ $$failed -eq 0 ]; then \ + echo "$(GREEN)✓ All $$total files validated successfully$(NC)"; \ + else \ + echo "$(RED)✗ $$failed of $$total files failed validation$(NC)"; \ + exit 1; \ + fi + +# Comprehensive linting with elisp-lint +lint: + @echo "$(YELLOW)Running elisp-lint...$(NC)" + @$(EMACS) --batch --eval "(progn \ + (require 'package) \ + (package-initialize) \ + (require 'elisp-lint))" \ + -f elisp-lint-files-batch \ + --no-checkdoc \ + ../chime.el test-*.el testutil-*.el 2>&1; \ + if [ $$? -eq 0 ]; then \ + echo "$(GREEN)✓ Linting completed successfully$(NC)"; \ + else \ + echo "$(RED)✗ Linting found issues (see above)$(NC)"; \ + exit 1; \ + fi + +# Clean byte-compiled files +clean: + @echo "$(YELLOW)Cleaning byte-compiled files...$(NC)" + @rm -f *.elc ../*.elc + @rm -f test-output.log test-file-output.log test-unit-output.log test-integration-output.log + @echo "$(GREEN)✓ Cleaned$(NC)" + +# Show help +help: + @echo "Chime Test Suite Makefile" + @echo "" + @echo "Usage:" + @echo " make test - Run all tests (339 tests)" + @echo " make test-file FILE=overdue - Run tests in one file (fuzzy match)" + @echo " make test-one TEST=pilot - Run one specific test (fuzzy match)" + @echo " make test-unit - Run unit tests only" + @echo " make test-integration - Run integration tests only" + @echo " make validate - Validate Emacs Lisp syntax (parens balance)" + @echo " make lint - Comprehensive linting with elisp-lint" + @echo " make count - Count tests per file" + @echo " make list - List all test names" + @echo " make clean - Remove byte-compiled files and logs" + @echo " make check-deps - Verify all dependencies are installed" + @echo " make help - Show this help message" + @echo "" + @echo "Examples:" + @echo " make test # Run everything" + @echo " make test-file FILE=overdue # Run test-chime-overdue-todos.el" + @echo " make test-one TEST=pilot # Run the pilot test" + @echo " make test-one TEST=test-overdue-has-passed # Run specific test" + @echo "" + @echo "Environment variables:" + @echo " EMACS - Emacs executable (default: emacs)" + @echo " ELPA_DIR - ELPA package directory (default: ~/.emacs.d/elpa)" diff --git a/tests/test-chime--deduplicate-events-by-title.el b/tests/test-chime--deduplicate-events-by-title.el new file mode 100644 index 0000000..494db57 --- /dev/null +++ b/tests/test-chime--deduplicate-events-by-title.el @@ -0,0 +1,200 @@ +;;; test-chime--deduplicate-events-by-title.el --- Tests for event deduplication by title -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--deduplicate-events-by-title. +;; Tests that recurring events (expanded into multiple instances by org-agenda-list) +;; are deduplicated to show only the soonest occurrence of each title. +;; +;; This fixes bug001: Recurring Events Show Duplicate Entries in Tooltip + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Enable debug mode and load chime +(setq chime-debug t) +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Test Helpers + +(defun test-make-event (title) + "Create a test event object with TITLE." + `((title . ,title))) + +(defun test-make-upcoming-item (title minutes) + "Create a test upcoming-events item with TITLE and MINUTES until event. +Returns format: (EVENT TIME-INFO MINUTES)" + (list (test-make-event title) + '("dummy-time-string" . nil) ; TIME-INFO (not used in deduplication) + minutes)) + +;;; Normal Cases + +(ert-deftest test-chime--deduplicate-events-by-title-normal-recurring-daily-keeps-soonest () + "Test that recurring daily event keeps only the soonest occurrence." + (let* ((events (list + (test-make-upcoming-item "Daily Standup" 60) ; 1 hour away + (test-make-upcoming-item "Daily Standup" 1500) ; tomorrow + (test-make-upcoming-item "Daily Standup" 2940))) ; day after + (result (chime--deduplicate-events-by-title events))) + (should (= 1 (length result))) + (should (string= "Daily Standup" (cdr (assoc 'title (car (car result)))))) + (should (= 60 (caddr (car result)))))) + +(ert-deftest test-chime--deduplicate-events-by-title-normal-multiple-different-events () + "Test that different event titles are all preserved." + (let* ((events (list + (test-make-upcoming-item "Meeting A" 30) + (test-make-upcoming-item "Meeting B" 60) + (test-make-upcoming-item "Meeting C" 90))) + (result (chime--deduplicate-events-by-title events))) + (should (= 3 (length result))) + ;; All three events should be present + (should (cl-find-if (lambda (item) (string= "Meeting A" (cdr (assoc 'title (car item))))) result)) + (should (cl-find-if (lambda (item) (string= "Meeting B" (cdr (assoc 'title (car item))))) result)) + (should (cl-find-if (lambda (item) (string= "Meeting C" (cdr (assoc 'title (car item))))) result)))) + +(ert-deftest test-chime--deduplicate-events-by-title-normal-mixed-recurring-and-unique () + "Test mix of recurring (duplicated) and unique events." + (let* ((events (list + (test-make-upcoming-item "Daily Wrap Up" 120) ; 2 hours + (test-make-upcoming-item "Team Sync" 180) ; 3 hours (unique) + (test-make-upcoming-item "Daily Wrap Up" 1560) ; tomorrow + (test-make-upcoming-item "Daily Wrap Up" 3000))) ; day after + (result (chime--deduplicate-events-by-title events))) + (should (= 2 (length result))) + ;; Daily Wrap Up should appear once (soonest at 120 minutes) + (let ((daily-wrap-up (cl-find-if (lambda (item) + (string= "Daily Wrap Up" (cdr (assoc 'title (car item))))) + result))) + (should daily-wrap-up) + (should (= 120 (caddr daily-wrap-up)))) + ;; Team Sync should appear once + (let ((team-sync (cl-find-if (lambda (item) + (string= "Team Sync" (cdr (assoc 'title (car item))))) + result))) + (should team-sync) + (should (= 180 (caddr team-sync)))))) + +;;; Boundary Cases + +(ert-deftest test-chime--deduplicate-events-by-title-boundary-empty-list-returns-empty () + "Test that empty list returns empty list." + (let ((result (chime--deduplicate-events-by-title '()))) + (should (null result)))) + +(ert-deftest test-chime--deduplicate-events-by-title-boundary-single-event-returns-same () + "Test that single event is returned unchanged." + (let* ((events (list (test-make-upcoming-item "Solo Event" 45))) + (result (chime--deduplicate-events-by-title events))) + (should (= 1 (length result))) + (should (string= "Solo Event" (cdr (assoc 'title (car (car result)))))) + (should (= 45 (caddr (car result)))))) + +(ert-deftest test-chime--deduplicate-events-by-title-boundary-all-same-title-keeps-soonest () + "Test that when all events have same title, only the soonest is kept." + (let* ((events (list + (test-make-upcoming-item "Recurring Task" 300) + (test-make-upcoming-item "Recurring Task" 100) ; soonest + (test-make-upcoming-item "Recurring Task" 500) + (test-make-upcoming-item "Recurring Task" 200))) + (result (chime--deduplicate-events-by-title events))) + (should (= 1 (length result))) + (should (= 100 (caddr (car result)))))) + +(ert-deftest test-chime--deduplicate-events-by-title-boundary-two-events-same-title-keeps-soonest () + "Test that with two events of same title, soonest is kept." + (let* ((events (list + (test-make-upcoming-item "Daily Check" 200) + (test-make-upcoming-item "Daily Check" 50))) ; soonest + (result (chime--deduplicate-events-by-title events))) + (should (= 1 (length result))) + (should (= 50 (caddr (car result)))))) + +(ert-deftest test-chime--deduplicate-events-by-title-boundary-same-title-same-time () + "Test events with same title and same time (edge case). +One instance should be kept." + (let* ((events (list + (test-make-upcoming-item "Duplicate Time" 100) + (test-make-upcoming-item "Duplicate Time" 100))) + (result (chime--deduplicate-events-by-title events))) + (should (= 1 (length result))) + (should (= 100 (caddr (car result)))))) + +(ert-deftest test-chime--deduplicate-events-by-title-boundary-zero-minutes () + "Test event happening right now (0 minutes away)." + (let* ((events (list + (test-make-upcoming-item "Happening Now" 0) + (test-make-upcoming-item "Happening Now" 1440))) ; tomorrow + (result (chime--deduplicate-events-by-title events))) + (should (= 1 (length result))) + (should (= 0 (caddr (car result)))))) + +(ert-deftest test-chime--deduplicate-events-by-title-boundary-large-minute-values () + "Test with very large minute values (1 year lookahead)." + (let* ((events (list + (test-make-upcoming-item "Annual Review" 60) + (test-make-upcoming-item "Annual Review" 525600))) ; 365 days + (result (chime--deduplicate-events-by-title events))) + (should (= 1 (length result))) + (should (= 60 (caddr (car result)))))) + +(ert-deftest test-chime--deduplicate-events-by-title-boundary-title-with-special-chars () + "Test titles with special characters." + (let* ((events (list + (test-make-upcoming-item "Review: Q1 Report (Draft)" 100) + (test-make-upcoming-item "Review: Q1 Report (Draft)" 200))) + (result (chime--deduplicate-events-by-title events))) + (should (= 1 (length result))) + (should (= 100 (caddr (car result)))))) + +(ert-deftest test-chime--deduplicate-events-by-title-boundary-empty-title () + "Test events with empty string titles." + (let* ((events (list + (test-make-upcoming-item "" 100) + (test-make-upcoming-item "" 200))) + (result (chime--deduplicate-events-by-title events))) + (should (= 1 (length result))) + (should (= 100 (caddr (car result)))))) + +;;; Error Cases + +(ert-deftest test-chime--deduplicate-events-by-title-error-nil-input-returns-empty () + "Test that nil input returns empty list." + (let ((result (chime--deduplicate-events-by-title nil))) + (should (null result)))) + +(provide 'test-chime--deduplicate-events-by-title) +;;; test-chime--deduplicate-events-by-title.el ends here diff --git a/tests/test-chime--time=.el b/tests/test-chime--time=.el new file mode 100644 index 0000000..1015abb --- /dev/null +++ b/tests/test-chime--time=.el @@ -0,0 +1,108 @@ +;;; test-chime--time=.el --- Tests for chime--time= -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--time= function. +;; Tests timestamp comparison ignoring seconds component. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'dash) +(require 'alert) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;;; Setup and Teardown + +(defun test-chime--time=-setup () + "Setup test environment." + ;; No special setup needed + ) + +(defun test-chime--time=-teardown () + "Teardown test environment." + ;; No special teardown needed + ) + +;;; Normal Cases + +(ert-deftest test-chime--time=-normal-two-equal-times-returns-true () + "Test that two timestamps with same day/hour/minute return true." + (test-chime--time=-setup) + (unwind-protect + (let* ((time1 (encode-time 15 30 14 8 11 2025)) ; 2025-11-08 14:30:15 + (time2 (encode-time 45 30 14 8 11 2025))) ; 2025-11-08 14:30:45 (different seconds) + (should (chime--time= time1 time2))) + (test-chime--time=-teardown))) + +(ert-deftest test-chime--time=-normal-three-equal-times-returns-true () + "Test that three timestamps with same day/hour/minute return true." + (test-chime--time=-setup) + (unwind-protect + (let* ((time1 (encode-time 10 45 9 8 11 2025)) + (time2 (encode-time 20 45 9 8 11 2025)) + (time3 (encode-time 55 45 9 8 11 2025))) + (should (chime--time= time1 time2 time3))) + (test-chime--time=-teardown))) + +(ert-deftest test-chime--time=-normal-two-different-times-returns-nil () + "Test that timestamps with different hour/minute return nil." + (test-chime--time=-setup) + (unwind-protect + (let* ((time1 (encode-time 0 30 14 8 11 2025)) ; 14:30 + (time2 (encode-time 0 31 14 8 11 2025))) ; 14:31 + (should-not (chime--time= time1 time2))) + (test-chime--time=-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime--time=-boundary-single-time-returns-true () + "Test that single timestamp returns true." + (test-chime--time=-setup) + (unwind-protect + (let ((time (encode-time 0 0 12 8 11 2025))) + (should (chime--time= time))) + (test-chime--time=-teardown))) + +(ert-deftest test-chime--time=-boundary-empty-list-returns-nil () + "Test that empty argument list returns nil." + (test-chime--time=-setup) + (unwind-protect + ;; Empty list has no elements, unique length is 0, not 1 + (should-not (chime--time= )) + (test-chime--time=-teardown))) + +(ert-deftest test-chime--time=-boundary-different-days-same-time-returns-nil () + "Test that same time on different days returns nil." + (test-chime--time=-setup) + (unwind-protect + (let* ((time1 (encode-time 0 30 14 8 11 2025)) ; Nov 8 + (time2 (encode-time 0 30 14 9 11 2025))) ; Nov 9 + (should-not (chime--time= time1 time2))) + (test-chime--time=-teardown))) + +(provide 'test-chime--time=) +;;; test-chime--time=.el ends here diff --git a/tests/test-chime--today.el b/tests/test-chime--today.el new file mode 100644 index 0000000..c053364 --- /dev/null +++ b/tests/test-chime--today.el @@ -0,0 +1,93 @@ +;;; test-chime--today.el --- Tests for chime--today -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--today function. +;; Tests retrieving beginning of current day timestamp. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'dash) +(require 'alert) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;;; Setup and Teardown + +(defun test-chime--today-setup () + "Setup test environment." + ;; No special setup needed + ) + +(defun test-chime--today-teardown () + "Teardown test environment." + ;; No special teardown needed + ) + +;;; Normal Cases + +(ert-deftest test-chime--today-normal-returns-current-date () + "Test that chime--today returns midnight of current day." + (test-chime--today-setup) + (unwind-protect + (let* ((now (encode-time 30 45 14 8 11 2025)) ; 2025-11-08 14:45:30 + (expected (encode-time 0 0 0 8 11 2025))) ; 2025-11-08 00:00:00 + (cl-letf (((symbol-function 'current-time) (lambda () now))) + (should (equal (chime--today) expected)))) + (test-chime--today-teardown))) + +(ert-deftest test-chime--today-normal-truncates-time-component () + "Test that chime--today zeros out hour/minute/second." + (test-chime--today-setup) + (unwind-protect + (let* ((now (encode-time 59 59 23 8 11 2025)) ; 2025-11-08 23:59:59 + (result (cl-letf (((symbol-function 'current-time) (lambda () now))) + (chime--today))) + (decoded (decode-time result))) + ;; Check that hour, minute, second are all 0 + (should (= 0 (decoded-time-second decoded))) + (should (= 0 (decoded-time-minute decoded))) + (should (= 0 (decoded-time-hour decoded))) + ;; Check date is preserved + (should (= 8 (decoded-time-day decoded))) + (should (= 11 (decoded-time-month decoded))) + (should (= 2025 (decoded-time-year decoded)))) + (test-chime--today-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime--today-boundary-midnight-returns-correct-day () + "Test that chime--today at midnight returns same day." + (test-chime--today-setup) + (unwind-protect + (let* ((now (encode-time 0 0 0 8 11 2025)) ; Already at midnight + (expected (encode-time 0 0 0 8 11 2025))) + (cl-letf (((symbol-function 'current-time) (lambda () now))) + (should (equal (chime--today) expected)))) + (test-chime--today-teardown))) + +(provide 'test-chime--today) +;;; test-chime--today.el ends here diff --git a/tests/test-chime--truncate-title.el b/tests/test-chime--truncate-title.el new file mode 100644 index 0000000..e888b43 --- /dev/null +++ b/tests/test-chime--truncate-title.el @@ -0,0 +1,120 @@ +;;; test-chime--truncate-title.el --- Tests for chime--truncate-title -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--truncate-title function. +;; Tests title truncation with ellipsis based on chime-max-title-length. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'dash) +(require 'alert) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;;; Setup and Teardown + +(defun test-chime--truncate-title-setup () + "Setup test environment." + ;; No special setup needed + ) + +(defun test-chime--truncate-title-teardown () + "Teardown test environment." + ;; No special teardown needed + ) + +;;; Normal Cases + +(ert-deftest test-chime--truncate-title-normal-short-title-unchanged () + "Test that short title under max length is unchanged." + (test-chime--truncate-title-setup) + (unwind-protect + (let ((chime-max-title-length 20)) + (should (equal (chime--truncate-title "Short title") + "Short title"))) + (test-chime--truncate-title-teardown))) + +(ert-deftest test-chime--truncate-title-normal-long-title-truncated-with-ellipsis () + "Test that long title is truncated with ... appended." + (test-chime--truncate-title-setup) + (unwind-protect + (let ((chime-max-title-length 15)) + (should (equal (chime--truncate-title "This is a very long title that needs truncation") + "This is a ve..."))) + (test-chime--truncate-title-teardown))) + +(ert-deftest test-chime--truncate-title-normal-unicode-characters-truncated-correctly () + "Test that unicode characters are handled correctly in truncation." + (test-chime--truncate-title-setup) + (unwind-protect + (let ((chime-max-title-length 10)) + (should (equal (chime--truncate-title "Meeting 🎉 with team") + "Meeting..."))) + (test-chime--truncate-title-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime--truncate-title-boundary-exact-max-length-unchanged () + "Test that title exactly at max length is unchanged." + (test-chime--truncate-title-setup) + (unwind-protect + (let ((chime-max-title-length 12)) + (should (equal (chime--truncate-title "Twelve chars") + "Twelve chars"))) + (test-chime--truncate-title-teardown))) + +(ert-deftest test-chime--truncate-title-boundary-one-char-over-max-truncated () + "Test that title one character over max is truncated." + (test-chime--truncate-title-setup) + (unwind-protect + (let ((chime-max-title-length 10)) + (should (equal (chime--truncate-title "Eleven char") + "Eleven ..."))) + (test-chime--truncate-title-teardown))) + +(ert-deftest test-chime--truncate-title-boundary-empty-string-returns-empty () + "Test that empty string returns empty string." + (test-chime--truncate-title-setup) + (unwind-protect + (let ((chime-max-title-length 20)) + (should (equal (chime--truncate-title "") + ""))) + (test-chime--truncate-title-teardown))) + +;;; Error Cases + +(ert-deftest test-chime--truncate-title-error-nil-title-returns-empty () + "Test that nil title returns empty string." + (test-chime--truncate-title-setup) + (unwind-protect + (let ((chime-max-title-length 20)) + (should (equal (chime--truncate-title nil) + ""))) + (test-chime--truncate-title-teardown))) + +(provide 'test-chime--truncate-title) +;;; test-chime--truncate-title.el ends here diff --git a/tests/test-chime-12hour-format.el b/tests/test-chime-12hour-format.el new file mode 100644 index 0000000..2b2a3e7 --- /dev/null +++ b/tests/test-chime-12hour-format.el @@ -0,0 +1,227 @@ +;;; test-chime-12hour-format.el --- Tests for 12-hour am/pm timestamp parsing -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Tests for 12-hour am/pm timestamp format support. +;; Verifies that chime correctly parses timestamps like: +;; - <2025-11-05 Wed 1:30pm> +;; - <2025-11-05 Wed 1:30 PM> +;; - <2025-11-05 Wed 12:00pm> +;; - <2025-11-05 Wed 12:00am> + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Enable debug mode and load chime +(setq chime-debug t) +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Tests for chime--convert-12hour-to-24hour + +(ert-deftest test-12hour-convert-1pm-to-13 () + "Test that 1pm converts to hour 13." + (should (= 13 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 1:30pm>" 1)))) + +(ert-deftest test-12hour-convert-1pm-uppercase-to-13 () + "Test that 1PM (uppercase) converts to hour 13." + (should (= 13 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 1:30PM>" 1)))) + +(ert-deftest test-12hour-convert-1pm-with-space-to-13 () + "Test that 1 PM (with space) converts to hour 13." + (should (= 13 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 1:30 PM>" 1)))) + +(ert-deftest test-12hour-convert-11pm-to-23 () + "Test that 11pm converts to hour 23." + (should (= 23 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 11:59pm>" 11)))) + +(ert-deftest test-12hour-convert-12pm-noon-stays-12 () + "Test that 12pm (noon) stays as hour 12." + (should (= 12 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 12:00pm>" 12)))) + +(ert-deftest test-12hour-convert-12am-midnight-to-0 () + "Test that 12am (midnight) converts to hour 0." + (should (= 0 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 12:00am>" 12)))) + +(ert-deftest test-12hour-convert-1am-stays-1 () + "Test that 1am stays as hour 1." + (should (= 1 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 1:00am>" 1)))) + +(ert-deftest test-12hour-convert-11am-stays-11 () + "Test that 11am stays as hour 11." + (should (= 11 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 11:59am>" 11)))) + +(ert-deftest test-12hour-convert-24hour-format-unchanged () + "Test that 24-hour format (no am/pm) is unchanged." + (should (= 13 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 13:30>" 13)))) + +(ert-deftest test-12hour-convert-24hour-0-unchanged () + "Test that 24-hour format hour 0 (midnight) is unchanged." + (should (= 0 (chime--convert-12hour-to-24hour "<2025-11-05 Wed 00:00>" 0)))) + +;;; Tests for chime--timestamp-parse with 12-hour format + +(ert-deftest test-12hour-parse-1-30pm () + "Test parsing 1:30pm returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 1:30pm>"))) + (should result) + ;; Check that hour is 13 (1pm) + (let ((time-decoded (decode-time result))) + (should (= 13 (decoded-time-hour time-decoded))) + (should (= 30 (decoded-time-minute time-decoded))) + (should (= 5 (decoded-time-day time-decoded))) + (should (= 11 (decoded-time-month time-decoded))) + (should (= 2025 (decoded-time-year time-decoded)))))) + +(ert-deftest test-12hour-parse-2-00pm () + "Test parsing 2:00PM (uppercase) returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 2:00PM>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 14 (decoded-time-hour time-decoded))) + (should (= 0 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-3-45-pm-with-space () + "Test parsing 3:45 PM (with space) returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 3:45 PM>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 15 (decoded-time-hour time-decoded))) + (should (= 45 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-11-59pm () + "Test parsing 11:59pm (last minute before midnight) returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 11:59pm>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 23 (decoded-time-hour time-decoded))) + (should (= 59 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-12-00pm-noon () + "Test parsing 12:00pm (noon) returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 12:00pm>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 12 (decoded-time-hour time-decoded))) + (should (= 0 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-12-30pm () + "Test parsing 12:30pm (afternoon) returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 12:30pm>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 12 (decoded-time-hour time-decoded))) + (should (= 30 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-12-00am-midnight () + "Test parsing 12:00am (midnight) returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 12:00am>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 0 (decoded-time-hour time-decoded))) + (should (= 0 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-12-30am () + "Test parsing 12:30am (after midnight) returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 12:30am>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 0 (decoded-time-hour time-decoded))) + (should (= 30 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-1-00am () + "Test parsing 1:00am returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 1:00am>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 1 (decoded-time-hour time-decoded))) + (should (= 0 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-11-59am () + "Test parsing 11:59am (last minute before noon) returns correct time list." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 11:59am>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 11 (decoded-time-hour time-decoded))) + (should (= 59 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-24hour-still-works () + "Test that 24-hour format (13:30) still works correctly." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 13:30>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 13 (decoded-time-hour time-decoded))) + (should (= 30 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-24hour-midnight () + "Test that 24-hour format 00:00 (midnight) still works correctly." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 00:00>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 0 (decoded-time-hour time-decoded))) + (should (= 0 (decoded-time-minute time-decoded)))))) + +(ert-deftest test-12hour-parse-24hour-23-59 () + "Test that 24-hour format 23:59 still works correctly." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 23:59>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 23 (decoded-time-hour time-decoded))) + (should (= 59 (decoded-time-minute time-decoded)))))) + +;;; Mixed case and whitespace variations + +(ert-deftest test-12hour-parse-mixed-case-Pm () + "Test parsing with mixed case Pm." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 3:30Pm>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 15 (decoded-time-hour time-decoded)))))) + +(ert-deftest test-12hour-parse-mixed-case-pM () + "Test parsing with mixed case pM." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 3:30pM>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 15 (decoded-time-hour time-decoded)))))) + +(ert-deftest test-12hour-parse-multiple-spaces () + "Test parsing with multiple spaces before am/pm." + (let ((result (chime--timestamp-parse "<2025-11-05 Wed 3:30 pm>"))) + (should result) + (let ((time-decoded (decode-time result))) + (should (= 15 (decoded-time-hour time-decoded)))))) + +(provide 'test-chime-12hour-format) +;;; test-chime-12hour-format.el ends here diff --git a/tests/test-chime-all-day-events.el b/tests/test-chime-all-day-events.el new file mode 100644 index 0000000..2dbffd6 --- /dev/null +++ b/tests/test-chime-all-day-events.el @@ -0,0 +1,274 @@ +;;; test-chime-all-day-events.el --- Tests for all-day event handling -*- lexical-binding: t; -*- + +;; Tests for: +;; - All-day event detection +;; - Tooltip display configuration (chime-tooltip-show-all-day-events) +;; - Day-wide notifications +;; - Advance notice notifications + +;;; Code: + +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'dash) +(require 'org-agenda) +(load (expand-file-name "../chime.el") nil t) +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Helper Functions + +(defun test-allday--create-event (title &optional timestamp-str has-time) + "Create a test event with TITLE and TIMESTAMP-STR. +If HAS-TIME is t, timestamp includes time component." + (let* ((ts-str (or timestamp-str + (if has-time + "<2025-11-15 Sat 10:00-11:00>" + "<2025-11-15 Sat>"))) + (parsed-time (when has-time + (chime--timestamp-parse ts-str)))) + `((title . ,title) + (times . ((,ts-str . ,parsed-time))) + (intervals . ((0 15 30))) + (marker-file . "/tmp/test.org") + (marker-pos . 1)))) + +;;; Tests: All-day event detection + +(ert-deftest test-chime-has-timestamp-with-time () + "Test that timestamps with time component are detected. + +REFACTORED: Uses dynamic timestamps" + (let ((time1 (test-time-tomorrow-at 10 0)) + (time2 (test-time-tomorrow-at 9 30))) + (should (chime--has-timestamp (test-timestamp-string time1))) + (should (chime--has-timestamp (test-timestamp-string time1))) + (should (chime--has-timestamp (format-time-string "<%Y-%m-%d %a %H:%M-%H:%M>" time2))))) + +(ert-deftest test-chime-has-timestamp-without-time () + "Test that all-day timestamps (no time) are correctly identified. + +REFACTORED: Uses dynamic timestamps" + (let ((time1 (test-time-tomorrow-at 0 0)) + (time2 (test-time-days-from-now 10)) + (time3 (test-time-days-from-now 30))) + (should-not (chime--has-timestamp (test-timestamp-string time1 t))) + (should-not (chime--has-timestamp (test-timestamp-string time2 t))) + (should-not (chime--has-timestamp (test-timestamp-string time3 t))))) + +(ert-deftest test-chime-event-has-day-wide-timestamp () + "Test detection of events with all-day timestamps. + +REFACTORED: Uses dynamic timestamps" + (let* ((all-day-time (test-time-days-from-now 10)) + (timed-time (test-time-tomorrow-at 10 0)) + (all-day-event (test-allday--create-event "Birthday" (test-timestamp-string all-day-time t) nil)) + (timed-event (test-allday--create-event "Meeting" (test-timestamp-string timed-time) t))) + (should (chime-event-has-any-day-wide-timestamp all-day-event)) + (should-not (chime-event-has-any-day-wide-timestamp timed-event)))) + +;;; Tests: Advance notice window + +(ert-deftest test-chime-advance-notice-nil () + "Test that advance notice is disabled when chime-day-wide-advance-notice is nil. + +TIME RELATIONSHIPS: + Current time: TODAY at 10:00 AM + Event: TOMORROW (all-day) + Setting: chime-day-wide-advance-notice = nil + +EXPECTED: Should NOT be in advance notice window (disabled) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (let* ((now (test-time-now)) + (tomorrow (test-time-tomorrow-at 0 0)) + (tomorrow-timestamp (test-timestamp-string tomorrow t)) + (chime-day-wide-advance-notice nil) + (event (test-allday--create-event "Birthday Tomorrow" tomorrow-timestamp nil))) + (with-test-time now + (should-not (chime-event-within-advance-notice-window event))))) + +(ert-deftest test-chime-advance-notice-tomorrow () + "Test advance notice for event tomorrow when set to 1 day. + +TIME RELATIONSHIPS: + Current time: TODAY at 10:00 AM + Event: TOMORROW (all-day) + Setting: chime-day-wide-advance-notice = 1 + +EXPECTED: Should be in advance notice window + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (let* ((now (test-time-now)) + (tomorrow (test-time-tomorrow-at 0 0)) + (tomorrow-timestamp (test-timestamp-string tomorrow t)) + (chime-day-wide-advance-notice 1) + (event (test-allday--create-event "Birthday Tomorrow" tomorrow-timestamp nil))) + (with-test-time now + (should (chime-event-within-advance-notice-window event))))) + +(ert-deftest test-chime-advance-notice-two-days () + "Test advance notice for event in 2 days when set to 2 days. + +TIME: TODAY, Event: 2 DAYS FROM NOW, advance=2 +EXPECTED: Should be in window +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (two-days (test-time-days-from-now 2)) + (timestamp (test-timestamp-string two-days t)) + (chime-day-wide-advance-notice 2) + (event (test-allday--create-event "Birthday in 2 days" timestamp nil))) + (with-test-time now + (should (chime-event-within-advance-notice-window event))))) + +(ert-deftest test-chime-advance-notice-too-far-future () + "Test that events beyond advance notice window are not included. + +TIME: TODAY, Event: 5 DAYS FROM NOW, advance=1 +EXPECTED: Should NOT be in window (too far) +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (five-days (test-time-days-from-now 5)) + (timestamp (test-timestamp-string five-days t)) + (chime-day-wide-advance-notice 1) + (event (test-allday--create-event "Birthday in 5 days" timestamp nil))) + (with-test-time now + (should-not (chime-event-within-advance-notice-window event))))) + +(ert-deftest test-chime-advance-notice-today-not-included () + "Test that today's events are not in advance notice window. + +TIME: TODAY, Event: TODAY, advance=1 +EXPECTED: Should NOT be in window (today is handled separately) +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (today-timestamp (test-timestamp-string now t)) + (chime-day-wide-advance-notice 1) + (event (test-allday--create-event "Birthday Today" today-timestamp nil))) + (with-test-time now + ;; Today's event should NOT be in advance notice window + ;; It should be handled by regular day-wide logic + (should-not (chime-event-within-advance-notice-window event))))) + +(ert-deftest test-chime-advance-notice-timed-events-ignored () + "Test that timed events are not included in advance notice. + +TIME: TODAY, Event: TOMORROW with time, advance=1 +EXPECTED: Should NOT be in window (only all-day events qualify) +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (tomorrow (test-time-tomorrow-at 10 0)) + (timestamp (test-timestamp-string tomorrow)) + (chime-day-wide-advance-notice 1) + (event (test-allday--create-event "Meeting Tomorrow" timestamp t))) + (with-test-time now + ;; Timed events should not trigger advance notices + (should-not (chime-event-within-advance-notice-window event))))) + +;;; Tests: Day-wide notification text + +(ert-deftest test-chime-day-wide-notification-today () + "Test notification text for all-day event today. +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (today-timestamp (test-timestamp-string now t)) + (chime-day-wide-advance-notice nil) + (event (test-allday--create-event "Blake's Birthday" today-timestamp nil))) + (with-test-time now + (let ((text (chime--day-wide-notification-text event))) + (should (string-match-p "Blake's Birthday is due or scheduled today" text)))))) + +(ert-deftest test-chime-day-wide-notification-tomorrow () + "Test notification text for all-day event tomorrow with advance notice. +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (tomorrow (test-time-tomorrow-at 0 0)) + (tomorrow-timestamp (test-timestamp-string tomorrow t)) + (chime-day-wide-advance-notice 1) + (event (test-allday--create-event "Blake's Birthday" tomorrow-timestamp nil))) + (with-test-time now + (let ((text (chime--day-wide-notification-text event))) + (should (string-match-p "Blake's Birthday is tomorrow" text)))))) + +(ert-deftest test-chime-day-wide-notification-in-2-days () + "Test notification text for all-day event in 2 days with advance notice. +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (two-days (test-time-days-from-now 2)) + (timestamp (test-timestamp-string two-days t)) + (chime-day-wide-advance-notice 2) + (event (test-allday--create-event "Blake's Birthday" timestamp nil))) + (with-test-time now + (let ((text (chime--day-wide-notification-text event))) + (should (string-match-p "Blake's Birthday is in 2 days" text)))))) + +(ert-deftest test-chime-day-wide-notification-in-N-days () + "Test notification text for all-day event in N days with advance notice. +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (five-days (test-time-days-from-now 5)) + (timestamp (test-timestamp-string five-days t)) + (chime-day-wide-advance-notice 5) + (event (test-allday--create-event "Conference" timestamp nil))) + (with-test-time now + (let ((text (chime--day-wide-notification-text event))) + (should (string-match-p "Conference is in [0-9]+ days" text)))))) + +;;; Tests: Display as day-wide event + +(ert-deftest test-chime-display-as-day-wide-event-today () + "Test that all-day events today are displayed as day-wide. +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (today-timestamp (test-timestamp-string now t)) + (chime-day-wide-advance-notice nil) + (event (test-allday--create-event "Birthday Today" today-timestamp nil))) + (with-test-time now + (should (chime-display-as-day-wide-event event))))) + +(ert-deftest test-chime-display-as-day-wide-event-tomorrow-with-advance () + "Test that all-day events tomorrow are displayed when advance notice is enabled. +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (tomorrow (test-time-tomorrow-at 0 0)) + (timestamp (test-timestamp-string tomorrow t)) + (chime-day-wide-advance-notice 1) + (event (test-allday--create-event "Birthday Tomorrow" timestamp nil))) + (with-test-time now + (should (chime-display-as-day-wide-event event))))) + +(ert-deftest test-chime-display-as-day-wide-event-tomorrow-without-advance () + "Test that all-day events tomorrow are NOT displayed without advance notice. +REFACTORED: Uses dynamic timestamps" + (let* ((now (test-time-now)) + (tomorrow (test-time-tomorrow-at 0 0)) + (timestamp (test-timestamp-string tomorrow t)) + (chime-day-wide-advance-notice nil) + (event (test-allday--create-event "Birthday Tomorrow" timestamp nil))) + (with-test-time now + (should-not (chime-display-as-day-wide-event event))))) + +;;; Tests: Tooltip configuration + +;; Note: These tests verify the logic exists, but full integration testing +;; requires the modeline update function which is async. See integration tests. + +(ert-deftest test-chime-tooltip-config-exists () + "Test that chime-tooltip-show-all-day-events customization exists." + (should (boundp 'chime-tooltip-show-all-day-events)) + (should (booleanp chime-tooltip-show-all-day-events))) + +(ert-deftest test-chime-day-wide-alert-times-default () + "Test that chime-day-wide-alert-times has correct default." + (should (boundp 'chime-day-wide-alert-times)) + (should (equal chime-day-wide-alert-times '("08:00")))) + +(ert-deftest test-chime-day-wide-advance-notice-default () + "Test that chime-day-wide-advance-notice has correct default." + (should (boundp 'chime-day-wide-advance-notice)) + (should (null chime-day-wide-advance-notice))) + +(provide 'test-chime-all-day-events) +;;; test-chime-all-day-events.el ends here diff --git a/tests/test-chime-apply-blacklist.el b/tests/test-chime-apply-blacklist.el new file mode 100644 index 0000000..6a82030 --- /dev/null +++ b/tests/test-chime-apply-blacklist.el @@ -0,0 +1,247 @@ +;;; test-chime-apply-blacklist.el --- Tests for chime--apply-blacklist -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--apply-blacklist function. +;; Tests use real org-mode buffers with real org syntax. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) + +;;; Setup and Teardown + +(defun test-chime-apply-blacklist-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset blacklist settings + (setq chime-keyword-blacklist nil) + (setq chime-tags-blacklist nil) + (setq chime-predicate-blacklist nil)) + +(defun test-chime-apply-blacklist-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir) + (setq chime-keyword-blacklist nil) + (setq chime-tags-blacklist nil) + (setq chime-predicate-blacklist nil)) + +;;; Normal Cases + +(ert-deftest test-chime-apply-blacklist-nil-blacklist-returns-all-markers () + "Test that nil blacklist returns all markers unchanged." + (test-chime-apply-blacklist-setup) + (unwind-protect + (let* ((chime-keyword-blacklist nil) + (chime-tags-blacklist nil) + (markers (list (make-marker) (make-marker) (make-marker))) + (result (chime--apply-blacklist markers))) + ;; Should return all markers when blacklist is nil + (should (equal (length result) 3))) + (test-chime-apply-blacklist-teardown))) + +(ert-deftest test-chime-apply-blacklist-keyword-blacklist-filters-correctly () + "Test that keyword blacklist filters out markers correctly." + (test-chime-apply-blacklist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1\n") + (insert "* DONE Task 2\n") + (insert "* TODO Task 3\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker)) + (chime-keyword-blacklist '("DONE"))) + (let ((result (chime--apply-blacklist (list marker1 marker2 marker3)))) + ;; Should filter out DONE marker + (should (= (length result) 2)) + (should (member marker1 result)) + (should-not (member marker2 result)) + (should (member marker3 result))))))) + (test-chime-apply-blacklist-teardown))) + +(ert-deftest test-chime-apply-blacklist-tags-blacklist-filters-correctly () + "Test that tags blacklist filters out markers correctly." + (test-chime-apply-blacklist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* Task 1 :work:urgent:\n") + (insert "* Task 2 :personal:\n") + (insert "* Task 3 :work:\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker)) + (chime-tags-blacklist '("personal"))) + (let ((result (chime--apply-blacklist (list marker1 marker2 marker3)))) + ;; Should filter out marker with "personal" tag + (should (= (length result) 2)) + (should (member marker1 result)) + (should-not (member marker2 result)) + (should (member marker3 result))))))) + (test-chime-apply-blacklist-teardown))) + +(ert-deftest test-chime-apply-blacklist-keyword-and-tags-blacklist-uses-or-logic () + "Test that both keyword and tags blacklists use OR logic." + (test-chime-apply-blacklist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1\n") + (insert "* DONE Task 2\n") + (insert "* NEXT Task 3 :archive:\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker)) + (chime-keyword-blacklist '("DONE")) + (chime-tags-blacklist '("archive"))) + (let ((result (chime--apply-blacklist (list marker1 marker2 marker3)))) + ;; Should filter out marker2 (DONE) and marker3 (archive tag) + (should (= (length result) 1)) + (should (member marker1 result)) + (should-not (member marker2 result)) + (should-not (member marker3 result))))))) + (test-chime-apply-blacklist-teardown))) + +(ert-deftest test-chime-apply-blacklist-multiple-keywords-filters-all () + "Test that multiple keywords in blacklist filters all matching." + (test-chime-apply-blacklist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1\n") + (insert "* DONE Task 2\n") + (insert "* DONE Task 3\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker)) + (chime-keyword-blacklist '("DONE"))) + (let ((result (chime--apply-blacklist (list marker1 marker2 marker3)))) + ;; Should only keep TODO marker, filter out both DONE markers + (should (= (length result) 1)) + (should (member marker1 result))))))) + (test-chime-apply-blacklist-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-apply-blacklist-empty-markers-list-returns-empty () + "Test that empty markers list returns empty." + (test-chime-apply-blacklist-setup) + (unwind-protect + (let ((chime-keyword-blacklist '("DONE")) + (result (chime--apply-blacklist '()))) + (should (equal result '()))) + (test-chime-apply-blacklist-teardown))) + +(ert-deftest test-chime-apply-blacklist-single-item-blacklist-works () + "Test that single-item blacklist works correctly." + (test-chime-apply-blacklist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1\n") + (insert "* DONE Task 2\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker)) + (chime-keyword-blacklist '("DONE"))) + (let ((result (chime--apply-blacklist (list marker1 marker2)))) + (should (= (length result) 1)) + (should (member marker1 result)))))) + (test-chime-apply-blacklist-teardown))) + +(ert-deftest test-chime-apply-blacklist-all-markers-blacklisted-returns-empty () + "Test that blacklisting all markers returns empty list." + (test-chime-apply-blacklist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* DONE Task 1\n") + (let ((marker1 (point-marker))) + (insert "* DONE Task 2\n") + (let ((marker2 (point-marker)) + (chime-keyword-blacklist '("DONE"))) + (let ((result (chime--apply-blacklist (list marker1 marker2)))) + (should (equal result '())))))) + (test-chime-apply-blacklist-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-apply-blacklist-handles-nil-keyword-gracefully () + "Test that nil keyword in marker is handled gracefully." + (test-chime-apply-blacklist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* Entry without TODO keyword\n") + (let ((marker1 (point-marker)) + (chime-keyword-blacklist '("DONE"))) + (let ((result (chime--apply-blacklist (list marker1)))) + ;; Should keep marker with nil keyword (not in blacklist) + (should (= (length result) 1))))) + (test-chime-apply-blacklist-teardown))) + +(ert-deftest test-chime-apply-blacklist-handles-nil-tags-gracefully () + "Test that nil tags in marker is handled gracefully." + (test-chime-apply-blacklist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* Entry without tags\n") + (let ((marker1 (point-marker)) + (chime-tags-blacklist '("archive"))) + (let ((result (chime--apply-blacklist (list marker1)))) + ;; Should keep marker with nil tags (not in blacklist) + (should (= (length result) 1))))) + (test-chime-apply-blacklist-teardown))) + +(provide 'test-chime-apply-blacklist) +;;; test-chime-apply-blacklist.el ends here diff --git a/tests/test-chime-apply-whitelist.el b/tests/test-chime-apply-whitelist.el new file mode 100644 index 0000000..80e1a68 --- /dev/null +++ b/tests/test-chime-apply-whitelist.el @@ -0,0 +1,229 @@ +;;; test-chime-apply-whitelist.el --- Tests for chime--apply-whitelist -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--apply-whitelist function. +;; Tests use real org-mode buffers with real org syntax. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) + +;;; Setup and Teardown + +(defun test-chime-apply-whitelist-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset whitelist settings + (setq chime-keyword-whitelist nil) + (setq chime-tags-whitelist nil) + (setq chime-predicate-whitelist nil)) + +(defun test-chime-apply-whitelist-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir) + (setq chime-keyword-whitelist nil) + (setq chime-tags-whitelist nil) + (setq chime-predicate-whitelist nil)) + +;;; Normal Cases + +(ert-deftest test-chime-apply-whitelist-nil-whitelist-returns-all-markers () + "Test that nil whitelist returns all markers unchanged." + (test-chime-apply-whitelist-setup) + (unwind-protect + (let* ((chime-keyword-whitelist nil) + (chime-tags-whitelist nil) + (markers (list (make-marker) (make-marker) (make-marker))) + (result (chime--apply-whitelist markers))) + ;; Should return all markers when whitelist is nil + (should (equal (length result) 3))) + (test-chime-apply-whitelist-teardown))) + +(ert-deftest test-chime-apply-whitelist-keyword-whitelist-filters-correctly () + "Test that keyword whitelist filters markers correctly." + (test-chime-apply-whitelist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1\n") + (insert "* DONE Task 2\n") + (insert "* TODO Task 3\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker)) + (chime-keyword-whitelist '("TODO"))) + (let ((result (chime--apply-whitelist (list marker1 marker2 marker3)))) + ;; Should only keep TODO markers + (should (= (length result) 2)) + (should (member marker1 result)) + (should-not (member marker2 result)) + (should (member marker3 result))))))) + (test-chime-apply-whitelist-teardown))) + +(ert-deftest test-chime-apply-whitelist-tags-whitelist-filters-correctly () + "Test that tags whitelist filters markers correctly." + (test-chime-apply-whitelist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* Task 1 :urgent:\n") + (insert "* Task 2 :normal:\n") + (insert "* Task 3 :urgent:\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker)) + (chime-tags-whitelist '("urgent"))) + (let ((result (chime--apply-whitelist (list marker1 marker2 marker3)))) + ;; Should only keep markers with "urgent" tag + (should (= (length result) 2)) + (should (member marker1 result)) + (should-not (member marker2 result)) + (should (member marker3 result))))))) + (test-chime-apply-whitelist-teardown))) + +(ert-deftest test-chime-apply-whitelist-keyword-and-tags-whitelist-uses-or-logic () + "Test that both keyword and tags whitelists use OR logic." + (test-chime-apply-whitelist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1\n") + (insert "* DONE Task 2\n") + (insert "* NEXT Task 3 :urgent:\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker)) + (chime-keyword-whitelist '("TODO")) + (chime-tags-whitelist '("urgent"))) + (let ((result (chime--apply-whitelist (list marker1 marker2 marker3)))) + ;; Should keep marker1 (TODO) and marker3 (urgent tag) + (should (= (length result) 2)) + (should (member marker1 result)) + (should-not (member marker2 result)) + (should (member marker3 result))))))) + (test-chime-apply-whitelist-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-apply-whitelist-empty-markers-list-returns-empty () + "Test that empty markers list returns empty." + (test-chime-apply-whitelist-setup) + (unwind-protect + (let ((chime-keyword-whitelist '("TODO")) + (result (chime--apply-whitelist '()))) + (should (equal result '()))) + (test-chime-apply-whitelist-teardown))) + +(ert-deftest test-chime-apply-whitelist-single-item-whitelist-works () + "Test that single-item whitelist works correctly." + (test-chime-apply-whitelist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1\n") + (insert "* DONE Task 2\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker)) + (chime-keyword-whitelist '("TODO"))) + (let ((result (chime--apply-whitelist (list marker1 marker2)))) + (should (= (length result) 1)) + (should (member marker1 result)))))) + (test-chime-apply-whitelist-teardown))) + +(ert-deftest test-chime-apply-whitelist-no-matching-markers-returns-empty () + "Test that whitelist with no matching markers returns empty list." + (test-chime-apply-whitelist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* DONE Task 1\n") + (insert "* DONE Task 2\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker)) + (chime-keyword-whitelist '("TODO"))) + (let ((result (chime--apply-whitelist (list marker1 marker2)))) + (should (equal result '())))))) + (test-chime-apply-whitelist-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-apply-whitelist-handles-nil-keyword-gracefully () + "Test that nil keyword in marker is handled gracefully." + (test-chime-apply-whitelist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* Entry without TODO keyword\n") + (goto-char (point-min)) + (let ((marker1 (point-marker)) + (chime-keyword-whitelist '("TODO"))) + (let ((result (chime--apply-whitelist (list marker1)))) + ;; Should filter out marker with nil keyword (not in whitelist) + (should (= (length result) 0))))) + (test-chime-apply-whitelist-teardown))) + +(ert-deftest test-chime-apply-whitelist-handles-nil-tags-gracefully () + "Test that nil tags in marker is handled gracefully." + (test-chime-apply-whitelist-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* Entry without tags\n") + (goto-char (point-min)) + (let ((marker1 (point-marker)) + (chime-tags-whitelist '("urgent"))) + (let ((result (chime--apply-whitelist (list marker1)))) + ;; Should filter out marker with nil tags (not in whitelist) + (should (= (length result) 0))))) + (test-chime-apply-whitelist-teardown))) + +(provide 'test-chime-apply-whitelist) +;;; test-chime-apply-whitelist.el ends here diff --git a/tests/test-chime-calendar-url.el b/tests/test-chime-calendar-url.el new file mode 100644 index 0000000..34fd1ef --- /dev/null +++ b/tests/test-chime-calendar-url.el @@ -0,0 +1,64 @@ +;;; test-chime-calendar-url.el --- Tests for calendar URL feature -*- lexical-binding: t; -*- + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'dash) +(require 'alert) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;;; Tests for chime--open-calendar-url + +(ert-deftest test-chime-open-calendar-url-opens-when-set () + "Test that chime--open-calendar-url calls browse-url when URL is set." + (let ((chime-calendar-url "https://calendar.google.com") + (url-opened nil)) + (cl-letf (((symbol-function 'browse-url) + (lambda (url) (setq url-opened url)))) + (chime--open-calendar-url) + (should (equal url-opened "https://calendar.google.com"))))) + +(ert-deftest test-chime-open-calendar-url-does-nothing-when-nil () + "Test that chime--open-calendar-url does nothing when URL is nil." + (let ((chime-calendar-url nil) + (browse-url-called nil)) + (cl-letf (((symbol-function 'browse-url) + (lambda (_url) (setq browse-url-called t)))) + (chime--open-calendar-url) + (should-not browse-url-called)))) + +;;; Tests for chime--jump-to-first-event + +(ert-deftest test-chime-jump-to-first-event-jumps-to-event () + "Test that chime--jump-to-first-event jumps to first event in list." + (let* ((event1 '((title . "Event 1") + (marker-file . "/tmp/test.org") + (marker-pos . 100))) + (event2 '((title . "Event 2") + (marker-file . "/tmp/test.org") + (marker-pos . 200))) + (chime--upcoming-events `((,event1 ("time1" . time1) 10) + (,event2 ("time2" . time2) 20))) + (jumped-to-event nil)) + (cl-letf (((symbol-function 'chime--jump-to-event) + (lambda (event) (setq jumped-to-event event)))) + (chime--jump-to-first-event) + (should (equal jumped-to-event event1))))) + +(ert-deftest test-chime-jump-to-first-event-does-nothing-when-empty () + "Test that chime--jump-to-first-event does nothing when no events." + (let ((chime--upcoming-events nil) + (jump-called nil)) + (cl-letf (((symbol-function 'chime--jump-to-event) + (lambda (_event) (setq jump-called t)))) + (chime--jump-to-first-event) + (should-not jump-called)))) + +(provide 'test-chime-calendar-url) +;;; test-chime-calendar-url.el ends here diff --git a/tests/test-chime-check-event.el b/tests/test-chime-check-event.el new file mode 100644 index 0000000..a34e377 --- /dev/null +++ b/tests/test-chime-check-event.el @@ -0,0 +1,215 @@ +;;; test-chime-check-event.el --- Tests for chime--check-event -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--check-event function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) +(require 'testutil-events (expand-file-name "testutil-events.el")) + +;;; Normal Cases + +(ert-deftest test-chime-check-event-single-notification-returns-message () + "Test that single matching notification returns formatted message. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let* ((now (test-time-today-at 14 0)) + ;; Event at 14:10 (10 minutes from now) + (event-time (test-time-today-at 14 10)) + (event (test-make-simple-event "Team Meeting" event-time 10 'medium))) + (with-test-time now + (let ((result (chime--check-event event))) + ;; Should return list with one formatted message + (should (listp result)) + (should (= 1 (length result))) + (should (consp (car result))) + (should (stringp (caar result))) ; Message part + (should (symbolp (cdar result))) ; Severity part + ;; Message should contain title and time information + (should (string-match-p "Team Meeting" (caar result))) + (should (string-match-p "02:10 PM" (caar result))) + (should (string-match-p "in 10 minutes" (caar result)))))))) + +(ert-deftest test-chime-check-event-multiple-notifications-returns-multiple-messages () + "Test that multiple matching notifications return multiple formatted messages. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let* ((now (test-time-today-at 14 0)) + ;; Two events: 14:10 and 14:05 + (event-time-1 (test-time-today-at 14 10)) + (event-time-2 (test-time-today-at 14 5)) + (timestamp-str-1 (test-timestamp-string event-time-1)) + (timestamp-str-2 (test-timestamp-string event-time-2)) + (event (test-make-event-data + "Important Call" + (list (cons timestamp-str-1 event-time-1) + (cons timestamp-str-2 event-time-2)) + '((10 . medium) (5 . medium))))) ; Both match + (with-test-time now + (let ((result (chime--check-event event))) + ;; Should return two formatted messages + (should (listp result)) + (should (= 2 (length result))) + (should (cl-every #'consp result)) ; All items are cons cells + ;; Both should mention the title + (should (string-match-p "Important Call" (caar result))) + (should (string-match-p "Important Call" (car (cadr result))))))))) + +(ert-deftest test-chime-check-event-zero-interval-returns-right-now-message () + "Test that zero interval produces 'right now' message. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let* ((now (test-time-today-at 14 0)) + ;; Event at exactly now + (event-time (test-time-today-at 14 0)) + (event (test-make-simple-event "Daily Standup" event-time 0 'high))) + (with-test-time now + (let ((result (chime--check-event event))) + (should (listp result)) + (should (= 1 (length result))) + (should (string-match-p "Daily Standup" (caar result))) + (should (string-match-p "right now" (caar result)))))))) + +;;; Boundary Cases + +(ert-deftest test-chime-check-event-no-matching-notifications-returns-empty-list () + "Test that event with no matching times returns empty list. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let* ((now (test-time-today-at 14 0)) + ;; Event at 14:20 (doesn't match 10 minute interval) + (event-time (test-time-today-at 14 20)) + (event (test-make-simple-event "Future Event" event-time 10 'medium))) + (with-test-time now + (let ((result (chime--check-event event))) + (should (listp result)) + (should (= 0 (length result)))))))) + +(ert-deftest test-chime-check-event-day-wide-event-returns-empty-list () + "Test that day-wide event (no time) returns empty list. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 0)) + (timestamp-str (test-timestamp-string event-time t)) ; all-day format + (event (test-make-event-data "All Day Event" + (list (cons timestamp-str event-time)) + '((10 . medium))))) + (with-test-time now + (let ((result (chime--check-event event))) + (should (listp result)) + (should (= 0 (length result)))))))) + +;;; Error Cases + +(ert-deftest test-chime-check-event-empty-times-returns-empty-list () + "Test that event with no times returns empty list. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let* ((now (test-time-today-at 14 0)) + (event (test-make-event-data "No Times Event" '() '((10 . medium))))) + (with-test-time now + (let ((result (chime--check-event event))) + (should (listp result)) + (should (= 0 (length result)))))))) + +(ert-deftest test-chime-check-event-empty-intervals-returns-empty-list () + "Test that event with no intervals returns empty list. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (event (test-make-simple-event "No Intervals Event" event-time nil nil))) + (setcdr (assoc 'intervals event) '()) ; Override with empty intervals + (with-test-time now + (let ((result (chime--check-event event))) + (should (listp result)) + (should (= 0 (length result)))))))) + +(ert-deftest test-chime-check-event-error-nil-event-handles-gracefully () + "Test that nil event parameter doesn't crash. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let ((now (test-time-today-at 14 0))) + (with-test-time now + ;; Should not error with nil event + (should-not (condition-case nil + (progn (chime--check-event nil) nil) + (error t))))))) + +(ert-deftest test-chime-check-event-error-invalid-event-structure-handles-gracefully () + "Test that invalid event structure doesn't crash. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let ((now (test-time-today-at 14 0)) + ;; Event missing required fields + (invalid-event '((invalid . "structure")))) + (with-test-time now + ;; Should not crash even with invalid event + (should-not (condition-case nil + (progn (chime--check-event invalid-event) nil) + (error t))))))) + +(ert-deftest test-chime-check-event-error-event-with-nil-times-handles-gracefully () + "Test that event with nil times field doesn't crash. + +REFACTORED: Uses testutil-events helpers" + (with-test-setup + (let ((now (test-time-today-at 14 0)) + (event '((times . nil) + (title . "Event with nil times") + (intervals . (10))))) + (with-test-time now + ;; Should not crash + (should-not (condition-case nil + (progn (chime--check-event event) nil) + (error t))))))) + +(provide 'test-chime-check-event) +;;; test-chime-check-event.el ends here diff --git a/tests/test-chime-check-interval.el b/tests/test-chime-check-interval.el new file mode 100644 index 0000000..0a51e2e --- /dev/null +++ b/tests/test-chime-check-interval.el @@ -0,0 +1,148 @@ +;;; test-chime-check-interval.el --- Tests for chime-check-interval -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime-check-interval customization variable. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) + +;;; Setup and Teardown + +(defun test-chime-check-interval-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset to default + (setq chime-check-interval 60)) + +(defun test-chime-check-interval-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir) + ;; Reset to default + (setq chime-check-interval 60)) + +;;; Normal Cases + +(ert-deftest test-chime-check-interval-normal-default-is-60 () + "Test that default check interval is 60 seconds." + (test-chime-check-interval-setup) + (unwind-protect + (progn + (should (equal chime-check-interval 60))) + (test-chime-check-interval-teardown))) + +(ert-deftest test-chime-check-interval-normal-can-set-30-seconds () + "Test that check interval can be set to 30 seconds." + (test-chime-check-interval-setup) + (unwind-protect + (progn + (setq chime-check-interval 30) + (should (equal chime-check-interval 30))) + (test-chime-check-interval-teardown))) + +(ert-deftest test-chime-check-interval-normal-can-set-300-seconds () + "Test that check interval can be set to 300 seconds (5 minutes)." + (test-chime-check-interval-setup) + (unwind-protect + (progn + (setq chime-check-interval 300) + (should (equal chime-check-interval 300))) + (test-chime-check-interval-teardown))) + +(ert-deftest test-chime-check-interval-normal-can-set-10-seconds () + "Test that check interval can be set to 10 seconds (minimum recommended)." + (test-chime-check-interval-setup) + (unwind-protect + (progn + (setq chime-check-interval 10) + (should (equal chime-check-interval 10))) + (test-chime-check-interval-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-check-interval-boundary-one-second-triggers-warning () + "Test that 1 second interval triggers warning but is allowed." + (test-chime-check-interval-setup) + (unwind-protect + (progn + ;; Setting to 1 should trigger a warning but succeed + ;; We can't easily test the warning, but we can verify it's set + (setq chime-check-interval 1) + (should (equal chime-check-interval 1))) + (test-chime-check-interval-teardown))) + +(ert-deftest test-chime-check-interval-boundary-large-value-accepted () + "Test that large interval values are accepted (e.g., 1 hour)." + (test-chime-check-interval-setup) + (unwind-protect + (progn + (setq chime-check-interval 3600) ; 1 hour + (should (equal chime-check-interval 3600))) + (test-chime-check-interval-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-check-interval-error-zero-rejected () + "Test that zero interval is rejected." + (test-chime-check-interval-setup) + (unwind-protect + (progn + (should-error (customize-set-variable 'chime-check-interval 0) + :type 'user-error)) + (test-chime-check-interval-teardown))) + +(ert-deftest test-chime-check-interval-error-negative-rejected () + "Test that negative interval is rejected." + (test-chime-check-interval-setup) + (unwind-protect + (progn + (should-error (customize-set-variable 'chime-check-interval -60) + :type 'user-error)) + (test-chime-check-interval-teardown))) + +(ert-deftest test-chime-check-interval-error-non-integer-rejected () + "Test that non-integer values are rejected." + (test-chime-check-interval-setup) + (unwind-protect + (progn + (should-error (customize-set-variable 'chime-check-interval "60") + :type 'user-error)) + (test-chime-check-interval-teardown))) + +(provide 'test-chime-check-interval) +;;; test-chime-check-interval.el ends here diff --git a/tests/test-chime-debug-functions.el b/tests/test-chime-debug-functions.el new file mode 100644 index 0000000..28484b4 --- /dev/null +++ b/tests/test-chime-debug-functions.el @@ -0,0 +1,224 @@ +;;; test-chime-debug-functions.el --- Tests for chime debug functions -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Tests for debug functions: chime--debug-dump-events, chime--debug-dump-tooltip, +;; and chime--debug-config. These tests verify that debug functions work correctly +;; and handle edge cases gracefully. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Enable debug mode and load chime +(setq chime-debug t) +(load (expand-file-name "../chime.el") nil t) +(require 'chime-debug (expand-file-name "../chime-debug.el")) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-debug-functions-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Clear upcoming events + (setq chime--upcoming-events nil)) + +(defun test-chime-debug-functions-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir) + (setq chime--upcoming-events nil)) + +;;; Tests for chime--debug-dump-events + +(ert-deftest test-chime-debug-dump-events-normal-with-events () + "Test that chime--debug-dump-events dumps events to *Messages* buffer." + (test-chime-debug-functions-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time)) + (minutes 10)) + (with-test-time now + ;; Set up chime--upcoming-events as if chime--update-modeline populated it + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Test Meeting"))) + (event-item (list event (cons timestamp-str event-time) minutes))) + (setq chime--upcoming-events (list event-item)) + ;; Clear messages buffer to check output + (with-current-buffer (get-buffer-create "*Messages*") + (let ((inhibit-read-only t)) + (erase-buffer))) + ;; Call debug function + (chime--debug-dump-events) + ;; Verify output in *Messages* buffer + (with-current-buffer "*Messages*" + (let ((content (buffer-string))) + (should (string-match-p "=== Chime Debug: Upcoming Events" content)) + (should (string-match-p "Test Meeting" content)) + (should (string-match-p "=== End Chime Debug ===" content))))))) + (test-chime-debug-functions-teardown))) + +(ert-deftest test-chime-debug-dump-events-boundary-no-events () + "Test that chime--debug-dump-events handles no events gracefully." + (test-chime-debug-functions-setup) + (unwind-protect + (progn + ;; Ensure no events + (setq chime--upcoming-events nil) + ;; Should not error + (should-not (condition-case nil + (progn (chime--debug-dump-events) nil) + (error t)))) + (test-chime-debug-functions-teardown))) + +;;; Tests for chime--debug-dump-tooltip + +(ert-deftest test-chime-debug-dump-tooltip-normal-with-events () + "Test that chime--debug-dump-tooltip dumps tooltip to *Messages* buffer." + (test-chime-debug-functions-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time)) + (minutes 10)) + (with-test-time now + ;; Set up chime--upcoming-events + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Test Meeting"))) + (event-item (list event (cons timestamp-str event-time) minutes))) + (setq chime--upcoming-events (list event-item)) + ;; Clear messages buffer + (with-current-buffer (get-buffer-create "*Messages*") + (let ((inhibit-read-only t)) + (erase-buffer))) + ;; Call debug function + (chime--debug-dump-tooltip) + ;; Verify output in *Messages* buffer + (with-current-buffer "*Messages*" + (let ((content (buffer-string))) + (should (string-match-p "=== Chime Debug: Tooltip Content ===" content)) + (should (string-match-p "Test Meeting" content)) + (should (string-match-p "=== End Chime Debug ===" content))))))) + (test-chime-debug-functions-teardown))) + +(ert-deftest test-chime-debug-dump-tooltip-boundary-no-events () + "Test that chime--debug-dump-tooltip handles no events gracefully." + (test-chime-debug-functions-setup) + (unwind-protect + (progn + ;; Ensure no events + (setq chime--upcoming-events nil) + ;; Should not error + (should-not (condition-case nil + (progn (chime--debug-dump-tooltip) nil) + (error t)))) + (test-chime-debug-functions-teardown))) + +;;; Tests for chime--debug-config + +(ert-deftest test-chime-debug-config-normal-dumps-config () + "Test that chime--debug-config dumps configuration to *Messages* buffer." + (test-chime-debug-functions-setup) + (unwind-protect + (let ((chime-enable-modeline t) + (chime-modeline-lookahead-minutes 60) + (chime-alert-intervals '((10 . medium))) + (org-agenda-files '("/tmp/test.org"))) + ;; Clear messages buffer + (with-current-buffer (get-buffer-create "*Messages*") + (let ((inhibit-read-only t)) + (erase-buffer))) + ;; Call debug function + (chime--debug-config) + ;; Verify output in *Messages* buffer + (with-current-buffer "*Messages*" + (let ((content (buffer-string))) + (should (string-match-p "=== Chime Debug: Configuration ===" content)) + (should (string-match-p "Mode enabled:" content)) + (should (string-match-p "chime-enable-modeline:" content)) + (should (string-match-p "chime-modeline-lookahead-minutes:" content)) + (should (string-match-p "chime-alert-intervals:" content)) + (should (string-match-p "Org agenda files" content)) + (should (string-match-p "=== End Chime Debug ===" content))))) + (test-chime-debug-functions-teardown))) + +(ert-deftest test-chime-debug-config-boundary-no-agenda-files () + "Test that chime--debug-config handles empty org-agenda-files." + (test-chime-debug-functions-setup) + (unwind-protect + (let ((org-agenda-files '())) + ;; Clear messages buffer + (with-current-buffer (get-buffer-create "*Messages*") + (let ((inhibit-read-only t)) + (erase-buffer))) + ;; Should not error + (should-not (condition-case nil + (progn (chime--debug-config) nil) + (error t))) + ;; Verify output mentions 0 files + (with-current-buffer "*Messages*" + (let ((content (buffer-string))) + (should (string-match-p "Org agenda files (0)" content))))) + (test-chime-debug-functions-teardown))) + +;;; Integration tests + +(ert-deftest test-chime-debug-all-functions-work-together () + "Test that all three debug functions can be called sequentially." + (test-chime-debug-functions-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time)) + (minutes 10) + (chime-enable-modeline t) + (org-agenda-files '("/tmp/test.org"))) + (with-test-time now + ;; Set up events + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Integration Test Meeting"))) + (event-item (list event (cons timestamp-str event-time) minutes))) + (setq chime--upcoming-events (list event-item)) + ;; Call all three debug functions - should not error + (should-not (condition-case nil + (progn + (chime--debug-dump-events) + (chime--debug-dump-tooltip) + (chime--debug-config) + nil) + (error t)))))) + (test-chime-debug-functions-teardown))) + +(provide 'test-chime-debug-functions) +;;; test-chime-debug-functions.el ends here diff --git a/tests/test-chime-extract-time.el b/tests/test-chime-extract-time.el new file mode 100644 index 0000000..3c2a50f --- /dev/null +++ b/tests/test-chime-extract-time.el @@ -0,0 +1,331 @@ +;;; test-chime-extract-time.el --- Tests for chime--extract-time -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Tests for chime--extract-time function with source-aware extraction: +;; - org-gcal events: extract ONLY from :org-gcal: drawer +;; - Regular events: prefer SCHEDULED/DEADLINE, fall back to plain timestamps +;; - Prevents duplicate entries when events are rescheduled + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-extract-time-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-extract-time-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Tests for org-gcal events + +(ert-deftest test-chime-extract-time-gcal-event-from-drawer () + "Test that org-gcal events extract timestamps ONLY from :org-gcal: drawer. + +REFACTORED: Uses dynamic timestamps" + (test-chime-extract-time-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp-str (format-time-string "<%Y-%m-%d %a %H:%M-15:00>" time)) + (test-content (format "* Meeting +:PROPERTIES: +:entry-id: abc123@google.com +:END: +:org-gcal: +%s +:END: +" timestamp-str)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (times (chime--extract-time marker))) + ;; Should extract the timestamp from :org-gcal: drawer + (should (= 1 (length times))) + (should (string-match-p "14:00" (car (car times)))))) + (kill-buffer test-buffer)) + (test-chime-extract-time-teardown))) + +(ert-deftest test-chime-extract-time-gcal-event-ignores-body-timestamps () + "Test that org-gcal events ignore plain timestamps in body text. + +When an event is rescheduled, old timestamps might remain in the body. +The :org-gcal: drawer has the correct time, so we should ignore body text. + +REFACTORED: Uses dynamic timestamps" + (test-chime-extract-time-setup) + (unwind-protect + (let* ((new-time (test-time-tomorrow-at 14 0)) + (old-time (test-time-today-at 14 0)) + (new-timestamp (test-timestamp-string new-time)) + (old-timestamp (test-timestamp-string old-time)) + (test-content (format "* Meeting +:PROPERTIES: +:entry-id: abc123@google.com +:END: +:org-gcal: +%s +:END: +Old time was %s +" new-timestamp old-timestamp)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (times (chime--extract-time marker))) + ;; Should extract ONLY from drawer (tomorrow), ignore body (today) + (should (= 1 (length times))) + (should (string-match-p "14:00" (car (car times)))) + ;; Verify it's the new timestamp, not the old one + (should (string-match-p (format-time-string "%Y-%m-%d" new-time) (car (car times)))))) + (kill-buffer test-buffer)) + (test-chime-extract-time-teardown))) + +(ert-deftest test-chime-extract-time-gcal-event-ignores-scheduled () + "Test that org-gcal events ignore SCHEDULED/DEADLINE properties. + +For org-gcal events, the :org-gcal: drawer is the source of truth. + +REFACTORED: Uses dynamic timestamps" + (test-chime-extract-time-setup) + (unwind-protect + (let* ((drawer-time (test-time-tomorrow-at 14 0)) + (scheduled-time (test-time-days-from-now 2)) + (drawer-timestamp (test-timestamp-string drawer-time)) + (scheduled-timestamp (test-timestamp-string scheduled-time)) + (test-content (format "* Meeting +SCHEDULED: %s +:PROPERTIES: +:entry-id: abc123@google.com +:END: +:org-gcal: +%s +:END: +" scheduled-timestamp drawer-timestamp)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (times (chime--extract-time marker))) + ;; Should extract ONLY from drawer (tomorrow), ignore SCHEDULED (day after) + (should (= 1 (length times))) + (should (string-match-p "14:00" (car (car times)))) + (should (string-match-p (format-time-string "%Y-%m-%d" drawer-time) (car (car times)))))) + (kill-buffer test-buffer)) + (test-chime-extract-time-teardown))) + +(ert-deftest test-chime-extract-time-gcal-event-multiple-in-drawer () + "Test that org-gcal events extract all timestamps from :org-gcal: drawer. + +Some recurring events might have multiple timestamps in the drawer. + +REFACTORED: Uses dynamic timestamps" + (test-chime-extract-time-setup) + (unwind-protect + (let* ((time1 (test-time-tomorrow-at 14 0)) + (time2 (test-time-days-from-now 2 14 0)) + (timestamp1 (test-timestamp-string time1)) + (timestamp2 (test-timestamp-string time2)) + (test-content (format "* Meeting +:PROPERTIES: +:entry-id: abc123@google.com +:END: +:org-gcal: +%s +%s +:END: +" timestamp1 timestamp2)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (times (chime--extract-time marker))) + ;; Should extract both timestamps from drawer + (should (= 2 (length times))) + (should (string-match-p "14:00" (car (car times)))) + (should (string-match-p "14:00" (car (cadr times)))))) + (kill-buffer test-buffer)) + (test-chime-extract-time-teardown))) + +;;; Tests for regular org events + +(ert-deftest test-chime-extract-time-regular-event-scheduled () + "Test that regular events extract SCHEDULED timestamp. + +REFACTORED: Uses dynamic timestamps" + (test-chime-extract-time-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-content (format "* Task +SCHEDULED: %s +" timestamp)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (times (chime--extract-time marker))) + ;; Should extract SCHEDULED timestamp + (should (= 1 (length times))) + (should (string-match-p "14:00" (car (car times)))))) + (kill-buffer test-buffer)) + (test-chime-extract-time-teardown))) + +(ert-deftest test-chime-extract-time-regular-event-deadline () + "Test that regular events extract DEADLINE timestamp. + +REFACTORED: Uses dynamic timestamps" + (test-chime-extract-time-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 17 0)) + (timestamp (test-timestamp-string time)) + (test-content (format "* Task +DEADLINE: %s +" timestamp)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (times (chime--extract-time marker))) + ;; Should extract DEADLINE timestamp + (should (= 1 (length times))) + (should (string-match-p "17:00" (car (car times)))))) + (kill-buffer test-buffer)) + (test-chime-extract-time-teardown))) + +(ert-deftest test-chime-extract-time-regular-event-plain-timestamps () + "Test that regular events extract plain timestamps when no SCHEDULED/DEADLINE. + +REFACTORED: Uses dynamic timestamps" + (test-chime-extract-time-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-content (format "* Meeting notes +Discussed: %s +" timestamp)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (times (chime--extract-time marker))) + ;; Should extract plain timestamp + (should (= 1 (length times))) + (should (string-match-p "14:00" (car (car times)))))) + (kill-buffer test-buffer)) + (test-chime-extract-time-teardown))) + +(ert-deftest test-chime-extract-time-regular-event-scheduled-and-plain () + "Test that regular events extract both SCHEDULED and plain timestamps. + +SCHEDULED/DEADLINE appear first, then plain timestamps. + +REFACTORED: Uses dynamic timestamps" + (test-chime-extract-time-setup) + (unwind-protect + (let* ((scheduled-time (test-time-tomorrow-at 14 0)) + (plain-time (test-time-days-from-now 2 15 0)) + (scheduled-timestamp (test-timestamp-string scheduled-time)) + (plain-timestamp (format-time-string "<%Y-%m-%d %a %H:%M>" plain-time)) + (test-content (format "* Task +SCHEDULED: %s +Note: also happens %s +" scheduled-timestamp plain-timestamp)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (times (chime--extract-time marker))) + ;; Should extract both: SCHEDULED first, then plain + (should (= 2 (length times))) + ;; First should be SCHEDULED + (should (string-match-p "14:00" (car (car times)))) + ;; Second should be plain at 15:00 + (should (string-match-p "15:00" (car (cadr times)))))) + (kill-buffer test-buffer)) + (test-chime-extract-time-teardown))) + +(ert-deftest test-chime-extract-time-regular-event-multiple-plain () + "Test that regular events extract all plain timestamps. + +REFACTORED: Uses dynamic timestamps" + (test-chime-extract-time-setup) + (unwind-protect + (let* ((time1 (test-time-tomorrow-at 14 0)) + (time2 (test-time-days-from-now 2 15 0)) + (timestamp1 (test-timestamp-string time1)) + (timestamp2 (test-timestamp-string time2)) + (test-content (format "* Meeting notes +First discussion: %s +Second discussion: %s +" timestamp1 timestamp2)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (times (chime--extract-time marker))) + ;; Should extract both plain timestamps + (should (= 2 (length times))) + (should (string-match-p "14:00" (car (car times)))) + (should (string-match-p "15:00" (car (cadr times)))))) + (kill-buffer test-buffer)) + (test-chime-extract-time-teardown))) + +(provide 'test-chime-extract-time) +;;; test-chime-extract-time.el ends here diff --git a/tests/test-chime-format-event-for-tooltip.el b/tests/test-chime-format-event-for-tooltip.el new file mode 100644 index 0000000..a9a53a5 --- /dev/null +++ b/tests/test-chime-format-event-for-tooltip.el @@ -0,0 +1,260 @@ +;;; test-chime-format-event-for-tooltip.el --- Tests for chime--format-event-for-tooltip -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--format-event-for-tooltip function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-format-event-for-tooltip-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-format-event-for-tooltip-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-format-event-for-tooltip-normal-minutes () + "Test formatting event with minutes until event. + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 10)) + (timestamp (test-timestamp-string time)) + (result (chime--format-event-for-tooltip + timestamp + 10 + "Team Meeting"))) + (should (stringp result)) + (should (string-match-p "Team Meeting" result)) + (should (string-match-p "02:10 PM" result)) + (should (string-match-p "10 minutes" result))) + (test-chime-format-event-for-tooltip-teardown))) + +(ert-deftest test-chime-format-event-for-tooltip-normal-hours () + "Test formatting event with hours until event. + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 15 30)) + (timestamp (test-timestamp-string time)) + (result (chime--format-event-for-tooltip + timestamp + 90 + "Afternoon Meeting"))) + (should (stringp result)) + (should (string-match-p "Afternoon Meeting" result)) + (should (string-match-p "03:30 PM" result)) + (should (string-match-p "1 hour" result))) + (test-chime-format-event-for-tooltip-teardown))) + +(ert-deftest test-chime-format-event-for-tooltip-normal-multiple-hours () + "Test formatting event with multiple hours until event. + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 17 0)) + (timestamp (test-timestamp-string time)) + (result (chime--format-event-for-tooltip + timestamp + 300 + "End of Day Review"))) + (should (stringp result)) + (should (string-match-p "End of Day Review" result)) + (should (string-match-p "05:00 PM" result)) + (should (string-match-p "5 hours" result))) + (test-chime-format-event-for-tooltip-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-format-event-for-tooltip-boundary-exactly-one-day () + "Test formatting event exactly 1 day away (1440 minutes). + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-days-from-now 1 9 0)) + (timestamp (test-timestamp-string time)) + (result (chime--format-event-for-tooltip + timestamp + 1440 + "Tomorrow Event"))) + (should (stringp result)) + (should (string-match-p "Tomorrow Event" result)) + (should (string-match-p "09:00 AM" result)) + (should (string-match-p "in 1 day" result))) + (test-chime-format-event-for-tooltip-teardown))) + +(ert-deftest test-chime-format-event-for-tooltip-boundary-multiple-days () + "Test formatting event multiple days away. + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-days-from-now 3 10 0)) + (timestamp (test-timestamp-string time)) + (result (chime--format-event-for-tooltip + timestamp + 4320 ; 3 days + "Future Meeting"))) + (should (stringp result)) + (should (string-match-p "Future Meeting" result)) + (should (string-match-p "10:00 AM" result)) + (should (string-match-p "in 3 days" result))) + (test-chime-format-event-for-tooltip-teardown))) + +(ert-deftest test-chime-format-event-for-tooltip-boundary-just-under-one-day () + "Test formatting event just under 1 day away (1439 minutes). + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 8 59)) + (timestamp (test-timestamp-string time)) + (result (chime--format-event-for-tooltip + timestamp + 1439 + "Almost Tomorrow"))) + (should (stringp result)) + (should (string-match-p "Almost Tomorrow" result)) + (should (string-match-p "08:59 AM" result)) + ;; Should show hours/minutes, not days + (should (string-match-p "23 hours" result))) + (test-chime-format-event-for-tooltip-teardown))) + +(ert-deftest test-chime-format-event-for-tooltip-boundary-zero-minutes () + "Test formatting event happening right now (0 minutes). + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-today-at 14 0)) + (timestamp (test-timestamp-string time)) + (result (chime--format-event-for-tooltip + timestamp + 0 + "Current Event"))) + (should (stringp result)) + (should (string-match-p "Current Event" result)) + (should (string-match-p "02:00 PM" result)) + (should (string-match-p "right now" result))) + (test-chime-format-event-for-tooltip-teardown))) + +(ert-deftest test-chime-format-event-for-tooltip-boundary-one-minute () + "Test formatting event 1 minute away. + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-today-at 14 1)) + (timestamp (test-timestamp-string time)) + (result (chime--format-event-for-tooltip + timestamp + 1 + "Imminent Event"))) + (should (stringp result)) + (should (string-match-p "Imminent Event" result)) + (should (string-match-p "02:01 PM" result)) + (should (string-match-p "1 minute" result))) + (test-chime-format-event-for-tooltip-teardown))) + +(ert-deftest test-chime-format-event-for-tooltip-boundary-long-title () + "Test formatting event with very long title. + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-today-at 14 10)) + (timestamp (test-timestamp-string time)) + (long-title (make-string 200 ?x)) + (result (chime--format-event-for-tooltip + timestamp + 10 + long-title))) + (should (stringp result)) + (should (string-match-p long-title result))) + (test-chime-format-event-for-tooltip-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-format-event-for-tooltip-error-nil-title () + "Test formatting with nil title doesn't crash. + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-today-at 14 10)) + (timestamp (test-timestamp-string time))) + ;; Should not crash with nil title + (should-not (condition-case nil + (progn + (chime--format-event-for-tooltip + timestamp + 10 + nil) + nil) + (error t)))) + (test-chime-format-event-for-tooltip-teardown))) + +(ert-deftest test-chime-format-event-for-tooltip-error-empty-title () + "Test formatting with empty title. + +REFACTORED: Uses dynamic timestamps" + (test-chime-format-event-for-tooltip-setup) + (unwind-protect + (let* ((time (test-time-today-at 14 10)) + (timestamp (test-timestamp-string time)) + (result (chime--format-event-for-tooltip + timestamp + 10 + ""))) + (should (stringp result)) + (should (string-match-p "02:10 PM" result))) + (test-chime-format-event-for-tooltip-teardown))) + +(provide 'test-chime-format-event-for-tooltip) +;;; test-chime-format-event-for-tooltip.el ends here diff --git a/tests/test-chime-format-refresh.el b/tests/test-chime-format-refresh.el new file mode 100644 index 0000000..4252c77 --- /dev/null +++ b/tests/test-chime-format-refresh.el @@ -0,0 +1,150 @@ +;;; test-chime-format-refresh.el --- Test format changes are picked up on refresh -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Tests to verify that changing configuration variables and calling +;; refresh functions picks up the new values. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-format-refresh-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset to defaults + (setq chime-modeline-string nil) + (setq chime-enable-modeline t) + (setq chime-modeline-lookahead-minutes 30) + (setq chime-modeline-format " ⏰ %s") + (setq chime-notification-text-format "%t at %T (%u)")) + +(defun test-chime-format-refresh-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir) + (setq chime-modeline-string nil)) + +;;; Tests + +(ert-deftest test-chime-format-refresh-update-modeline-picks-up-format-change () + "Test that chime--update-modeline picks up changed chime-notification-text-format." + (test-chime-format-refresh-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Meeting"))) + (events (list event))) + ;; First update with format "%t at %T (%u)" + (chime--update-modeline events) + (should (string-match-p "Meeting at" chime-modeline-string)) + (should (string-match-p "(in" chime-modeline-string)) + + ;; Change format to "%t %u" (no time, no parentheses) + (setq chime-notification-text-format "%t %u") + + ;; Update again - should pick up new format + (chime--update-modeline events) + (should (string-match-p "Meeting in" chime-modeline-string)) + (should-not (string-match-p "Meeting at" chime-modeline-string)) + (should-not (string-match-p "(in" chime-modeline-string)))))) + (test-chime-format-refresh-teardown))) + +(ert-deftest test-chime-format-refresh-title-only-format () + "Test changing format to title-only." + (test-chime-format-refresh-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Important Meeting"))) + (events (list event))) + ;; Start with default format + (chime--update-modeline events) + (should (string-match-p "Important Meeting" chime-modeline-string)) + (should (string-match-p "at\\|in" chime-modeline-string)) + + ;; Change to title only + (setq chime-notification-text-format "%t") + + ;; Update - should show title only + (chime--update-modeline events) + ;; The modeline string will have the format applied, then wrapped with chime-modeline-format + ;; chime-modeline-format is " ⏰ %s", so the title will be in there + (should (string-match-p "Important Meeting" chime-modeline-string)) + ;; After format-spec, the raw text is just "Important Meeting" + ;; Wrapped with " ⏰ %s" it becomes " ⏰ Important Meeting" + ;; So we should NOT see time or countdown + (should-not (string-match-p "at [0-9]" chime-modeline-string)) + (should-not (string-match-p "in [0-9]" chime-modeline-string)))))) + (test-chime-format-refresh-teardown))) + +(ert-deftest test-chime-format-refresh-custom-separator () + "Test changing format with custom separator." + (test-chime-format-refresh-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Review PR"))) + (events (list event))) + ;; Start with default "at" + (chime--update-modeline events) + (should (string-match-p "at" chime-modeline-string)) + + ;; Change to custom separator " - " + (setq chime-notification-text-format "%t - %T") + + ;; Update - should show custom separator + (chime--update-modeline events) + (should (string-match-p "Review PR - " chime-modeline-string)) + (should-not (string-match-p " at " chime-modeline-string)))))) + (test-chime-format-refresh-teardown))) + +(provide 'test-chime-format-refresh) +;;; test-chime-format-refresh.el ends here diff --git a/tests/test-chime-gather-info.el b/tests/test-chime-gather-info.el new file mode 100644 index 0000000..1da1991 --- /dev/null +++ b/tests/test-chime-gather-info.el @@ -0,0 +1,475 @@ +;;; test-chime-gather-info.el --- Tests for chime--gather-info -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Integration tests for chime--gather-info function. +;; Tests ensure that event information is collected correctly +;; and that titles are properly sanitized to prevent Lisp read +;; syntax errors during async serialization. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) +(require 'testutil-events (expand-file-name "testutil-events.el")) + +;;; Setup and Teardown + +(defun test-chime-gather-info-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset to default alert intervals + (setq chime-alert-intervals '((10 . medium)))) + +(defun test-chime-gather-info-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-gather-info-extracts-all-components () + "Test that gather-info extracts times, title, intervals, and marker. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Team Meeting\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Should have all required keys + (should (assoc 'times info)) + (should (assoc 'title info)) + (should (assoc 'intervals info)) + (should (assoc 'marker-file info)) + (should (assoc 'marker-pos info)) + ;; Title should be extracted + (should (string-equal "Team Meeting" (cdr (assoc 'title info)))) + ;; Intervals should include default alert interval as cons cell + (should (member '(10 . medium) (cdr (assoc 'intervals info)))))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-with-balanced-parens-in-title () + "Test that balanced parentheses in title are preserved. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Meeting (Team Sync)\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + (should (string-equal "Meeting (Team Sync)" (cdr (assoc 'title info)))))) + (kill-buffer test-buffer)) + )) + +;;; Sanitization Cases + +(ert-deftest test-chime-gather-info-sanitizes-unmatched-opening-paren () + "Test that unmatched opening parenthesis in title is closed. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Meeting (Team Sync\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Should add closing paren + (should (string-equal "Meeting (Team Sync)" (cdr (assoc 'title info)))))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-sanitizes-unmatched-opening-bracket () + "Test that unmatched opening bracket in title is closed. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 15 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Review [PR #123\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Should add closing bracket + (should (string-equal "Review [PR #123]" (cdr (assoc 'title info)))))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-sanitizes-unmatched-opening-brace () + "Test that unmatched opening brace in title is closed. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 16 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Code Review {urgent\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Should add closing brace + (should (string-equal "Code Review {urgent}" (cdr (assoc 'title info)))))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-sanitizes-multiple-unmatched-delimiters () + "Test that multiple unmatched delimiters are all closed. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 17 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Meeting [Team (Sync {Status\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Should close all unmatched delimiters + (should (string-equal "Meeting [Team (Sync {Status})]" (cdr (assoc 'title info)))))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-sanitizes-unmatched-closing-paren () + "Test that unmatched closing parenthesis is removed. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Meeting Title)\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Should remove extra closing paren + (should (string-equal "Meeting Title" (cdr (assoc 'title info)))))) + (kill-buffer test-buffer)) + )) + +;;; Real-World Bug Cases + +(ert-deftest test-chime-gather-info-bug-case-extended-leadership () + "Test the actual bug case from vineti.meetings.org. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 13 1)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO 1:01pm CTO/COO XLT (Extended Leadership\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Should close the unmatched paren + (should (string-equal "1:01pm CTO/COO XLT (Extended Leadership)" (cdr (assoc 'title info)))))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-bug-case-spice-cake () + "Test the actual bug case from journal/2023-11-22.org. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 18 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Spice Cake (\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Should close the unmatched paren + (should (string-equal "Spice Cake ()" (cdr (assoc 'title info)))))) + (kill-buffer test-buffer)) + )) + +;;; Serialization Safety + +(ert-deftest test-chime-gather-info-output-serializable-with-unmatched-parens () + "Test that gather-info output with unmatched parens can be serialized. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Meeting (Team\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker)) + ;; Extract just the title for serialization test + (title (cdr (assoc 'title info))) + ;; Simulate what happens in async serialization + (serialized (format "'((title . \"%s\"))" title))) + ;; Should not signal 'invalid-read-syntax error + (should (listp (read serialized))) + ;; Title should be sanitized + (should (string-equal "Meeting (Team)" title)))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-multiple-events-all-serializable () + "Test that multiple events with various delimiter issues are all serializable. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (problematic-titles '("Meeting (Team" + "Review [PR" + "Code {Status" + "Event ((" + "Task ))")) + (test-content (mapconcat + (lambda (title) + (format "* TODO %s\nSCHEDULED: %s\n" title timestamp)) + problematic-titles + "\n")) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file)) + (all-info '())) + (with-current-buffer test-buffer + (org-mode) + ;; Gather info for all events + (goto-char (point-min)) + (while (re-search-forward "^\\*\\s-+TODO" nil t) + (beginning-of-line) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + (push info all-info) + (end-of-line))) + ;; Try to serialize all titles + (dolist (info all-info) + (let* ((title (cdr (assoc 'title info))) + (serialized (format "'((title . \"%s\"))" title))) + ;; Should not signal error + (should (listp (read serialized)))))) + (kill-buffer test-buffer)) + )) + +;;; Edge Cases + +(ert-deftest test-chime-gather-info-handles-empty-title () + "Test that gather-info handles entries with no title. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Should return empty string for nil title + (should (string-equal "" (cdr (assoc 'title info)))))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-handles-very-long-title-with-delimiters () + "Test that gather-info handles very long titles with unmatched delimiters. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (long-title "This is a very long meeting title that contains many words and might wrap in the notification display (Extended Info") + (test-file (chime-create-temp-test-file-with-content + (format "* TODO %s\nSCHEDULED: %s\n" long-title timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker)) + (title (cdr (assoc 'title info)))) + ;; Should close the unmatched paren + (should (string-suffix-p ")" title)) + ;; Should be able to serialize + (should (listp (read (format "'((title . \"%s\"))" title)))))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-serializable-without-marker-object () + "Test that gather-info returns serializable data without marker object. + +This tests the fix for the bug where marker objects from buffers with names +like 'todo.org<jr-estate>' could not be serialized because angle brackets in +the buffer name created invalid Lisp syntax: #<marker ... in todo.org<dir>> + +The fix returns marker-file and marker-pos instead of the marker object, +which can be properly serialized regardless of buffer name. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Test Task\nSCHEDULED: %s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + + ;; Should have marker-file and marker-pos, NOT marker object + (should (assoc 'marker-file info)) + (should (assoc 'marker-pos info)) + (should-not (assoc 'marker info)) + + ;; The file path and position should be correct + (should (string-equal test-file (cdr (assoc 'marker-file info)))) + (should (numberp (cdr (assoc 'marker-pos info)))) + (should (> (cdr (assoc 'marker-pos info)) 0)) + + ;; The entire structure should be serializable via format %S and read + ;; This simulates what async.el does with the data + (let* ((serialized (format "%S" info)) + (deserialized (read serialized))) + ;; Should deserialize without error + (should (listp deserialized)) + ;; Should have the same data structure + (should (string-equal (cdr (assoc 'title deserialized)) + (cdr (assoc 'title info)))) + (should (string-equal (cdr (assoc 'marker-file deserialized)) + (cdr (assoc 'marker-file info)))) + (should (equal (cdr (assoc 'marker-pos deserialized)) + (cdr (assoc 'marker-pos info))))))) + (kill-buffer test-buffer)) + )) + +(ert-deftest test-chime-gather-info-special-chars-in-title () + "Test that titles with Lisp special characters serialize correctly. + +Tests characters that could theoretically cause Lisp read syntax errors: +- Double quotes: string delimiters +- Backslashes: escape characters +- Semicolons: comment start +- Backticks/commas: quasiquote syntax +- Hash symbols: reader macros + +These should all be properly escaped by format %S. + +REFACTORED: Uses dynamic timestamps" + (with-test-setup + (setq chime-alert-intervals '((10 . medium))) + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (special-titles '(("Quote in \"middle\"" . "Quote in \"middle\"") + ("Backslash\\path\\here" . "Backslash\\path\\here") + ("Semicolon; not a comment" . "Semicolon; not a comment") + ("Backtick `and` comma, here" . "Backtick `and` comma, here") + ("Hash #tag and @mention" . "Hash #tag and @mention") + ("Mixed: \"foo\\bar;baz`qux#\"" . "Mixed: \"foo\\bar;baz`qux#\"")))) + (dolist (title-pair special-titles) + (let* ((title (car title-pair)) + (expected (cdr title-pair)) + (test-content (format "* TODO %s\nSCHEDULED: %s\n" title timestamp)) + (test-file (chime-create-temp-test-file-with-content test-content)) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + ;; Title should be preserved exactly + (should (string-equal expected (cdr (assoc 'title info)))) + ;; Full structure should serialize/deserialize correctly + (let* ((serialized (format "%S" info)) + (deserialized (read serialized))) + (should (listp deserialized)) + (should (string-equal expected (cdr (assoc 'title deserialized))))))) + (kill-buffer test-buffer)))) + )) + +(provide 'test-chime-gather-info) +;;; test-chime-gather-info.el ends here diff --git a/tests/test-chime-group-events-by-day.el b/tests/test-chime-group-events-by-day.el new file mode 100644 index 0000000..c9e564a --- /dev/null +++ b/tests/test-chime-group-events-by-day.el @@ -0,0 +1,268 @@ +;;; test-chime-group-events-by-day.el --- Tests for chime--group-events-by-day -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--group-events-by-day function. +;; Tests cover normal cases, boundary cases, and error cases. +;; +;; Note: chime--group-events-by-day does not handle malformed events gracefully. +;; This is acceptable since events are generated internally by chime and should +;; always have the correct structure. If a malformed event is passed, it will error. +;; Removed test: test-chime-group-events-by-day-error-malformed-event + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-group-events-by-day-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-group-events-by-day-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Test Helpers + +(defun test-chime-make-event-item (minutes-until title) + "Create a mock event item for testing. +MINUTES-UNTIL is minutes until event, TITLE is event title." + (let* ((now (current-time)) + (event-time (time-add now (seconds-to-time (* minutes-until 60)))) + (timestamp-str (test-timestamp-string event-time)) + (event `((title . ,title) + (times . ()))) + (time-info (cons timestamp-str event-time))) + (list event time-info minutes-until))) + +;;; Normal Cases + +(ert-deftest test-chime-group-events-by-day-normal-single-day () + "Test grouping events all on same day. + +REFACTORED: Uses dynamic timestamps" + (test-chime-group-events-by-day-setup) + (unwind-protect + (let* ((event1 (test-chime-make-event-item 10 "Event 1")) + (event2 (test-chime-make-event-item 30 "Event 2")) + (event3 (test-chime-make-event-item 60 "Event 3")) + (upcoming (list event1 event2 event3)) + (result (chime--group-events-by-day upcoming))) + ;; Should have 1 group (today) + (should (= 1 (length result))) + ;; Group should have 3 events + (should (= 3 (length (cdr (car result))))) + ;; Date string should say "Today" + (should (string-match-p "Today" (car (car result))))) + (test-chime-group-events-by-day-teardown))) + +(ert-deftest test-chime-group-events-by-day-normal-multiple-days () + "Test grouping events across multiple days. + +REFACTORED: Uses dynamic timestamps" + (test-chime-group-events-by-day-setup) + (unwind-protect + (let* ((event1 (test-chime-make-event-item 10 "Today Event")) + (event2 (test-chime-make-event-item 1500 "Tomorrow Event")) ; > 1440 + (event3 (test-chime-make-event-item 3000 "Future Event")) ; > 2880 + (upcoming (list event1 event2 event3)) + (result (chime--group-events-by-day upcoming))) + ;; Should have 3 groups (today, tomorrow, future) + (should (= 3 (length result))) + ;; First group should say "Today" + (should (string-match-p "Today" (car (nth 0 result)))) + ;; Second group should say "Tomorrow" + (should (string-match-p "Tomorrow" (car (nth 1 result))))) + (test-chime-group-events-by-day-teardown))) + +(ert-deftest test-chime-group-events-by-day-normal-maintains-order () + "Test that events maintain order within groups. + +REFACTORED: Uses dynamic timestamps" + (test-chime-group-events-by-day-setup) + (unwind-protect + (let* ((event1 (test-chime-make-event-item 10 "First")) + (event2 (test-chime-make-event-item 20 "Second")) + (event3 (test-chime-make-event-item 30 "Third")) + (upcoming (list event1 event2 event3)) + (result (chime--group-events-by-day upcoming)) + (today-events (cdr (car result)))) + ;; Should maintain order + (should (= 3 (length today-events))) + (should (string= "First" (cdr (assoc 'title (car (nth 0 today-events)))))) + (should (string= "Second" (cdr (assoc 'title (car (nth 1 today-events)))))) + (should (string= "Third" (cdr (assoc 'title (car (nth 2 today-events))))))) + (test-chime-group-events-by-day-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-group-events-by-day-boundary-empty-list () + "Test grouping empty events list. + +REFACTORED: No timestamps used" + (test-chime-group-events-by-day-setup) + (unwind-protect + (let ((result (chime--group-events-by-day '()))) + ;; Should return empty list + (should (null result))) + (test-chime-group-events-by-day-teardown))) + +(ert-deftest test-chime-group-events-by-day-boundary-single-event () + "Test grouping single event. + +REFACTORED: Uses dynamic timestamps" + (test-chime-group-events-by-day-setup) + (unwind-protect + (let* ((event (test-chime-make-event-item 10 "Only Event")) + (upcoming (list event)) + (result (chime--group-events-by-day upcoming))) + ;; Should have 1 group + (should (= 1 (length result))) + ;; Group should have 1 event + (should (= 1 (length (cdr (car result)))))) + (test-chime-group-events-by-day-teardown))) + +(ert-deftest test-chime-group-events-by-day-boundary-exactly-1440-minutes () + "Test event at exactly 1440 minutes (1 day boundary). + +REFACTORED: Uses dynamic timestamps" + (test-chime-group-events-by-day-setup) + (unwind-protect + (let* ((event (test-chime-make-event-item 1440 "Boundary Event")) + (upcoming (list event)) + (result (chime--group-events-by-day upcoming))) + ;; Should be grouped as "Tomorrow" + (should (= 1 (length result))) + (should (string-match-p "Tomorrow" (car (car result))))) + (test-chime-group-events-by-day-teardown))) + +(ert-deftest test-chime-group-events-by-day-boundary-just-under-1440 () + "Test event at 1439 minutes (23h 59m away). + +If current time is 10:00 AM, an event 1439 minutes away is at 9:59 AM +the next calendar day, so it should be grouped as 'Tomorrow', not 'Today'. + +REFACTORED: Uses dynamic timestamps and corrects expected behavior" + (test-chime-group-events-by-day-setup) + (unwind-protect + (let* ((event (test-chime-make-event-item 1439 "Almost Tomorrow")) + (upcoming (list event)) + (result (chime--group-events-by-day upcoming))) + ;; Should be grouped as "Tomorrow" (next calendar day) + (should (= 1 (length result))) + (should (string-match-p "Tomorrow" (car (car result))))) + (test-chime-group-events-by-day-teardown))) + +(ert-deftest test-chime-group-events-by-day-boundary-exactly-2880-minutes () + "Test event at exactly 2880 minutes (2 day boundary). + +REFACTORED: Uses dynamic timestamps" + (test-chime-group-events-by-day-setup) + (unwind-protect + (let* ((event (test-chime-make-event-item 2880 "Two Days Away")) + (upcoming (list event)) + (result (chime--group-events-by-day upcoming))) + ;; Should be grouped as a future day (not "Tomorrow") + (should (= 1 (length result))) + (should-not (string-match-p "Tomorrow" (car (car result))))) + (test-chime-group-events-by-day-teardown))) + +(ert-deftest test-chime-group-events-by-day-boundary-zero-minutes () + "Test event at 0 minutes (happening now). + +REFACTORED: Uses dynamic timestamps" + (test-chime-group-events-by-day-setup) + (unwind-protect + (let* ((event (test-chime-make-event-item 0 "Right Now")) + (upcoming (list event)) + (result (chime--group-events-by-day upcoming))) + ;; Should be grouped as "Today" + (should (= 1 (length result))) + (should (string-match-p "Today" (car (car result))))) + (test-chime-group-events-by-day-teardown))) + +;;; Bug Reproduction Tests + +(ert-deftest test-chime-group-events-by-day-bug-tomorrow-morning-grouped-as-today () + "Test that tomorrow morning event is NOT grouped as 'Today'. +This reproduces the bug where an event at 10:00 AM tomorrow, +when it's 11:23 AM today (22h 37m = 1357 minutes away), +is incorrectly grouped as 'Today' instead of 'Tomorrow'. + +The bug: The function groups by 24-hour period (<1440 minutes) +instead of by calendar day." + (test-chime-group-events-by-day-setup) + (unwind-protect + (with-test-time (encode-time 0 23 11 2 11 2025) ; Nov 02, 2025 11:23 AM + (let* ((now (current-time)) + (tomorrow-morning (encode-time 0 0 10 3 11 2025)) ; Nov 03, 2025 10:00 AM + (minutes-until (/ (- (float-time tomorrow-morning) (float-time now)) 60)) + ;; Create event manually since test-chime-make-event-item uses relative time + (event `((title . "Transit to Meeting") + (times . ()))) + (time-info (cons (test-timestamp-string tomorrow-morning) tomorrow-morning)) + (event-item (list event time-info minutes-until)) + (upcoming (list event-item)) + (result (chime--group-events-by-day upcoming))) + ;; Verify it's less than 1440 minutes (this is why the bug happens) + (should (< minutes-until 1440)) + ;; Should have 1 group + (should (= 1 (length result))) + ;; BUG: Currently groups as "Today" but should be "Tomorrow" + ;; because the event is on Nov 03, not Nov 02 + (should (string-match-p "Tomorrow" (car (car result)))))) + (test-chime-group-events-by-day-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-group-events-by-day-error-nil-input () + "Test that nil input doesn't crash. + +REFACTORED: No timestamps used" + (test-chime-group-events-by-day-setup) + (unwind-protect + (progn + ;; Should not crash with nil + (should-not (condition-case nil + (progn (chime--group-events-by-day nil) nil) + (error t)))) + (test-chime-group-events-by-day-teardown))) + +(provide 'test-chime-group-events-by-day) +;;; test-chime-group-events-by-day.el ends here diff --git a/tests/test-chime-has-timestamp.el b/tests/test-chime-has-timestamp.el new file mode 100644 index 0000000..22eb8ad --- /dev/null +++ b/tests/test-chime-has-timestamp.el @@ -0,0 +1,277 @@ +;;; test-chime-has-timestamp.el --- Tests for chime--has-timestamp -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--has-timestamp function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-has-timestamp-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-has-timestamp-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-has-timestamp-standard-timestamp-with-time-returns-non-nil () + "Test that standard timestamp with time returns non-nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (timestamp (test-timestamp-string time)) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-timestamp-without-brackets-returns-non-nil () + "Test that timestamp without brackets but with time returns non-nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (timestamp (format-time-string "%Y-%m-%d %a %H:%M" time)) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-timestamp-with-time-range-returns-non-nil () + "Test that timestamp with time range returns non-nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (format-time-string "<%Y-%m-%d %a %H:%M-15:30>" time)) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-scheduled-with-time-returns-non-nil () + "Test that SCHEDULED timestamp with time returns non-nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 9 0)) + (timestamp (concat "SCHEDULED: " (test-timestamp-string time))) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-deadline-with-time-returns-non-nil () + "Test that DEADLINE timestamp with time returns non-nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 17 0)) + (timestamp (concat "DEADLINE: " (test-timestamp-string time))) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-repeater-with-time-returns-non-nil () + "Test that timestamp with repeater and time returns non-nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (format-time-string "<%Y-%m-%d %a %H:%M +1w>" time)) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-midnight-timestamp-returns-non-nil () + "Test that midnight timestamp (00:00) returns non-nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 0 0)) + (timestamp (test-timestamp-string time)) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-has-timestamp-day-wide-timestamp-returns-nil () + "Test that day-wide timestamp without time returns nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 0 0)) + (timestamp (test-timestamp-string time t)) + (result (chime--has-timestamp timestamp))) + (should-not result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-date-only-returns-nil () + "Test that date-only timestamp without day name returns nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 0 0)) + (timestamp (format-time-string "<%Y-%m-%d>" time)) + (result (chime--has-timestamp timestamp))) + (should-not result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-single-digit-hour-returns-non-nil () + "Test that timestamp with single-digit hour returns non-nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 9 0)) + (timestamp (format-time-string "<%Y-%m-%d %a %-H:%M>" time)) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-embedded-in-text-returns-non-nil () + "Test that timestamp embedded in text returns non-nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (concat "Meeting scheduled for " (test-timestamp-string time) " in conference room")) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-multiple-timestamps-returns-non-nil () + "Test that string with multiple timestamps returns non-nil for first match. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time1 (test-time-tomorrow-at 14 0)) + (time2 (test-time-days-from-now 2)) + (timestamp (concat (test-timestamp-string time1) " and " + (format-time-string "<%Y-%m-%d %a %H:%M>" time2))) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-has-timestamp-empty-string-returns-nil () + "Test that empty string returns nil." + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((timestamp "") + (result (chime--has-timestamp timestamp))) + (should-not result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-nil-input-returns-nil () + "Test that nil input returns nil." + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((timestamp nil) + (result (chime--has-timestamp timestamp))) + (should-not result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-no-timestamp-returns-nil () + "Test that string without timestamp returns nil." + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((timestamp "Just a regular string with no timestamp") + (result (chime--has-timestamp timestamp))) + (should-not result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-invalid-format-returns-nil () + "Test that invalid timestamp format returns nil. + +REFACTORED: Uses dynamic timestamps (keeps invalid format for testing)" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + ;; Intentionally wrong format (MM-DD-YYYY instead of YYYY-MM-DD) for testing + (timestamp (format-time-string "<%m-%d-%Y %a %H:%M>" time)) + (result (chime--has-timestamp timestamp))) + (should-not result)) + (test-chime-has-timestamp-teardown))) + +(ert-deftest test-chime-has-timestamp-partial-timestamp-returns-nil () + "Test that partial timestamp returns nil. + +REFACTORED: Uses dynamic timestamps (keeps partial format for testing)" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 0 0)) + ;; Intentionally incomplete timestamp for testing + (timestamp (format-time-string "<%Y-%m-%d" time)) + (result (chime--has-timestamp timestamp))) + (should-not result)) + (test-chime-has-timestamp-teardown))) + +;;; org-gcal Integration Tests + +(ert-deftest test-chime-has-timestamp-org-gcal-time-range-returns-non-nil () + "Test that org-gcal time range format is detected. +org-gcal uses format like <2025-10-24 Fri 17:30-18:00> which should be detected. + +REFACTORED: Uses dynamic timestamps" + (test-chime-has-timestamp-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 17 30)) + (timestamp (format-time-string "<%Y-%m-%d %a %H:%M-18:00>" time)) + (result (chime--has-timestamp timestamp))) + (should result)) + (test-chime-has-timestamp-teardown))) + +(provide 'test-chime-has-timestamp) +;;; test-chime-has-timestamp.el ends here diff --git a/tests/test-chime-modeline-no-events-text.el b/tests/test-chime-modeline-no-events-text.el new file mode 100644 index 0000000..d9d78fd --- /dev/null +++ b/tests/test-chime-modeline-no-events-text.el @@ -0,0 +1,290 @@ +;;; test-chime-modeline-no-events-text.el --- Tests for chime-modeline-no-events-text customization -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime-modeline-no-events-text defcustom. +;; Tests the modeline display when no events are within lookahead window. +;; +;; Tests three scenarios: +;; 1. Setting is nil → show nothing in modeline +;; 2. Setting is custom text → show that text +;; 3. Event within lookahead → show event (ignores setting) + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defvar test-chime-modeline-no-events-text--orig-lookahead nil) +(defvar test-chime-modeline-no-events-text--orig-tooltip-lookahead nil) +(defvar test-chime-modeline-no-events-text--orig-no-events-text nil) + +(defun test-chime-modeline-no-events-text-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Save original values + (setq test-chime-modeline-no-events-text--orig-lookahead chime-modeline-lookahead-minutes) + (setq test-chime-modeline-no-events-text--orig-tooltip-lookahead chime-tooltip-lookahead-hours) + (setq test-chime-modeline-no-events-text--orig-no-events-text chime-modeline-no-events-text) + ;; Set short lookahead for testing + (setq chime-modeline-lookahead-minutes 60) ; 1 hour + (setq chime-tooltip-lookahead-hours 24)) ; 24 hours + +(defun test-chime-modeline-no-events-text-teardown () + "Teardown function run after each test." + ;; Restore original values + (setq chime-modeline-lookahead-minutes test-chime-modeline-no-events-text--orig-lookahead) + (setq chime-tooltip-lookahead-hours test-chime-modeline-no-events-text--orig-tooltip-lookahead) + (setq chime-modeline-no-events-text test-chime-modeline-no-events-text--orig-no-events-text) + (chime-delete-test-base-dir)) + +;;; Helper Functions + +(defun test-chime-modeline-no-events-text--create-event (title time-offset-hours) + "Create org content for event with TITLE at TIME-OFFSET-HOURS from now." + (let* ((event-time (test-time-at 0 time-offset-hours 0)) + (timestamp (test-timestamp-string event-time))) + (format "* TODO %s\nSCHEDULED: %s\n" title timestamp))) + +(defun test-chime-modeline-no-events-text--gather-events (content) + "Process CONTENT like chime-check does and return events list." + (let* ((test-file (chime-create-temp-test-file-with-content content)) + (test-buffer (find-file-noselect test-file)) + (events nil)) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (while (re-search-forward "^\\*+ " nil t) + (beginning-of-line) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + (push info events)) + (forward-line 1))) + (kill-buffer test-buffer) + (nreverse events))) + +(defun test-chime-modeline-no-events-text--update-and-get-modeline (events-content) + "Create org file with EVENTS-CONTENT, update modeline, return chime-modeline-string." + (let ((events (test-chime-modeline-no-events-text--gather-events events-content))) + (chime--update-modeline events) + ;; Return the modeline string + chime-modeline-string)) + +;;; Normal Cases + +(ert-deftest test-chime-modeline-no-events-text-normal-nil-setting-no-events-in-lookahead-returns-nil () + "Test that nil setting shows nothing when events exist beyond lookahead. + +When chime-modeline-no-events-text is nil and events exist beyond +the lookahead window but not within it, the modeline should be nil +(show nothing)." + (test-chime-modeline-no-events-text-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event 3 hours from now (beyond 1-hour lookahead) + (content (test-chime-modeline-no-events-text--create-event "Future Event" 3))) + ;; Set to nil (default) + (setq chime-modeline-no-events-text nil) + (with-test-time now + (let ((result (test-chime-modeline-no-events-text--update-and-get-modeline content))) + (should (null result))))) + (test-chime-modeline-no-events-text-teardown))) + +(ert-deftest test-chime-modeline-no-events-text-normal-custom-text-no-events-in-lookahead-returns-text () + "Test that custom text displays when events exist beyond lookahead. + +When chime-modeline-no-events-text is \" 🔕\" and events exist beyond +the lookahead window, the modeline should show \" 🔕\"." + (test-chime-modeline-no-events-text-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event 3 hours from now (beyond 1-hour lookahead) + (content (test-chime-modeline-no-events-text--create-event "Future Event" 3))) + ;; Set custom text + (setq chime-modeline-no-events-text " 🔕") + (with-test-time now + (let ((result (test-chime-modeline-no-events-text--update-and-get-modeline content))) + (should (stringp result)) + (should (string-match-p "🔕" result))))) + (test-chime-modeline-no-events-text-teardown))) + +(ert-deftest test-chime-modeline-no-events-text-normal-event-within-lookahead-shows-event () + "Test that event within lookahead is shown, ignoring no-events-text. + +When an event is within the lookahead window, the modeline should show +the event regardless of chime-modeline-no-events-text setting." + (test-chime-modeline-no-events-text-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event 30 minutes from now (within 1-hour lookahead) + (content (test-chime-modeline-no-events-text--create-event "Upcoming Event" 0.5))) + ;; Set custom text (should be ignored) + (setq chime-modeline-no-events-text " 🔕") + (with-test-time now + (let ((result (test-chime-modeline-no-events-text--update-and-get-modeline content))) + (should (stringp result)) + (should (string-match-p "Upcoming Event" result)) + (should-not (string-match-p "🔕" result))))) + (test-chime-modeline-no-events-text-teardown))) + +(ert-deftest test-chime-modeline-no-events-text-normal-custom-text-has-tooltip () + "Test that custom text has tooltip when displayed. + +When chime-modeline-no-events-text is displayed, it should have a +help-echo property with the tooltip showing upcoming events." + (test-chime-modeline-no-events-text-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event 3 hours from now (beyond 1-hour lookahead) + (content (test-chime-modeline-no-events-text--create-event "Future Event" 3))) + ;; Set custom text + (setq chime-modeline-no-events-text " 🔕") + (with-test-time now + (let ((result (test-chime-modeline-no-events-text--update-and-get-modeline content))) + (should (stringp result)) + ;; Check for help-echo property (tooltip) + (should (get-text-property 0 'help-echo result))))) + (test-chime-modeline-no-events-text-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-modeline-no-events-text-boundary-empty-string-no-events-returns-empty () + "Test that empty string setting shows empty string. + +When chime-modeline-no-events-text is \"\" (empty string) and events +exist beyond lookahead, the modeline should show empty string." + (test-chime-modeline-no-events-text-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event 3 hours from now (beyond 1-hour lookahead) + (content (test-chime-modeline-no-events-text--create-event "Future Event" 3))) + ;; Set to empty string + (setq chime-modeline-no-events-text "") + (with-test-time now + (let ((result (test-chime-modeline-no-events-text--update-and-get-modeline content))) + (should (stringp result)) + (should (equal "" result))))) + (test-chime-modeline-no-events-text-teardown))) + +(ert-deftest test-chime-modeline-no-events-text-boundary-no-events-at-all-shows-icon () + "Test that icon appears even when there are no events at all. + +When there are zero events (not just beyond lookahead, but none at all), +the modeline should still show the icon with a helpful tooltip explaining +that there are no events and suggesting to increase the lookahead window." + (test-chime-modeline-no-events-text-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; No events - empty content + (content "")) + ;; Set custom text + (setq chime-modeline-no-events-text " 🔕") + (with-test-time now + (let ((result (test-chime-modeline-no-events-text--update-and-get-modeline content))) + ;; Should show icon (not nil) + (should result) + (should (stringp result)) + (should (equal " 🔕" (substring-no-properties result))) + ;; Should have tooltip explaining no events + (let ((tooltip (get-text-property 0 'help-echo result))) + (should tooltip) + (should (string-match-p "No calendar events" tooltip)) + (should (string-match-p "chime-tooltip-lookahead-hours" tooltip)))))) + (test-chime-modeline-no-events-text-teardown))) + +(ert-deftest test-chime-modeline-no-events-text-boundary-very-long-text-displays-correctly () + "Test that very long custom text displays correctly. + +When chime-modeline-no-events-text is a very long string (50+ chars), +the modeline should show the full string." + (test-chime-modeline-no-events-text-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event 3 hours from now (beyond 1-hour lookahead) + (content (test-chime-modeline-no-events-text--create-event "Future Event" 3)) + (long-text " No events within the next hour, but some later today")) + ;; Set very long text + (setq chime-modeline-no-events-text long-text) + (with-test-time now + (let ((result (test-chime-modeline-no-events-text--update-and-get-modeline content))) + (should (stringp result)) + (should (equal long-text result))))) + (test-chime-modeline-no-events-text-teardown))) + +(ert-deftest test-chime-modeline-no-events-text-boundary-special-characters-emoji-displays () + "Test that special characters and emoji display correctly. + +When chime-modeline-no-events-text contains emoji and unicode characters, +they should display correctly in the modeline." + (test-chime-modeline-no-events-text-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event 3 hours from now (beyond 1-hour lookahead) + (content (test-chime-modeline-no-events-text--create-event "Future Event" 3)) + (emoji-text " 🔕🔔⏰📅")) + ;; Set emoji text + (setq chime-modeline-no-events-text emoji-text) + (with-test-time now + (let ((result (test-chime-modeline-no-events-text--update-and-get-modeline content))) + (should (stringp result)) + (should (equal emoji-text result))))) + (test-chime-modeline-no-events-text-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-modeline-no-events-text-error-whitespace-only-displays-whitespace () + "Test that whitespace-only setting displays as-is. + +When chime-modeline-no-events-text is \" \" (whitespace only), +it should display the whitespace without trimming." + (test-chime-modeline-no-events-text-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event 3 hours from now (beyond 1-hour lookahead) + (content (test-chime-modeline-no-events-text--create-event "Future Event" 3)) + (whitespace-text " ")) + ;; Set whitespace text + (setq chime-modeline-no-events-text whitespace-text) + (with-test-time now + (let ((result (test-chime-modeline-no-events-text--update-and-get-modeline content))) + (should (stringp result)) + (should (equal whitespace-text result))))) + (test-chime-modeline-no-events-text-teardown))) + +(provide 'test-chime-modeline-no-events-text) +;;; test-chime-modeline-no-events-text.el ends here diff --git a/tests/test-chime-modeline.el b/tests/test-chime-modeline.el new file mode 100644 index 0000000..534b017 --- /dev/null +++ b/tests/test-chime-modeline.el @@ -0,0 +1,1076 @@ +;;; test-chime-modeline.el --- Tests for chime modeline and tooltip -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Integration tests for chime modeline and tooltip behavior: +;; - Tests that rescheduled gcal events show correct times +;; - Tests that events don't appear multiple times in tooltip +;; - Tests that tooltip shows events in correct order +;; - Replicates real-world user scenarios + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-modeline-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Save original values + (setq test-chime-modeline--orig-lookahead chime-modeline-lookahead-minutes) + (setq test-chime-modeline--orig-tooltip-lookahead chime-tooltip-lookahead-hours) + ;; Set lookahead to 24 hours for testing (both modeline and tooltip) + (setq chime-modeline-lookahead-minutes 1440) + (setq chime-tooltip-lookahead-hours 24)) + +(defun test-chime-modeline-teardown () + "Teardown function run after each test." + ;; Restore original values + (setq chime-modeline-lookahead-minutes test-chime-modeline--orig-lookahead) + (setq chime-tooltip-lookahead-hours test-chime-modeline--orig-tooltip-lookahead) + (chime-delete-test-base-dir)) + +(defvar test-chime-modeline--orig-lookahead nil) +(defvar test-chime-modeline--orig-tooltip-lookahead nil) + +;;; Helper functions + +(defun test-chime-modeline--create-gcal-event (title time-str &optional old-time-str) + "Create test org content for a gcal event. +TITLE is the event title. +TIME-STR is the current time in the :org-gcal: drawer. +OLD-TIME-STR is an optional old time that might remain in the body." + (concat + (format "* %s\n" title) + ":PROPERTIES:\n" + ":entry-id: test123@google.com\n" + ":END:\n" + ":org-gcal:\n" + (format "%s\n" time-str) + ":END:\n" + (when old-time-str + (format "Old time was: %s\n" old-time-str)))) + +(defun test-chime-modeline--gather-events (content) + "Process CONTENT like chime-check does and return events list." + (let* ((test-file (chime-create-temp-test-file-with-content content)) + (test-buffer (find-file-noselect test-file)) + (events nil)) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (while (re-search-forward "^\\*+ " nil t) + (beginning-of-line) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + (push info events)) + (forward-line 1))) + (kill-buffer test-buffer) + (nreverse events))) + +(defun test-chime-modeline--count-in-string (regexp string) + "Count occurrences of REGEXP in STRING." + (let ((count 0) + (start 0)) + (while (string-match regexp string start) + (setq count (1+ count)) + (setq start (match-end 0))) + count)) + +;;; Tests for org-gcal event rescheduling + +(ert-deftest test-chime-modeline-gcal-event-after-reschedule () + "Test that rescheduled gcal event shows only NEW time, not old. + +Scenario: User moves event in Google Calendar, syncs with org-gcal. +The body might still mention the old time, but modeline should show new time. + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-modeline-setup) + (unwind-protect + (let* ((tomorrow (test-time-tomorrow-at 14 0)) + (today (test-time-today-at 14 0)) + (tomorrow-str (test-timestamp-string tomorrow)) + (today-str (test-timestamp-string today)) + (content (test-chime-modeline--create-gcal-event + "Team Meeting" + tomorrow-str + today-str)) + (events (test-chime-modeline--gather-events content))) + ;; Should have one event + (should (= 1 (length events))) + + ;; Event should have only ONE timestamp (from drawer, not body) + (let* ((event (car events)) + (times (cdr (assoc 'times event)))) + (should (= 1 (length times))) + ;; times is a list of (timestamp-string . parsed-time) cons cells + ;; Check the first timestamp string + (let ((time-str (caar times))) + (should (string-match-p ".*14:00" time-str)) + (should-not (string-match-p (format-time-string "%Y-%m-%d" today) time-str))))) + (test-chime-modeline-teardown))) + +;;; Tests for modeline deduplication + +(ert-deftest test-chime-modeline-no-duplicate-events () + "Test that modeline doesn't show the same event multiple times. + +Even if an event has multiple timestamps, it should appear only once +in the upcoming events list (with its soonest timestamp). + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (meeting-a-time (test-time-tomorrow-at 14 0)) + (meeting-b-time (test-time-tomorrow-at 15 0)) + (content (concat + (test-chime-modeline--create-gcal-event + "Meeting A" + (test-timestamp-string meeting-a-time)) + (test-chime-modeline--create-gcal-event + "Meeting B" + (test-timestamp-string meeting-b-time)))) + (events (test-chime-modeline--gather-events content))) + + ;; Should have two events + (should (= 2 (length events))) + + (with-test-time now + ;; Set lookahead to cover test events (30+ days in future due to test-time offset) + (setq chime-modeline-lookahead-minutes 64800) ; 45 days + (setq chime-tooltip-lookahead-hours 1080) ; 45 days + + ;; Update modeline with these events + (chime--update-modeline events) + + ;; Check that upcoming events list has no duplicates + (should (= 2 (length chime--upcoming-events))) + + ;; Each event should appear exactly once + (let ((titles (mapcar (lambda (item) + (cdr (assoc 'title (car item)))) + chime--upcoming-events))) + (should (member "Meeting A" titles)) + (should (member "Meeting B" titles)) + ;; No duplicate titles + (should (= 2 (length (delete-dups (copy-sequence titles)))))))) + (test-chime-modeline-teardown))) + +;;; Tests for tooltip generation + +(ert-deftest test-chime-modeline-tooltip-no-duplicates () + "Test that tooltip doesn't show the same event multiple times. +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (meeting-time (test-time-tomorrow-at 14 0)) + (content (test-chime-modeline--create-gcal-event + "Team Meeting" + (test-timestamp-string meeting-time))) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set lookahead to cover test events (30+ days in future due to test-time offset) + (setq chime-modeline-lookahead-minutes 64800) ; 45 days + (setq chime-tooltip-lookahead-hours 1080) ; 45 days + + ;; Update modeline + (chime--update-modeline events) + + ;; Generate tooltip + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (message "DEBUG: Tooltip content:\n%s" tooltip) + + ;; Tooltip should contain "Team Meeting" exactly once + (let ((count (test-chime-modeline--count-in-string "Team Meeting" tooltip))) + (should (= 1 count))) + + ;; "Upcoming Events" header should appear exactly once + (let ((header-count (test-chime-modeline--count-in-string "Upcoming Events" tooltip))) + (should (= 1 header-count)))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-modeline-tooltip-correct-order () + "Test that tooltip shows events in chronological order (soonest first). +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (meeting-a-time (test-time-tomorrow-at 14 0)) + (meeting-b-time (test-time-tomorrow-at 15 0)) + (content (concat + ;; Later event + (test-chime-modeline--create-gcal-event + "Meeting B" + (test-timestamp-string meeting-b-time)) + ;; Earlier event + (test-chime-modeline--create-gcal-event + "Meeting A" + (test-timestamp-string meeting-a-time)))) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set lookahead to cover test events (30+ days in future due to test-time offset) + (setq chime-modeline-lookahead-minutes 64800) ; 45 days + (setq chime-tooltip-lookahead-hours 1080) ; 45 days + + ;; Update modeline + (chime--update-modeline events) + + ;; Generate tooltip + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (message "DEBUG: Tooltip for order test:\n%s" tooltip) + + ;; "Meeting A" should appear before "Meeting B" in tooltip + (let ((pos-a (string-match "Meeting A" tooltip)) + (pos-b (string-match "Meeting B" tooltip))) + (should pos-a) + (should pos-b) + (should (< pos-a pos-b)))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-modeline-tooltip-structure () + "Test that tooltip has proper structure without duplicates. + +Tooltip should have: +- 'Upcoming Events as of...' header (once, at the beginning) +- Date sections (once per date) +- Event listings (once per event) +- No duplicate headers or sections + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (meeting-time (test-time-tomorrow-at 14 0)) + (content (test-chime-modeline--create-gcal-event + "Team Meeting" + (test-timestamp-string meeting-time))) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set lookahead to cover test events (30+ days in future due to test-time offset) + (setq chime-modeline-lookahead-minutes 64800) ; 45 days + (setq chime-tooltip-lookahead-hours 1080) ; 45 days + + ;; Update modeline + (chime--update-modeline events) + + ;; Generate tooltip + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (message "DEBUG: Tooltip structure:\n%s" tooltip) + + ;; Should have exactly one "Upcoming Events" header + (should (= 1 (test-chime-modeline--count-in-string "Upcoming Events" tooltip))) + + ;; Should start with "Upcoming Events as of" (new header format with timestamp) + (should (string-match-p "^Upcoming Events as of" tooltip)) + + ;; Event should appear exactly once + (should (= 1 (test-chime-modeline--count-in-string "Team Meeting" tooltip)))))) + (test-chime-modeline-teardown))) + +;;; Tests for tooltip max events limit + +(ert-deftest test-chime-modeline-tooltip-max-events () + "Test that tooltip respects chime-modeline-tooltip-max-events limit. + +When there are more events than the max, tooltip should show: +- Only the first N events +- '... and N more' message at the end + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (base-time (test-time-tomorrow-at 14 0)) + (content (concat + (test-chime-modeline--create-gcal-event + "Event 1" + (test-timestamp-string base-time)) + (test-chime-modeline--create-gcal-event + "Event 2" + (test-timestamp-string (test-time-tomorrow-at 15 0))) + (test-chime-modeline--create-gcal-event + "Event 3" + (test-timestamp-string (test-time-tomorrow-at 16 0))) + (test-chime-modeline--create-gcal-event + "Event 4" + (test-timestamp-string (test-time-tomorrow-at 17 0))) + (test-chime-modeline--create-gcal-event + "Event 5" + (test-timestamp-string (test-time-tomorrow-at 18 0))))) + (events (test-chime-modeline--gather-events content)) + (chime-modeline-tooltip-max-events 3)) + + (with-test-time now + ;; Set lookahead to cover test events (30+ days in future due to test-time offset) + (setq chime-modeline-lookahead-minutes 64800) ; 45 days + (setq chime-tooltip-lookahead-hours 1080) ; 45 days + + ;; Update modeline + (chime--update-modeline events) + + ;; Generate tooltip + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Should show first 3 events + (should (string-match-p "Event 1" tooltip)) + (should (string-match-p "Event 2" tooltip)) + (should (string-match-p "Event 3" tooltip)) + + ;; Should NOT show events 4 and 5 + (should-not (string-match-p "Event 4" tooltip)) + (should-not (string-match-p "Event 5" tooltip)) + + ;; Should have "... and 2 more events" message + (should (string-match-p "\\.\\.\\. and 2 more events" tooltip))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-modeline-tooltip-max-events-nil () + "Test that tooltip shows all events when max-events is nil. + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (base-time (test-time-tomorrow-at 14 0)) + (content (concat + (test-chime-modeline--create-gcal-event + "Event 1" + (test-timestamp-string base-time)) + (test-chime-modeline--create-gcal-event + "Event 2" + (test-timestamp-string (test-time-tomorrow-at 15 0))) + (test-chime-modeline--create-gcal-event + "Event 3" + (test-timestamp-string (test-time-tomorrow-at 16 0))))) + (events (test-chime-modeline--gather-events content)) + (chime-modeline-tooltip-max-events nil)) + + (with-test-time now + ;; Set lookahead to cover test events (30+ days in future due to test-time offset) + (setq chime-modeline-lookahead-minutes 64800) ; 45 days + (setq chime-tooltip-lookahead-hours 1080) ; 45 days + + ;; Update modeline + (chime--update-modeline events) + + ;; Generate tooltip + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Should show all 3 events + (should (string-match-p "Event 1" tooltip)) + (should (string-match-p "Event 2" tooltip)) + (should (string-match-p "Event 3" tooltip)) + + ;; Should NOT have "... and N more" message + (should-not (string-match-p "\\.\\.\\. and" tooltip))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-modeline-tooltip-max-events-14-across-week () + "Test max-events with 14 events (2/day across 7 days). + +Comprehensive test of max-events interaction with multi-day grouping: +- 14 events total (2 per day for 7 days) +- max-events=20: should see all 14 events +- max-events=10: should see 10 events (2/day over 5 days) +- max-events=3: should see 2 for today, 1 for tomorrow + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (content "") + (events nil)) + + (with-test-time now + ;; Create 14 events: 2 per day for 7 days + (dotimes (day 7) + (dotimes (event-num 2) + (let* ((hours-offset (+ (* day 24) (* event-num 2) 2)) ; 2, 4, 26, 28, etc. + (event-time (time-add now (seconds-to-time (* hours-offset 3600)))) + (time-str (format-time-string "<%Y-%m-%d %a %H:%M>" event-time)) + (title (format "Day%d-Event%d" (1+ day) (1+ event-num)))) + (setq content (concat content + (test-chime-modeline--create-gcal-event + title + time-str)))))) + + (setq events (test-chime-modeline--gather-events content)) + + ;; Should have gathered 14 events + (should (= 14 (length events))) + + ;; Set lookahead to 10 days (enough to see all events, both modeline and tooltip) + (setq chime-modeline-lookahead-minutes 240) + (setq chime-tooltip-lookahead-hours 240) + + ;; Test 1: max-events=20 should show all 14 + (let ((chime-modeline-tooltip-max-events 20)) + (chime--update-modeline events) + (should (= 14 (length chime--upcoming-events))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Tooltip structure checks + (should (string-match-p "^Upcoming Events as of" tooltip)) + (should (= 1 (test-chime-modeline--count-in-string "Upcoming Events" tooltip))) + + ;; All 14 events should appear in tooltip + (dotimes (day 7) + (dotimes (event-num 2) + (let ((title (format "Day%d-Event%d" (1+ day) (1+ event-num)))) + (should (string-match-p title tooltip))))) + + ;; Verify chronological order: Day1-Event1 before Day7-Event2 + (let ((pos-first (string-match "Day1-Event1" tooltip)) + (pos-last (string-match "Day7-Event2" tooltip))) + (should pos-first) + (should pos-last) + (should (< pos-first pos-last))) + + ;; Should NOT have "... and N more" + (should-not (string-match-p "\\.\\.\\. and" tooltip)))) + + ;; Test 2: max-events=10 should show first 10 (5 days) + (let ((chime-modeline-tooltip-max-events 10)) + (chime--update-modeline events) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Tooltip structure checks + (should (string-match-p "^Upcoming Events as of" tooltip)) + (should (= 1 (test-chime-modeline--count-in-string "Upcoming Events" tooltip))) + + ;; First 10 events (days 1-5) should appear in tooltip + (dotimes (day 5) + (dotimes (event-num 2) + (let ((title (format "Day%d-Event%d" (1+ day) (1+ event-num)))) + (should (string-match-p title tooltip))))) + + ;; Events from days 6-7 should NOT appear in tooltip + (dotimes (day 2) + (dotimes (event-num 2) + (let ((title (format "Day%d-Event%d" (+ day 6) (1+ event-num)))) + (should-not (string-match-p title tooltip))))) + + ;; Verify chronological order in tooltip + (let ((pos-first (string-match "Day1-Event1" tooltip)) + (pos-last (string-match "Day5-Event2" tooltip))) + (should pos-first) + (should pos-last) + (should (< pos-first pos-last))) + + ;; Tooltip should have "... and 4 more events" + (should (string-match-p "\\.\\.\\. and 4 more events" tooltip)))) + + ;; Test 3: max-events=3 should show 2 today + 1 tomorrow + (let ((chime-modeline-tooltip-max-events 3)) + (chime--update-modeline events) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Tooltip structure checks + (should (string-match-p "^Upcoming Events as of" tooltip)) + (should (= 1 (test-chime-modeline--count-in-string "Upcoming Events" tooltip))) + + ;; First 2 events (today) should appear in tooltip + (should (string-match-p "Day1-Event1" tooltip)) + (should (string-match-p "Day1-Event2" tooltip)) + + ;; First event from day 2 (tomorrow) should appear in tooltip + (should (string-match-p "Day2-Event1" tooltip)) + + ;; Second event from day 2 should NOT appear in tooltip + (should-not (string-match-p "Day2-Event2" tooltip)) + + ;; Events from days 3+ should NOT appear in tooltip + (dotimes (day 5) + (dotimes (event-num 2) + (let ((title (format "Day%d-Event%d" (+ day 3) (1+ event-num)))) + (should-not (string-match-p title tooltip))))) + + ;; Verify chronological order in tooltip: Day1-Event1 before Day2-Event1 + (let ((pos-day1-e1 (string-match "Day1-Event1" tooltip)) + (pos-day1-e2 (string-match "Day1-Event2" tooltip)) + (pos-day2-e1 (string-match "Day2-Event1" tooltip))) + (should pos-day1-e1) + (should pos-day1-e2) + (should pos-day2-e1) + (should (< pos-day1-e1 pos-day1-e2)) + (should (< pos-day1-e2 pos-day2-e1))) + + ;; Should have "Today," and "Tomorrow," day labels in tooltip + (should (string-match-p "Today," tooltip)) + (should (string-match-p "Tomorrow," tooltip)) + + ;; Tooltip should have "... and 11 more events" + (should (string-match-p "\\.\\.\\. and 11 more events" tooltip)))))) + (test-chime-modeline-teardown))) + +;;; Tests for lookahead window boundaries + +(ert-deftest test-chime-modeline-lookahead-exact-limit () + "Test that event exactly at lookahead limit appears in modeline. + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Create event exactly 60 minutes from now + (future-time (time-add now (seconds-to-time (* 60 60)))) + (time-str (format-time-string "<%Y-%m-%d %a %H:%M>" future-time)) + (content (test-chime-modeline--create-gcal-event + "Event at limit" + time-str)) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set lookahead to 60 minutes (both modeline and tooltip) + (setq chime-modeline-lookahead-minutes 60) + (setq chime-tooltip-lookahead-hours 1) + + ;; Update modeline + (chime--update-modeline events) + + ;; Event should appear in upcoming events + (should (= 1 (length chime--upcoming-events))) + (should (string-match-p "Event at limit" + (cdr (assoc 'title (car (car chime--upcoming-events)))))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-modeline-lookahead-beyond-limit () + "Test that event beyond lookahead limit does NOT appear. + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Create event 90 minutes from now (beyond 60 min limit) + (future-time (time-add now (seconds-to-time (* 90 60)))) + (time-str (format-time-string "<%Y-%m-%d %a %H:%M>" future-time)) + (content (test-chime-modeline--create-gcal-event + "Event beyond limit" + time-str)) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set lookahead to 60 minutes (both modeline and tooltip) + (setq chime-modeline-lookahead-minutes 60) + (setq chime-tooltip-lookahead-hours 1) + + ;; Update modeline + (chime--update-modeline events) + + ;; Event should NOT appear in upcoming events + (should (= 0 (length chime--upcoming-events))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-modeline-past-event-excluded () + "Test that past events do NOT appear in modeline. + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((past-time (test-time-yesterday-at 14 0)) + (content (test-chime-modeline--create-gcal-event + "Past Event" + (test-timestamp-string past-time))) + (events (test-chime-modeline--gather-events content))) + + ;; Update modeline + (chime--update-modeline events) + + ;; Past event should NOT appear + (should (= 0 (length chime--upcoming-events)))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-tooltip-lookahead-boundary-exactly-5-days () + "Test that event exactly 5 days away appears when tooltip lookahead is 5 days. + +This tests the inclusive boundary condition at a multi-day scale. +Event at exactly 120 hours with lookahead=120 hours should appear (at boundary). + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event exactly 5 days (120 hours) from now + (event-time (time-add now (seconds-to-time (* 120 3600)))) + (time-str (format-time-string "<%Y-%m-%d %a %H:%M>" event-time)) + (content (test-chime-modeline--create-gcal-event + "Event at 5 day boundary" + time-str)) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set tooltip lookahead to 120 hours (exactly 5 days) + (setq chime-modeline-lookahead-minutes 10080) ; 7 days (include in modeline) + (setq chime-tooltip-lookahead-hours 120) ; Exactly 5 days + + ;; Update modeline + (chime--update-modeline events) + + ;; Event at 120 hours should appear (<= 120 hour boundary, inclusive) + (should (= 1 (length chime--upcoming-events))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (should (string-match-p "Event at 5 day boundary" tooltip)) + (should (string-match-p "in 5 days" tooltip))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-tooltip-lookahead-boundary-crosses-into-window () + "Test that event appears when time progression brings it into lookahead window. + +Scenario: +- Event exactly 120 hours away +- Tooltip lookahead set to 119 hours (just under 5 days) +- Initially: event should NOT appear (120 > 119, beyond lookahead) +- Time progresses by 2 hours +- Now event is 118 hours away +- After time progression: event SHOULD appear (118 <= 119, within lookahead) + +This tests dynamic boundary crossing as time progresses. + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event exactly 120 hours from now + (event-time (time-add now (seconds-to-time (* 120 3600)))) + (event-time-str (format-time-string "<%Y-%m-%d %a %H:%M>" event-time)) + (content (test-chime-modeline--create-gcal-event + "Future Event" + event-time-str)) + (events (test-chime-modeline--gather-events content))) + + ;; PHASE 1: Initial state - event should NOT appear + (with-test-time now + (setq chime-modeline-lookahead-minutes 10080) ; 7 days (include in modeline) + (setq chime-tooltip-lookahead-hours 119) ; Just under 5 days + + (chime--update-modeline events) + + ;; Event at 120 hours should NOT appear (120 > 119, beyond lookahead) + (should (= 0 (length chime--upcoming-events)))) + + ;; PHASE 2: Time progresses by 2 hours + (let ((later (time-add now (seconds-to-time (* 2 3600))))) ; +2 hours + (with-test-time later + ;; Same lookahead settings + (setq chime-modeline-lookahead-minutes 10080) + (setq chime-tooltip-lookahead-hours 119) + + (chime--update-modeline events) + + ;; Now event is 118 hours away, should appear (118 <= 119) + (should (= 1 (length chime--upcoming-events))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (should (string-match-p "Future Event" tooltip)) + ;; Should show approximately 4 days 22 hours (118 hours) + (should (string-match-p "in 4 days 22 hours" tooltip)))))) + (test-chime-modeline-teardown))) + +;;; Tests for org-gcal with SCHEDULED edge case + +(ert-deftest test-chime-extract-time-gcal-ignores-scheduled () + "Test that org-gcal events ignore SCHEDULED and use drawer time. + +This is the CRITICAL test for the user's issue: when an event is +rescheduled in Google Calendar, org-gcal updates the :org-gcal: drawer +but might leave SCHEDULED property. We should ONLY use drawer time. + +REFACTORED: Uses dynamic timestamps" + (chime-create-test-base-dir) + (unwind-protect + (let* ((old-time (test-time-yesterday-at 14 0)) + (new-time (test-time-today-at 16 0)) + (old-timestamp (test-timestamp-string old-time)) + (new-timestamp (test-timestamp-string new-time)) + (content (format "* Team Meeting +SCHEDULED: %s +:PROPERTIES: +:entry-id: test123@google.com +:END: +:org-gcal: +%s +:END: +" old-timestamp new-timestamp)) + (test-file (chime-create-temp-test-file-with-content content)) + (test-buffer (find-file-noselect test-file)) + marker) + + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (setq marker (point-marker))) + + ;; Extract times + (let ((times (chime--extract-time marker))) + ;; Should have exactly one timestamp (from drawer, not SCHEDULED) + (should (= 1 (length times))) + + ;; Should be the drawer time (today 16:00), not SCHEDULED (yesterday 14:00) + (let ((time-str (caar times))) + (should (string-match-p "16:00" time-str)) + (should-not (string-match-p "14:00" time-str)))) + + (kill-buffer test-buffer)) + (chime-delete-test-base-dir))) + +;;; Tests for multiple timestamps deduplication + +(ert-deftest test-chime-modeline-multiple-timestamps-shows-soonest () + "Test that event with multiple timestamps appears once with soonest time. + +Regular org events (not org-gcal) can have multiple plain timestamps. +Modeline should show the event ONCE with its SOONEST upcoming timestamp. + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (wed-time (test-time-tomorrow-at 14 0)) + (thu-time (test-time-days-from-now 2)) + (fri-time (test-time-days-from-now 3)) + (content (format "* Weekly Meeting +%s +%s +%s +" (test-timestamp-string wed-time) + (test-timestamp-string thu-time) + (test-timestamp-string fri-time))) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set lookahead to cover test events (30+ days in future due to test-time offset) + (setq chime-modeline-lookahead-minutes 64800) ; 45 days + (setq chime-tooltip-lookahead-hours 1080) ; 45 days + + ;; Update modeline + (chime--update-modeline events) + + ;; Should have exactly ONE entry in upcoming events + (should (= 1 (length chime--upcoming-events))) + + ;; The entry should be for the soonest time (tomorrow) + (let* ((item (car chime--upcoming-events)) + (time-str (car (nth 1 item)))) + (should (string-match-p "14:00" time-str))) + + ;; Tooltip should show the event exactly once + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (should (= 1 (test-chime-modeline--count-in-string "Weekly Meeting" tooltip)))))) + (test-chime-modeline-teardown))) + +;;; Tests for day grouping + +(ert-deftest test-chime-modeline-day-grouping-today-tomorrow-future () + "Test tooltip groups events by day with correct labels. + +Events should be grouped as: +- 'Today, MMM DD' for events in next 24 hours +- 'Tomorrow, MMM DD' for events 24-48 hours away +- 'Weekday, MMM DD' for events beyond 48 hours + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event in 2 hours (today) + (today-time (time-add now (seconds-to-time (* 2 3600)))) + (today-str (format-time-string "<%Y-%m-%d %a %H:%M>" today-time)) + ;; Event in 26 hours (tomorrow) + (tomorrow-time (time-add now (seconds-to-time (* 26 3600)))) + (tomorrow-str (format-time-string "<%Y-%m-%d %a %H:%M>" tomorrow-time)) + ;; Event in 50 hours (future) + (future-time (time-add now (seconds-to-time (* 50 3600)))) + (future-str (format-time-string "<%Y-%m-%d %a %H:%M>" future-time)) + (content (concat + (test-chime-modeline--create-gcal-event + "Today Event" + today-str) + (test-chime-modeline--create-gcal-event + "Tomorrow Event" + tomorrow-str) + (test-chime-modeline--create-gcal-event + "Future Event" + future-str))) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set lookahead to 72 hours (both modeline and tooltip) + (setq chime-modeline-lookahead-minutes 4320) + (setq chime-tooltip-lookahead-hours 72) + + ;; Update modeline + (chime--update-modeline events) + + ;; Should have 3 events + (should (= 3 (length chime--upcoming-events))) + + ;; Generate tooltip + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Should have "Today," label + (should (string-match-p "Today," tooltip)) + + ;; Should have "Tomorrow," label + (should (string-match-p "Tomorrow," tooltip)) + + ;; Should have a weekday name for future event + (should (string-match-p (format-time-string "%A," future-time) tooltip)) + + ;; All three events should appear + (should (string-match-p "Today Event" tooltip)) + (should (string-match-p "Tomorrow Event" tooltip)) + (should (string-match-p "Future Event" tooltip))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-modeline-day-grouping-multiple-same-day () + "Test that multiple events on same day are grouped together. + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (morning-time (test-time-tomorrow-at 9 0)) + (afternoon-time (test-time-tomorrow-at 14 0)) + (evening-time (test-time-tomorrow-at 18 0)) + (content (concat + (test-chime-modeline--create-gcal-event + "Morning Event" + (test-timestamp-string morning-time)) + (test-chime-modeline--create-gcal-event + "Afternoon Event" + (test-timestamp-string afternoon-time)) + (test-chime-modeline--create-gcal-event + "Evening Event" + (test-timestamp-string evening-time)))) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set lookahead to cover test events (30+ days in future due to test-time offset) + (setq chime-modeline-lookahead-minutes 64800) ; 45 days + (setq chime-tooltip-lookahead-hours 1080) ; 45 days + + ;; Update modeline + (chime--update-modeline events) + + ;; Generate tooltip + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Should have "Tomorrow, " exactly once (all events same day) + (should (= 1 (test-chime-modeline--count-in-string "Tomorrow," tooltip))) + + ;; All three events should appear + (should (string-match-p "Morning Event" tooltip)) + (should (string-match-p "Afternoon Event" tooltip)) + (should (string-match-p "Evening Event" tooltip)) + + ;; Events should appear in chronological order + (let ((pos-morning (string-match "Morning Event" tooltip)) + (pos-afternoon (string-match "Afternoon Event" tooltip)) + (pos-evening (string-match "Evening Event" tooltip))) + (should (< pos-morning pos-afternoon)) + (should (< pos-afternoon pos-evening)))))) + (test-chime-modeline-teardown))) + +;;; Tests for tooltip lookahead independence + +(ert-deftest test-chime-tooltip-lookahead-hours-independent () + "Test that tooltip can show events beyond modeline lookahead. + +Scenario: modeline-lookahead=60 (1 hour), tooltip-lookahead=180 (3 hours) +- Event at 30 min: appears in BOTH modeline and tooltip +- Event at 90 min: appears ONLY in tooltip (not modeline) +- Event at 240 min: appears in NEITHER + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event in 30 minutes (within modeline lookahead) + (event1-time (time-add now (seconds-to-time (* 30 60)))) + (event1-str (format-time-string "<%Y-%m-%d %a %H:%M>" event1-time)) + ;; Event in 90 minutes (beyond modeline, within tooltip) + (event2-time (time-add now (seconds-to-time (* 90 60)))) + (event2-str (format-time-string "<%Y-%m-%d %a %H:%M>" event2-time)) + ;; Event in 240 minutes (beyond both) + (event3-time (time-add now (seconds-to-time (* 240 60)))) + (event3-str (format-time-string "<%Y-%m-%d %a %H:%M>" event3-time)) + (content (concat + (test-chime-modeline--create-gcal-event + "Soon Event" + event1-str) + (test-chime-modeline--create-gcal-event + "Later Event" + event2-str) + (test-chime-modeline--create-gcal-event + "Far Event" + event3-str))) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set different lookaheads for modeline vs tooltip + (setq chime-modeline-lookahead-minutes 60) ; 1 hour for modeline + (setq chime-tooltip-lookahead-hours 3) ; 3 hours for tooltip + + ;; Update modeline + (chime--update-modeline events) + + ;; Modeline should show only the 30-min event + (should (string-match-p "Soon Event" (or chime-modeline-string ""))) + (should-not (string-match-p "Later Event" (or chime-modeline-string ""))) + + ;; Tooltip should show both 30-min and 90-min events, but not 240-min + (should (= 2 (length chime--upcoming-events))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Tooltip should have event within modeline lookahead + (should (string-match-p "Soon Event" tooltip)) + + ;; Tooltip should have event beyond modeline but within tooltip lookahead + (should (string-match-p "Later Event" tooltip)) + + ;; Tooltip should NOT have event beyond tooltip lookahead + (should-not (string-match-p "Far Event" tooltip))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-tooltip-lookahead-hours-default () + "Test that tooltip default lookahead (1 year) shows all future events. + +The default value effectively means 'show all future events' limited only +by max-events count. + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event in 30 minutes + (event1-time (time-add now (seconds-to-time (* 30 60)))) + (event1-str (format-time-string "<%Y-%m-%d %a %H:%M>" event1-time)) + ;; Event in 90 minutes + (event2-time (time-add now (seconds-to-time (* 90 60)))) + (event2-str (format-time-string "<%Y-%m-%d %a %H:%M>" event2-time)) + ;; Event in 2 days + (event3-time (time-add now (seconds-to-time (* 48 3600)))) + (event3-str (format-time-string "<%Y-%m-%d %a %H:%M>" event3-time)) + (content (concat + (test-chime-modeline--create-gcal-event + "Soon Event" + event1-str) + (test-chime-modeline--create-gcal-event + "Later Event" + event2-str) + (test-chime-modeline--create-gcal-event + "Far Event" + event3-str))) + (events (test-chime-modeline--gather-events content))) + + (with-test-time now + ;; Set modeline lookahead only (tooltip uses default: 525600 = 1 year) + (setq chime-modeline-lookahead-minutes 60) + (setq chime-tooltip-lookahead-hours 8760) ; Default + + ;; Update modeline + (chime--update-modeline events) + + ;; Tooltip should see all 3 events (all within 1 year) + (should (= 3 (length chime--upcoming-events))) + + ;; Modeline should only show first event (within 60 min) + (should (string-match-p "Soon Event" (or chime-modeline-string ""))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; All events should appear in tooltip + (should (string-match-p "Soon Event" tooltip)) + (should (string-match-p "Later Event" tooltip)) + (should (string-match-p "Far Event" tooltip))))) + (test-chime-modeline-teardown))) + +(ert-deftest test-chime-tooltip-lookahead-hours-larger-shows-more () + "Test that larger tooltip lookahead shows more events than modeline. + +Real-world scenario: Show next event in modeline if within 2 hours, +but show all events for today (24 hours) in tooltip. + +REFACTORED: Uses dynamic timestamps" + (test-chime-modeline-setup) + (unwind-protect + (let* ((now (test-time-now)) + (content "") + (events nil)) + + (with-test-time now + ;; Create 5 events spread across 12 hours + (dotimes (i 5) + (let* ((hours-offset (+ 1 (* i 2))) ; 1, 3, 5, 7, 9 hours + (event-time (time-add now (seconds-to-time (* hours-offset 3600)))) + (time-str (format-time-string "<%Y-%m-%d %a %H:%M>" event-time)) + (title (format "Event-%d-hours" hours-offset))) + (setq content (concat content + (test-chime-modeline--create-gcal-event + title + time-str))))) + + (setq events (test-chime-modeline--gather-events content)) + + ;; Set lookaheads: 2 hours for modeline, 12 hours for tooltip + (setq chime-modeline-lookahead-minutes 120) + (setq chime-tooltip-lookahead-hours 12) + + ;; Update modeline + (chime--update-modeline events) + + ;; Modeline should show only first event (within 2 hours) + (should (string-match-p "Event-1-hours" (or chime-modeline-string ""))) + (should-not (string-match-p "Event-5-hours" (or chime-modeline-string ""))) + + ;; Tooltip should show all 5 events (all within 12 hours) + (should (= 5 (length chime--upcoming-events))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; All events should appear in tooltip + (should (string-match-p "Event-1-hours" tooltip)) + (should (string-match-p "Event-3-hours" tooltip)) + (should (string-match-p "Event-5-hours" tooltip)) + (should (string-match-p "Event-7-hours" tooltip)) + (should (string-match-p "Event-9-hours" tooltip))))) + (test-chime-modeline-teardown))) + +(provide 'test-chime-modeline) +;;; test-chime-modeline.el ends here diff --git a/tests/test-chime-notification-text.el b/tests/test-chime-notification-text.el new file mode 100644 index 0000000..71a2969 --- /dev/null +++ b/tests/test-chime-notification-text.el @@ -0,0 +1,542 @@ +;;; test-chime-notification-text.el --- Tests for chime--notification-text -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--notification-text function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-notification-text-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset display format to default + (setq chime-display-time-format-string "%I:%M %p") + ;; Reset notification text format to default + (setq chime-notification-text-format "%t at %T (%u)") + ;; Reset time-left formats to defaults + (setq chime-time-left-format-at-event "right now") + (setq chime-time-left-format-short "in %M") + (setq chime-time-left-format-long "in %H %M") + ;; Reset title truncation to default (no truncation) + (setq chime-max-title-length nil)) + +(defun test-chime-notification-text-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-notification-text-standard-event-formats-correctly () + "Test that standard event formats correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Team Meeting"))) + (result (chime--notification-text str-interval event))) + ;; Should format: "Team Meeting at 02:30 PM (in X minutes)" + (should (stringp result)) + (should (string-match-p "Team Meeting" result)) + (should (string-match-p "02:30 PM" result)) + (should (string-match-p "in 10 minutes" result))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-morning-time-formats-with-am () + "Test that morning time uses AM. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 9 15)) + (str-interval (cons (test-timestamp-string time) '(5 . medium))) + (event '((title . "Standup"))) + (result (chime--notification-text str-interval event))) + (should (string-match-p "Standup" result)) + (should (string-match-p "09:15 AM" result)) + (should (string-match-p "in 5 minutes" result))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-midnight-formats-correctly () + "Test that midnight time formats correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 0 0)) + (str-interval (cons (test-timestamp-string time) '(30 . medium))) + (event '((title . "Midnight Event"))) + (result (chime--notification-text str-interval event))) + (should (string-match-p "Midnight Event" result)) + (should (string-match-p "12:00 AM" result)) + (should (string-match-p "in 30 minutes" result))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-noon-formats-correctly () + "Test that noon time formats correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 12 0)) + (str-interval (cons (test-timestamp-string time) '(15 . medium))) + (event '((title . "Lunch"))) + (result (chime--notification-text str-interval event))) + (should (string-match-p "Lunch" result)) + (should (string-match-p "12:00 PM" result)) + (should (string-match-p "in 15 minutes" result))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-zero-minutes-shows-right-now () + "Test that zero minutes shows 'right now'. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (str-interval (cons (test-timestamp-string time) '(0 . high))) + (event '((title . "Current Event"))) + (result (chime--notification-text str-interval event))) + (should (string-match-p "Current Event" result)) + (should (string-match-p "02:00 PM" result)) + (should (string-match-p "right now" result))) + (test-chime-notification-text-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-notification-text-very-long-title-included () + "Test that very long titles are included in full. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 15 45)) + (str-interval (cons (test-timestamp-string time) '(20 . medium))) + (long-title "This is a very long event title that contains many words and might wrap in the notification display") + (event `((title . ,long-title))) + (result (chime--notification-text str-interval event))) + ;; Should include the full title + (should (string-match-p long-title result)) + (should (string-match-p "03:45 PM" result)) + (should (string-match-p "in 20 minutes" result))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-title-with-special-characters () + "Test that titles with special characters work correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 16 30)) + (str-interval (cons (test-timestamp-string time) '(5 . medium))) + (event '((title . "Review: Alice's PR #123 (urgent!)"))) + (result (chime--notification-text str-interval event))) + (should (string-match-p "Review: Alice's PR #123 (urgent!)" result)) + (should (string-match-p "04:30 PM" result))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-custom-time-format () + "Test that custom time format string is respected. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Meeting"))) + (chime-display-time-format-string "%H:%M") ; 24-hour format + (result (chime--notification-text str-interval event))) + ;; Should use 24-hour format + (should (string-match-p "Meeting" result)) + (should (string-match-p "14:30" result)) + (should-not (string-match-p "PM" result))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-large-interval-shows-hours () + "Test that large intervals show hours. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 18 0)) + (str-interval (cons (test-timestamp-string time) '(120 . low))) ; 2 hours + (event '((title . "Evening Event"))) + (result (chime--notification-text str-interval event))) + (should (string-match-p "Evening Event" result)) + (should (string-match-p "06:00 PM" result)) + ;; Should show hours format + (should (string-match-p "in 2 hours" result))) + (test-chime-notification-text-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-notification-text-empty-title-shows-empty () + "Test that empty title still generates output. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . ""))) + (result (chime--notification-text str-interval event))) + ;; Should still format, even with empty title + (should (stringp result)) + (should (string-match-p "02:30 PM" result)) + (should (string-match-p "in 10 minutes" result))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-missing-title-shows-nil () + "Test that missing title shows nil in output. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '()) ; No title + (result (chime--notification-text str-interval event))) + ;; Should still generate output with nil title + (should (stringp result)) + (should (string-match-p "02:30 PM" result)) + (should (string-match-p "in 10 minutes" result))) + (test-chime-notification-text-teardown))) + +;;; Custom Format Cases + +(ert-deftest test-chime-notification-text-custom-title-only () + "Test custom format showing title only. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Team Meeting"))) + (chime-notification-text-format "%t")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-equal "Team Meeting" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-custom-title-and-time-no-countdown () + "Test custom format with title and time, no countdown. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Team Meeting"))) + (chime-notification-text-format "%t at %T")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-equal "Team Meeting at 02:30 PM" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-custom-title-and-countdown-no-time () + "Test custom format with title and countdown, no time. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Team Meeting"))) + (chime-notification-text-format "%t (%u)")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-equal "Team Meeting (in 10 minutes)" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-custom-separator () + "Test custom format with custom separator. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Team Meeting"))) + (chime-notification-text-format "%t - %T")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-equal "Team Meeting - 02:30 PM" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-custom-order-time-first () + "Test custom format with time before title. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Team Meeting"))) + (chime-notification-text-format "%T: %t")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-equal "02:30 PM: Team Meeting" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-custom-compact-format () + "Test custom compact format. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Meeting"))) + (chime-notification-text-format "%t@%T")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-equal "Meeting@02:30 PM" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-custom-with-compact-time-left () + "Test custom format with compact time-left format. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Meeting"))) + (chime-notification-text-format "%t (%u)") + (chime-time-left-format-short "in %mm")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-equal "Meeting (in 10m)" result)))) + (test-chime-notification-text-teardown))) + +;;; Time Format Cases + +(ert-deftest test-chime-notification-text-24-hour-time-format () + "Test 24-hour time format (14:30). + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Meeting"))) + (chime-display-time-format-string "%H:%M")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "14:30" result)) + (should-not (string-match-p "PM" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-12-hour-no-space-before-ampm () + "Test 12-hour format without space before AM/PM (02:30PM). + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Meeting"))) + (chime-display-time-format-string "%I:%M%p")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "02:30PM" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-lowercase-ampm () + "Test 12-hour format with lowercase am/pm (02:30 pm). + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Meeting"))) + (chime-display-time-format-string "%I:%M %P")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "02:30 pm" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-24-hour-morning () + "Test 24-hour format for morning time (09:15). + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 9 15)) + (str-interval (cons (test-timestamp-string time) '(5 . medium))) + (event '((title . "Standup"))) + (chime-display-time-format-string "%H:%M")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "09:15" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-24-hour-midnight () + "Test 24-hour format for midnight (00:00). + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 0 0)) + (str-interval (cons (test-timestamp-string time) '(30 . medium))) + (event '((title . "Midnight"))) + (chime-display-time-format-string "%H:%M")) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "00:00" result)))) + (test-chime-notification-text-teardown))) + +;;; Title Truncation Cases + +(ert-deftest test-chime-notification-text-truncate-nil-no-truncation () + "Test that nil chime-max-title-length shows full title. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Very Long Meeting Title That Goes On And On"))) + (chime-max-title-length nil)) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "Very Long Meeting Title That Goes On And On" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-truncate-25-chars () + "Test truncation to 25 characters. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Very Long Meeting Title That Goes On"))) + (chime-max-title-length 25)) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "Very Long Meeting Titl\\.\\.\\." result)) + (should-not (string-match-p "That Goes On" result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-truncate-15-chars () + "Test truncation to 15 characters. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Very Long Meeting Title"))) + (chime-max-title-length 15)) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "Very Long Me\\.\\.\\." result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-truncate-10-chars () + "Test truncation to 10 characters. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Very Long Title"))) + (chime-max-title-length 10)) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "Very Lo\\.\\.\\." result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-truncate-short-title-unchanged () + "Test that short titles are not truncated. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Short"))) + (chime-max-title-length 25)) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "Short" result)) + (should-not (string-match-p "\\.\\.\\." result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-truncate-exact-length-unchanged () + "Test that title exactly at max length is not truncated. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '((title . "Exactly Twenty-Five C"))) ; 21 chars + (chime-max-title-length 21)) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "Exactly Twenty-Five C" result)) + (should-not (string-match-p "\\.\\.\\." result)))) + (test-chime-notification-text-teardown))) + +(ert-deftest test-chime-notification-text-truncate-nil-title-handled () + "Test that nil title is handled gracefully with truncation enabled. + +REFACTORED: Uses dynamic timestamps" + (test-chime-notification-text-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (str-interval (cons (test-timestamp-string time) '(10 . medium))) + (event '()) ; No title + (chime-max-title-length 25)) + (let ((result (chime--notification-text str-interval event))) + (should (stringp result)) + (should (string-match-p "02:30 PM" result)))) + (test-chime-notification-text-teardown))) + +(provide 'test-chime-notification-text) +;;; test-chime-notification-text.el ends here diff --git a/tests/test-chime-notifications.el b/tests/test-chime-notifications.el new file mode 100644 index 0000000..d3d5e81 --- /dev/null +++ b/tests/test-chime-notifications.el @@ -0,0 +1,259 @@ +;;; test-chime-notifications.el --- Tests for chime--notifications -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--notifications function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-notifications-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-notifications-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-notifications-single-time-single-interval-returns-pair () + "Test that single time with single interval returns one notification pair. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at 14:10 (10 minutes from now) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Test Event") + (intervals . ((10 . medium))))) + (result (chime--notifications event))) + ;; Should return list with one pair + (should (listp result)) + (should (= 1 (length result)))))) + (test-chime-notifications-teardown))) + +(ert-deftest test-chime-notifications-single-time-multiple-intervals-returns-multiple-pairs () + "Test that single time with multiple intervals returns multiple notification pairs. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at 14:10 + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Test Event") + (intervals . ((10 . medium) (5 . medium))))) ; Two intervals, only 10 matches + (result (chime--notifications event))) + ;; Should return only matching interval + (should (listp result)) + (should (= 1 (length result)))))) + (test-chime-notifications-teardown))) + +(ert-deftest test-chime-notifications-multiple-times-single-interval-returns-matching-pairs () + "Test that multiple times with single interval returns matching notifications. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Two events: one at 14:10, one at 14:05 + (event-time-1 (test-time-today-at 14 10)) + (event-time-2 (test-time-today-at 14 5)) + (timestamp-str-1 (test-timestamp-string event-time-1)) + (timestamp-str-2 (test-timestamp-string event-time-2))) + (with-test-time now + (let* ((event `((times . ((,timestamp-str-1 . ,event-time-1) + (,timestamp-str-2 . ,event-time-2))) + (title . "Test Event") + (intervals . ((10 . medium))))) ; Only first time matches + (result (chime--notifications event))) + ;; Should return only matching time + (should (listp result)) + (should (= 1 (length result)))))) + (test-chime-notifications-teardown))) + +(ert-deftest test-chime-notifications-multiple-times-multiple-intervals-returns-all-matches () + "Test that multiple times and intervals return all matching combinations. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at 14:10 and 14:05 + (event-time-1 (test-time-today-at 14 10)) + (event-time-2 (test-time-today-at 14 5)) + (timestamp-str-1 (test-timestamp-string event-time-1)) + (timestamp-str-2 (test-timestamp-string event-time-2))) + (with-test-time now + (let* ((event `((times . ((,timestamp-str-1 . ,event-time-1) + (,timestamp-str-2 . ,event-time-2))) + (title . "Test Event") + (intervals . ((10 . medium) (5 . medium))))) ; Both match (10 with first, 5 with second) + (result (chime--notifications event))) + ;; Should return both matching pairs + (should (listp result)) + (should (= 2 (length result)))))) + (test-chime-notifications-teardown))) + +(ert-deftest test-chime-notifications-zero-interval-returns-current-time-match () + "Test that zero interval (notify now) works correctly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at exactly current time + (event-time (test-time-today-at 14 0)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Test Event") + (intervals . ((0 . high))))) + (result (chime--notifications event))) + ;; Should return one matching pair + (should (listp result)) + (should (= 1 (length result)))))) + (test-chime-notifications-teardown))) + +(ert-deftest test-chime-notifications-filters-day-wide-events () + "Test that day-wide events (without time) are filtered out. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Mix of day-wide and timed events + (event-time (test-time-today-at 14 10)) + (timestamp-str-day (test-timestamp-string event-time t)) ; Day-wide + (timestamp-str-timed (test-timestamp-string event-time))) ; Timed + (with-test-time now + (let* ((event `((times . ((,timestamp-str-day . ,event-time) ; Day-wide + (,timestamp-str-timed . ,event-time))) ; Timed + (title . "Test Event") + (intervals . ((10 . medium))))) + (result (chime--notifications event))) + ;; Should return only timed event + (should (listp result)) + (should (= 1 (length result)))))) + (test-chime-notifications-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-notifications-empty-times-returns-empty-list () + "Test that event with no times returns empty list. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let ((now (test-time-today-at 14 0))) + (with-test-time now + (let* ((event `((times . (())) + (title . "Test Event") + (intervals . ((10 . medium))))) + (result (chime--notifications event))) + (should (listp result)) + (should (= 0 (length result)))))) + (test-chime-notifications-teardown))) + +(ert-deftest test-chime-notifications-empty-intervals-returns-empty-list () + "Test that event with no intervals returns empty list. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Test Event") + (intervals . ()))) + (result (chime--notifications event))) + (should (listp result)) + (should (= 0 (length result)))))) + (test-chime-notifications-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-notifications-nil-times-returns-empty-list () + "Test that event with nil times returns empty list. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let ((now (test-time-today-at 14 0))) + (with-test-time now + (let* ((event `((times . (nil)) + (title . "Test Event") + (intervals . ((10 . medium))))) + (result (chime--notifications event))) + (should (listp result)) + (should (= 0 (length result)))))) + (test-chime-notifications-teardown))) + +(ert-deftest test-chime-notifications-nil-intervals-returns-empty-list () + "Test that event with nil intervals returns empty list. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Test Event") + (intervals . nil))) + (result (chime--notifications event))) + (should (listp result)) + (should (= 0 (length result)))))) + (test-chime-notifications-teardown))) + +(provide 'test-chime-notifications) +;;; test-chime-notifications.el ends here diff --git a/tests/test-chime-notify.el b/tests/test-chime-notify.el new file mode 100644 index 0000000..20727c1 --- /dev/null +++ b/tests/test-chime-notify.el @@ -0,0 +1,259 @@ +;;; test-chime-notify.el --- Tests for chime--notify -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--notify function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) + +;;; Setup and Teardown + +(defun test-chime-notify-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset notification settings + (setq chime-notification-title "Agenda") + (setq chime-notification-icon nil) + (setq chime-extra-alert-plist nil) + (setq chime-play-sound t) + ;; Use a simple test path for sound file + (setq chime-sound-file "/tmp/test-chime.wav")) + +(defun test-chime-notify-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-notify-plays-sound-when-enabled-and-file-exists () + "Test that sound is played when enabled and file exists." + (test-chime-notify-setup) + (unwind-protect + (let ((sound-played nil) + (alert-called nil) + (alert-message nil)) + (cl-letf* ((chime-play-sound t) + (chime-sound-file (expand-file-name "test-sound.wav" chime-test-base-dir)) + ;; Mock file-exists-p to return t + ((symbol-function 'file-exists-p) (lambda (file) t)) + ;; Mock play-sound-file to track if called + ((symbol-function 'play-sound-file) + (lambda (file) + (setq sound-played t))) + ;; Mock alert to track if called + ((symbol-function 'alert) + (lambda (msg &rest args) + (setq alert-called t) + (setq alert-message msg)))) + (chime--notify "Team Meeting at 02:10 PM") + ;; Should play sound + (should sound-played) + ;; Should show alert + (should alert-called) + (should (equal alert-message "Team Meeting at 02:10 PM")))) + (test-chime-notify-teardown))) + +(ert-deftest test-chime-notify-uses-beep-when-no-sound-file-specified () + "Test that no sound is played when chime-sound-file is nil." + (test-chime-notify-setup) + (unwind-protect + (let ((beep-called nil) + (alert-called nil)) + (cl-letf* ((chime-play-sound t) + (chime-sound-file nil) + ;; Mock beep to track if called + ((symbol-function 'beep) + (lambda () (setq beep-called t))) + ;; Mock alert + ((symbol-function 'alert) + (lambda (msg &rest args) (setq alert-called t)))) + (chime--notify "Standup in 5 minutes") + ;; Should NOT call beep (no sound when chime-sound-file is nil) + (should-not beep-called) + ;; Should still show alert + (should alert-called))) + (test-chime-notify-teardown))) + +(ert-deftest test-chime-notify-no-sound-when-disabled () + "Test that no sound is played when chime-play-sound is nil." + (test-chime-notify-setup) + (unwind-protect + (let ((sound-played nil) + (beep-called nil) + (alert-called nil)) + (cl-letf* ((chime-play-sound nil) + ((symbol-function 'play-sound-file) + (lambda (file) (setq sound-played t))) + ((symbol-function 'beep) + (lambda () (setq beep-called t))) + ((symbol-function 'alert) + (lambda (msg &rest args) (setq alert-called t)))) + (chime--notify "Daily Standup") + ;; Should NOT play sound or beep + (should-not sound-played) + (should-not beep-called) + ;; Should still show alert + (should alert-called))) + (test-chime-notify-teardown))) + +(ert-deftest test-chime-notify-passes-correct-parameters-to-alert () + "Test that alert is called with correct parameters." + (test-chime-notify-setup) + (unwind-protect + (let ((alert-params nil)) + (cl-letf* ((chime-play-sound nil) + (chime-notification-title "Custom Title") + (chime-notification-icon "/path/to/icon.png") + (chime-extra-alert-plist '(:persistent t)) + ;; Mock alert to capture parameters + ((symbol-function 'alert) + (lambda (msg &rest args) (setq alert-params args)))) + ;; Pass cons cell (message . severity) to chime--notify + (chime--notify (cons "Test Event" 'high)) + ;; Verify alert was called with correct parameters + (should (equal (plist-get alert-params :title) "Custom Title")) + (should (equal (plist-get alert-params :icon) "/path/to/icon.png")) + (should (equal (plist-get alert-params :severity) 'high)) + (should (equal (plist-get alert-params :category) 'chime)) + ;; Extra plist should be merged in + (should (equal (plist-get alert-params :persistent) t)))) + (test-chime-notify-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-notify-empty-message-still-notifies () + "Test that empty message still triggers notification." + (test-chime-notify-setup) + (unwind-protect + (let ((alert-called nil) + (alert-message nil)) + (cl-letf* ((chime-play-sound nil) + ((symbol-function 'alert) + (lambda (msg &rest args) + (setq alert-called t) + (setq alert-message msg)))) + (chime--notify "") + ;; Should still call alert, even with empty message + (should alert-called) + (should (equal alert-message "")))) + (test-chime-notify-teardown))) + +(ert-deftest test-chime-notify-no-sound-file-when-file-doesnt-exist () + "Test that no sound is played when file doesn't exist." + (test-chime-notify-setup) + (unwind-protect + (let ((sound-played nil) + (alert-called nil)) + (cl-letf* ((chime-play-sound t) + (chime-sound-file "/nonexistent/path/sound.wav") + ;; Mock file-exists-p to return nil + ((symbol-function 'file-exists-p) (lambda (file) nil)) + ((symbol-function 'play-sound-file) + (lambda (file) (setq sound-played t))) + ((symbol-function 'alert) + (lambda (msg &rest args) (setq alert-called t)))) + (chime--notify "Test Event") + ;; Should NOT play sound + (should-not sound-played) + ;; Should still show alert + (should alert-called))) + (test-chime-notify-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-notify-handles-sound-playback-error-gracefully () + "Test that errors in sound playback don't prevent notification." + (test-chime-notify-setup) + (unwind-protect + (let ((alert-called nil)) + (cl-letf* ((chime-play-sound t) + (chime-sound-file "/some/file.wav") + ;; Mock file-exists-p to return t + ((symbol-function 'file-exists-p) (lambda (file) t)) + ;; Mock play-sound-file to throw error + ((symbol-function 'play-sound-file) + (lambda (file) (error "Sound playback failed"))) + ((symbol-function 'alert) + (lambda (msg &rest args) (setq alert-called t)))) + ;; Should not throw error + (should-not (condition-case nil + (progn (chime--notify "Test Event") nil) + (error t))) + ;; Should still show alert despite sound error + (should alert-called))) + (test-chime-notify-teardown))) + +(ert-deftest test-chime-notify-handles-beep-error-gracefully () + "Test that errors in beep don't prevent notification." + (test-chime-notify-setup) + (unwind-protect + (let ((alert-called nil)) + (cl-letf* ((chime-play-sound t) + (chime-sound-file nil) + ;; Mock beep to throw error + ((symbol-function 'beep) + (lambda () (error "Beep failed"))) + ((symbol-function 'alert) + (lambda (msg &rest args) (setq alert-called t)))) + ;; Should not throw error + (should-not (condition-case nil + (progn (chime--notify "Test Event") nil) + (error t))) + ;; Should still show alert despite beep error + (should alert-called))) + (test-chime-notify-teardown))) + +(ert-deftest test-chime-notify-error-nil-message-handles-gracefully () + "Test that nil message parameter doesn't crash." + (test-chime-notify-setup) + (unwind-protect + (let ((alert-called nil)) + (cl-letf* ((chime-play-sound nil) + ((symbol-function 'alert) + (lambda (msg &rest args) (setq alert-called t)))) + ;; Should not error with nil message + (should-not (condition-case nil + (progn (chime--notify nil) nil) + (error t))) + ;; Alert should still be called + (should alert-called))) + (test-chime-notify-teardown))) + +(provide 'test-chime-notify) +;;; test-chime-notify.el ends here diff --git a/tests/test-chime-org-contacts.el b/tests/test-chime-org-contacts.el new file mode 100644 index 0000000..a9a01a1 --- /dev/null +++ b/tests/test-chime-org-contacts.el @@ -0,0 +1,317 @@ +;;; test-chime-org-contacts.el --- Tests for chime-org-contacts.el -*- 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 and integration tests for chime-org-contacts.el + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'org) +(require 'org-capture) + +;; Load the module being tested +(let ((module-file (expand-file-name "../chime-org-contacts.el" + (file-name-directory (or load-file-name buffer-file-name))))) + (load module-file nil t)) + +;;; Unit Tests - chime-org-contacts--parse-birthday + +(ert-deftest test-chime-org-contacts-parse-birthday-full-format () + "Test parsing YYYY-MM-DD format." + (let ((result (chime-org-contacts--parse-birthday "2000-01-01"))) + (should (equal result '(2000 1 1)))) + + (let ((result (chime-org-contacts--parse-birthday "1985-03-15"))) + (should (equal result '(1985 3 15)))) + + (let ((result (chime-org-contacts--parse-birthday "2024-12-31"))) + (should (equal result '(2024 12 31))))) + +(ert-deftest test-chime-org-contacts-parse-birthday-partial-format () + "Test parsing MM-DD format uses current year." + (let ((current-year (nth 5 (decode-time)))) + (let ((result (chime-org-contacts--parse-birthday "03-15"))) + (should (equal result (list current-year 3 15)))) + + (let ((result (chime-org-contacts--parse-birthday "12-31"))) + (should (equal result (list current-year 12 31)))))) + +(ert-deftest test-chime-org-contacts-parse-birthday-leap-year () + "Test parsing leap year date." + (let ((result (chime-org-contacts--parse-birthday "2024-02-29"))) + (should (equal result '(2024 2 29))))) + +(ert-deftest test-chime-org-contacts-parse-birthday-invalid-format () + "Test that invalid formats return nil." + (should (null (chime-org-contacts--parse-birthday "2000/01/01"))) + (should (null (chime-org-contacts--parse-birthday "1-1-2000"))) + (should (null (chime-org-contacts--parse-birthday "Jan 1, 2000"))) + (should (null (chime-org-contacts--parse-birthday "not a date")))) + +(ert-deftest test-chime-org-contacts-parse-birthday-empty-input () + "Test that empty input returns nil." + (should (null (chime-org-contacts--parse-birthday "")))) + +(ert-deftest test-chime-org-contacts-parse-birthday-boundary-dates () + "Test boundary dates (start/end of year, end of months)." + (should (equal (chime-org-contacts--parse-birthday "2025-01-01") '(2025 1 1))) + (should (equal (chime-org-contacts--parse-birthday "2025-12-31") '(2025 12 31))) + (should (equal (chime-org-contacts--parse-birthday "2025-11-30") '(2025 11 30)))) + +;;; Unit Tests - chime-org-contacts--format-timestamp + +(ert-deftest test-chime-org-contacts-format-timestamp-basic () + "Test basic timestamp formatting." + (let ((timestamp (chime-org-contacts--format-timestamp 2025 1 1))) + (should (string-match-p "^<2025-01-01 [A-Za-z]\\{3\\} \\+1y>$" timestamp)))) + +(ert-deftest test-chime-org-contacts-format-timestamp-day-of-week () + "Test that day of week matches the date." + ;; 2025-01-01 is a Wednesday + (let ((timestamp (chime-org-contacts--format-timestamp 2025 1 1))) + (should (string-match-p "Wed" timestamp))) + + ;; 2024-02-29 is a Thursday (leap year) + (let ((timestamp (chime-org-contacts--format-timestamp 2024 2 29))) + (should (string-match-p "Thu" timestamp)))) + +(ert-deftest test-chime-org-contacts-format-timestamp-all-months () + "Test formatting for all months." + (dolist (month '(1 2 3 4 5 6 7 8 9 10 11 12)) + (let ((timestamp (chime-org-contacts--format-timestamp 2025 month 1))) + (should (string-match-p (format "^<2025-%02d-01 [A-Za-z]\\{3\\} \\+1y>$" month) timestamp))))) + +(ert-deftest test-chime-org-contacts-format-timestamp-repeater () + "Test that +1y repeater is always included." + (let ((timestamp (chime-org-contacts--format-timestamp 2025 3 15))) + (should (string-match-p "\\+1y>" timestamp)))) + +;;; Unit Tests - chime-org-contacts--insert-timestamp-after-drawer + +(ert-deftest test-chime-org-contacts-insert-timestamp-when-none-exists () + "Test inserting timestamp when none exists." + (with-temp-buffer + (org-mode) + (insert "* Contact\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert ":END:\n") + (goto-char (point-min)) + + (chime-org-contacts--insert-timestamp-after-drawer "<2000-01-01 Wed +1y>") + + (let ((content (buffer-string))) + (should (string-match-p "<2000-01-01 Wed \\+1y>" content)) + (should (string-match-p ":END:\n<2000-01-01" content))))) + +(ert-deftest test-chime-org-contacts-insert-timestamp-skips-when-exists () + "Test that insertion is skipped when timestamp already exists." + (with-temp-buffer + (org-mode) + (insert "* Contact\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert ":END:\n") + (insert "<2000-01-01 Wed +1y>\n") + (goto-char (point-min)) + + (chime-org-contacts--insert-timestamp-after-drawer "<2000-01-01 Wed +1y>") + + ;; Should have exactly one timestamp + (should (= 1 (how-many "<2000-01-01 [A-Za-z]\\{3\\} \\+1y>" (point-min) (point-max)))))) + +(ert-deftest test-chime-org-contacts-insert-timestamp-handles-whitespace () + "Test handling of whitespace around :END:." + (with-temp-buffer + (org-mode) + (insert "* Contact\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert " :END: \n") ; Whitespace before and after + (goto-char (point-min)) + + (chime-org-contacts--insert-timestamp-after-drawer "<2000-01-01 Wed +1y>") + + (should (string-match-p "<2000-01-01 Wed \\+1y>" (buffer-string))))) + +(ert-deftest test-chime-org-contacts-insert-timestamp-preserves-content () + "Test that insertion doesn't modify other content." + (with-temp-buffer + (org-mode) + (insert "* Contact\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: test@example.com\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert ":END:\n") + (insert "Some notes about the contact.\n") + (goto-char (point-min)) + + (let ((original-content (buffer-substring (point-min) (point-max)))) + (chime-org-contacts--insert-timestamp-after-drawer "<2000-01-01 Wed +1y>") + + (should (string-search ":EMAIL: test@example.com" (buffer-string))) + (should (string-search "Some notes about the contact" (buffer-string)))))) + +(ert-deftest test-chime-org-contacts-insert-timestamp-missing-end () + "Test handling when :END: is missing (malformed drawer)." + (with-temp-buffer + (org-mode) + (insert "* Contact\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + ;; No :END: + (goto-char (point-min)) + + (chime-org-contacts--insert-timestamp-after-drawer "<2000-01-01 Wed +1y>") + + ;; Should not insert when :END: is missing + (should-not (string-match-p "<2000-01-01" (buffer-string))))) + +;;; Integration Tests - chime-org-contacts--finalize-birthday-timestamp + +(ert-deftest test-chime-org-contacts-finalize-adds-timestamp-full-date () + "Test 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") + (goto-char (point-min)) + + (let ((org-capture-plist '(:key "C"))) + (chime-org-contacts--finalize-birthday-timestamp) + + (let ((content (buffer-string))) + (should (string-match-p "<1985-03-15 [A-Za-z]\\{3\\} \\+1y>" content)))))) + +(ert-deftest test-chime-org-contacts-finalize-adds-timestamp-partial-date () + "Test finalize adds timestamp for MM-DD birthday." + (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") + (goto-char (point-min)) + + (let ((org-capture-plist '(:key "C"))) + (chime-org-contacts--finalize-birthday-timestamp) + + (let ((content (buffer-string))) + (should (string-match-p (format "<%d-07-04 [A-Za-z]\\{3\\} \\+1y>" current-year) content))))))) + +(ert-deftest test-chime-org-contacts-finalize-skips-when-no-birthday () + "Test finalize does nothing when :BIRTHDAY: property missing." + (with-temp-buffer + (org-mode) + (insert "* Carol Chen\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: carol@example.com\n") + (insert ":END:\n") + (goto-char (point-min)) + + (let ((original-content (buffer-string)) + (org-capture-plist '(:key "C"))) + (chime-org-contacts--finalize-birthday-timestamp) + + ;; Content should be unchanged + (should (string= (buffer-string) original-content))))) + +(ert-deftest test-chime-org-contacts-finalize-skips-empty-birthday () + "Test finalize skips empty birthday values." + (with-temp-buffer + (org-mode) + (insert "* David Davis\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: \n") + (insert ":END:\n") + (goto-char (point-min)) + + (let ((original-content (buffer-string)) + (org-capture-plist '(:key "C"))) + (chime-org-contacts--finalize-birthday-timestamp) + + (should (string= (buffer-string) original-content))))) + +(ert-deftest test-chime-org-contacts-finalize-only-runs-for-correct-key () + "Test finalize only runs for configured capture key." + (with-temp-buffer + (org-mode) + (insert "* Task\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert ":END:\n") + (goto-char (point-min)) + + (let ((original-content (buffer-string)) + (org-capture-plist '(:key "t"))) ; Different key + (chime-org-contacts--finalize-birthday-timestamp) + + ;; Should not insert timestamp + (should (string= (buffer-string) original-content))))) + +;;; Integration Tests - chime-org-contacts--setup-capture-template + +(ert-deftest test-chime-org-contacts-setup-adds-template-when-file-set () + "Test that template is added when file is set." + (let ((chime-org-contacts-file "/tmp/test-contacts.org") + (org-capture-templates nil)) + + (chime-org-contacts--setup-capture-template) + + (should org-capture-templates) + (should (assoc "C" org-capture-templates)))) + +(ert-deftest test-chime-org-contacts-setup-skips-when-file-nil () + "Test that template is not added when file is nil." + (let ((chime-org-contacts-file nil) + (org-capture-templates nil)) + + (chime-org-contacts--setup-capture-template) + + (should-not org-capture-templates))) + +(ert-deftest test-chime-org-contacts-setup-template-structure () + "Test that added template has correct structure." + (let ((chime-org-contacts-file "/tmp/test-contacts.org") + (chime-org-contacts-capture-key "C") + (chime-org-contacts-heading "Contacts") + (org-capture-templates nil)) + + (chime-org-contacts--setup-capture-template) + + (let ((template (assoc "C" org-capture-templates))) + (should (string= (nth 1 template) "Contact (chime)")) + (should (eq (nth 2 template) 'entry)) + (should (equal (nth 3 template) '(file+headline chime-org-contacts-file "Contacts")))))) + +(ert-deftest test-chime-org-contacts-setup-uses-custom-key () + "Test that template uses custom capture key." + (let ((chime-org-contacts-file "/tmp/test-contacts.org") + (chime-org-contacts-capture-key "K") + (org-capture-templates nil)) + + (chime-org-contacts--setup-capture-template) + + (should (assoc "K" org-capture-templates)) + (should-not (assoc "C" org-capture-templates)))) + +(provide 'test-chime-org-contacts) +;;; test-chime-org-contacts.el ends here diff --git a/tests/test-chime-overdue-todos.el b/tests/test-chime-overdue-todos.el new file mode 100644 index 0000000..ad19d10 --- /dev/null +++ b/tests/test-chime-overdue-todos.el @@ -0,0 +1,403 @@ +;;; test-chime-overdue-todos.el --- Tests for overdue TODO functionality -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Tests for overdue TODO functionality controlled by +;; `chime-show-any-overdue-with-day-wide-alerts'. +;; +;; When enabled (default t): Show overdue TODO items with day-wide alerts +;; When disabled (nil): Only show today's all-day events, not overdue items +;; +;; "Overdue" means events with timestamps in the past (before today). + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Test Helper Functions + +(defun test-overdue--create-event (title timestamp has-time) + "Create test event with TITLE and TIMESTAMP. +HAS-TIME determines if timestamp has time component." + (let* ((parsed-time (when has-time + (apply 'encode-time (org-parse-time-string timestamp)))) + (times (list (cons timestamp parsed-time)))) + `((title . ,title) + (times . ,times) + (intervals . (10))))) + +;;; Setup and Teardown + +(defun test-chime-overdue-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-overdue-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Tests for chime-event-has-any-passed-time + +(ert-deftest test-overdue-has-passed-time-yesterday-all-day () + "Test that all-day event from yesterday is recognized as passed. + +TIME RELATIONSHIPS: + Current time: TODAY at 10:00 AM + Event: YESTERDAY (all-day) + +EXPECTED BEHAVIOR: + Should return t (yesterday is in the past) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (yesterday (test-time-yesterday-at 0 0)) + (yesterday-timestamp (test-timestamp-string yesterday t)) + (event (test-overdue--create-event + "Yesterday Event" + yesterday-timestamp + nil))) ; all-day event + (with-test-time now + (should (chime-event-has-any-passed-time event)))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-has-passed-time-today-all-day () + "Test that all-day event from today is recognized as passed. + +TIME RELATIONSHIPS: + Current time: TODAY at 10:00 AM + Event: TODAY (all-day, no specific time) + +DAY-OF-WEEK REQUIREMENTS: + None - any day of week works + +SPECIAL PROPERTIES: + - All-day event: Yes (no time component) + - Timed event: No + - Repeating: No + - Range: No + +EXPECTED BEHAVIOR: + chime-event-has-any-passed-time should return t because the event + date (today) is not in the future. + +CURRENT IMPLEMENTATION (as of 2025-10-28): + Mock current-time: 2025-10-28 10:00 + Event timestamp: <2025-10-28 Tue> + +REFACTORING NOTES: + Simple case - just needs TODAY timestamp and TODAY current-time. + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (today-timestamp (test-timestamp-string now t)) + (event (test-overdue--create-event + "Today Event" + today-timestamp + nil))) ; all-day event + (with-test-time now + (should (chime-event-has-any-passed-time event)))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-has-passed-time-tomorrow-all-day () + "Test that all-day event from tomorrow is NOT recognized as passed. + +TIME RELATIONSHIPS: + Current time: TODAY at 10:00 AM + Event: TOMORROW (all-day) + +EXPECTED BEHAVIOR: + Should return nil (tomorrow is in the future) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (tomorrow (test-time-tomorrow-at 0 0)) + (tomorrow-timestamp (test-timestamp-string tomorrow t)) + (event (test-overdue--create-event + "Tomorrow Event" + tomorrow-timestamp + nil))) ; all-day event + (with-test-time now + (should-not (chime-event-has-any-passed-time event)))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-has-passed-time-timed-event-past () + "Test that timed event in the past is recognized as passed. + +TIME RELATIONSHIPS: + Current time: TODAY at 14:00 (2pm) + Event: TODAY at 09:00 (9am) - 5 hours ago + +EXPECTED BEHAVIOR: + Should return t (event time has passed) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (past-event (test-time-today-at 9 0)) + (past-timestamp (test-timestamp-string past-event)) + (event (test-overdue--create-event + "Past Meeting" + past-timestamp + t))) ; timed event + (with-test-time now + (should (chime-event-has-any-passed-time event)))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-has-passed-time-timed-event-future () + "Test that timed event in the future is NOT recognized as passed. + +TIME RELATIONSHIPS: + Current time: TODAY at 14:00 (2pm) + Event: TODAY at 16:00 (4pm) - 2 hours from now + +EXPECTED BEHAVIOR: + Should return nil (event time is in future) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (future-event (test-time-today-at 16 0)) + (future-timestamp (test-timestamp-string future-event)) + (event (test-overdue--create-event + "Future Meeting" + future-timestamp + t))) ; timed event + (with-test-time now + (should-not (chime-event-has-any-passed-time event)))) + (test-chime-overdue-teardown))) + +;;; Tests for chime-display-as-day-wide-event with overdue setting + +(ert-deftest test-overdue-display-yesterday-all-day-with-overdue-enabled () + "Test that yesterday's all-day event is displayed when overdue is enabled. + +TIME RELATIONSHIPS: + Current time: TODAY at 10:00 AM + Event: YESTERDAY (all-day) + Setting: chime-show-any-overdue-with-day-wide-alerts = t + +EXPECTED BEHAVIOR: + Should display (overdue enabled shows past events) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (yesterday (test-time-yesterday-at 0 0)) + (yesterday-timestamp (test-timestamp-string yesterday t)) + (chime-show-any-overdue-with-day-wide-alerts t) + (chime-day-wide-advance-notice nil) + (event (test-overdue--create-event + "Yesterday Birthday" + yesterday-timestamp + nil))) + (with-test-time now + (should (chime-display-as-day-wide-event event)))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-display-yesterday-all-day-with-overdue-disabled () + "Test that yesterday's all-day event is NOT displayed when overdue is disabled. +This prevents showing old birthdays/holidays from the past. + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (yesterday (test-time-yesterday-at 0 0)) + (yesterday-timestamp (test-timestamp-string yesterday t)) + (chime-show-any-overdue-with-day-wide-alerts nil) + (chime-day-wide-advance-notice nil) + (event (test-overdue--create-event + "Yesterday Birthday" + yesterday-timestamp + nil))) + (with-test-time now + (should-not (chime-display-as-day-wide-event event)))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-display-yesterday-timed-with-overdue-enabled () + "Test that yesterday's timed event is displayed when overdue is enabled. + +TIME: TODAY 10am, Event: YESTERDAY 2pm, overdue=t +EXPECTED: Display (show past timed events when enabled) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (yesterday (test-time-yesterday-at 14 0)) + (yesterday-timestamp (test-timestamp-string yesterday)) + (chime-show-any-overdue-with-day-wide-alerts t) + (chime-day-wide-advance-notice nil) + (event (test-overdue--create-event + "Yesterday Meeting" + yesterday-timestamp + t))) + (with-test-time now + (should (chime-display-as-day-wide-event event)))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-display-yesterday-timed-with-overdue-disabled () + "Test that yesterday's timed event is NOT displayed when overdue is disabled. + +TIME: TODAY 10am, Event: YESTERDAY 2pm, overdue=nil +EXPECTED: Hide (don't show past timed events when disabled) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (yesterday (test-time-yesterday-at 14 0)) + (yesterday-timestamp (test-timestamp-string yesterday)) + (chime-show-any-overdue-with-day-wide-alerts nil) + (chime-day-wide-advance-notice nil) + (event (test-overdue--create-event + "Yesterday Meeting" + yesterday-timestamp + t))) + (with-test-time now + (should-not (chime-display-as-day-wide-event event)))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-display-today-all-day-always-shown () + "Test that today's all-day event is always displayed regardless of overdue setting. + +TIME: TODAY 10am, Event: TODAY (all-day), both overdue=t and =nil +EXPECTED: Always display (today's events shown regardless of setting) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (today-timestamp (test-timestamp-string now t)) + (chime-day-wide-advance-notice nil) + (event (test-overdue--create-event + "Today Birthday" + today-timestamp + nil))) + (with-test-time now + ;; Should show with overdue enabled + (let ((chime-show-any-overdue-with-day-wide-alerts t)) + (should (chime-display-as-day-wide-event event))) + ;; Should also show with overdue disabled (it's today, not overdue) + (let ((chime-show-any-overdue-with-day-wide-alerts nil)) + (should (chime-display-as-day-wide-event event))))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-display-week-old-all-day-with-overdue-enabled () + "Test that week-old all-day event is displayed when overdue is enabled. + +TIME: TODAY (Oct 28), Event: 7 DAYS AGO (Oct 21), overdue=t +EXPECTED: Display (show old events when enabled) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (week-ago (test-time-days-ago 7)) + (week-ago-timestamp (test-timestamp-string week-ago t)) + (chime-show-any-overdue-with-day-wide-alerts t) + (chime-day-wide-advance-notice nil) + (event (test-overdue--create-event + "Week Old Event" + week-ago-timestamp + nil))) + (with-test-time now + (should (chime-display-as-day-wide-event event)))) + (test-chime-overdue-teardown))) + +(ert-deftest test-overdue-display-week-old-all-day-with-overdue-disabled () + "Test that week-old all-day event is NOT displayed when overdue is disabled. +This prevents showing old birthdays/holidays from past weeks. + +TIME: TODAY (Oct 28), Event: 7 DAYS AGO (Oct 21), overdue=nil +EXPECTED: Hide (prevent old birthday spam) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (week-ago (test-time-days-ago 7)) + (week-ago-timestamp (test-timestamp-string week-ago t)) + (chime-show-any-overdue-with-day-wide-alerts nil) + (chime-day-wide-advance-notice nil) + (event (test-overdue--create-event + "Week Old Event" + week-ago-timestamp + nil))) + (with-test-time now + (should-not (chime-display-as-day-wide-event event)))) + (test-chime-overdue-teardown))) + +;;; Tests verifying overdue doesn't affect future events + +(ert-deftest test-overdue-future-event-not-affected-by-overdue-setting () + "Test that future events are not affected by overdue setting. + +TIME: TODAY (Oct 28), Event: 2 DAYS FROM NOW (Oct 30), both overdue settings +EXPECTED: Never display (future events not shown without advance notice) + +REFACTORED: Uses dynamic timestamps via testutil-time.el" + (test-chime-overdue-setup) + (unwind-protect + (let* ((now (test-time-now)) + (future (test-time-days-from-now 2)) + (future-timestamp (test-timestamp-string future t)) + (chime-day-wide-advance-notice nil) + (event (test-overdue--create-event + "Future Event" + future-timestamp + nil))) + (with-test-time now + ;; Should NOT show with overdue enabled (it's future, not today) + (let ((chime-show-any-overdue-with-day-wide-alerts t)) + (should-not (chime-display-as-day-wide-event event))) + ;; Should NOT show with overdue disabled (it's future, not today) + (let ((chime-show-any-overdue-with-day-wide-alerts nil)) + (should-not (chime-display-as-day-wide-event event))))) + (test-chime-overdue-teardown))) + +(provide 'test-chime-overdue-todos) +;;; test-chime-overdue-todos.el ends here diff --git a/tests/test-chime-process-notifications.el b/tests/test-chime-process-notifications.el new file mode 100644 index 0000000..8f0a829 --- /dev/null +++ b/tests/test-chime-process-notifications.el @@ -0,0 +1,344 @@ +;;; test-chime-process-notifications.el --- Tests for chime--process-notifications -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--process-notifications function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-process-notifications-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-process-notifications-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-process-notifications-normal-single-event-calls-notify () + "Test that single event with notification calls chime--notify. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-process-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at 14:10 (10 minutes from now) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time)) + (notify-called nil) + (notify-messages '())) + (with-test-time now + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) + (setq notify-called t) + (push msg notify-messages))) + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Team Meeting") + (intervals . ((10 . medium))))) + (events (list event))) + (chime--process-notifications events) + ;; Should call notify + (should notify-called) + (should (= 1 (length notify-messages))) + (should (string-match-p "Team Meeting" (caar notify-messages))))))) + (test-chime-process-notifications-teardown))) + +(ert-deftest test-chime-process-notifications-normal-multiple-events-calls-notify-multiple-times () + "Test that multiple events with notifications call chime--notify multiple times. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-process-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event 1 at 14:10 + (event-time-1 (test-time-today-at 14 10)) + (timestamp-str-1 (test-timestamp-string event-time-1)) + ;; Event 2 at 14:05 + (event-time-2 (test-time-today-at 14 5)) + (timestamp-str-2 (test-timestamp-string event-time-2)) + (notify-count 0)) + (with-test-time now + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) (setq notify-count (1+ notify-count)))) + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil))) + (let* ((event1 `((times . ((,timestamp-str-1 . ,event-time-1))) + (title . "Meeting 1") + (intervals . ((10 . medium))))) + (event2 `((times . ((,timestamp-str-2 . ,event-time-2))) + (title . "Meeting 2") + (intervals . ((5 . medium))))) + (events (list event1 event2))) + (chime--process-notifications events) + ;; Should call notify twice (once per event) + (should (= 2 notify-count)))))) + (test-chime-process-notifications-teardown))) + +(ert-deftest test-chime-process-notifications-normal-deduplication-removes-duplicates () + "Test that duplicate notification messages are deduplicated. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-process-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Two events with same title and time - should dedupe + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time)) + (notify-messages '())) + (with-test-time now + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) (push msg notify-messages))) + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil))) + (let* ((event1 `((times . ((,timestamp-str . ,event-time))) + (title . "Team Meeting") + (intervals . ((10 . medium))))) + (event2 `((times . ((,timestamp-str . ,event-time))) + (title . "Team Meeting") + (intervals . ((10 . medium))))) + (events (list event1 event2))) + (chime--process-notifications events) + ;; Should only call notify once due to deduplication + (should (= 1 (length notify-messages))))))) + (test-chime-process-notifications-teardown))) + +(ert-deftest test-chime-process-notifications-normal-day-wide-notifications-called-at-right-time () + "Test that day-wide notifications are sent when current time matches. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-process-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 9 0)) + ;; Day-wide event + (event-time (test-time-today-at 0 0)) + (timestamp-str (test-timestamp-string event-time t)) ; Day-wide + (notify-count 0)) + (with-test-time now + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) (setq notify-count (1+ notify-count)))) + ;; Mock day-wide time to return true + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () t)) + ((symbol-function 'chime-day-wide-notifications) + (lambda (events) (list "Day-wide alert")))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "All Day Event") + (intervals . ()))) + (events (list event))) + (chime--process-notifications events) + ;; Should call notify at least once for day-wide + (should (>= notify-count 1)))))) + (test-chime-process-notifications-teardown))) + +(ert-deftest test-chime-process-notifications-normal-no-day-wide-when-wrong-time () + "Test that day-wide notifications are not sent when time doesn't match. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-process-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 0 0)) + (timestamp-str (test-timestamp-string event-time t)) ; Day-wide + (day-wide-called nil)) + (with-test-time now + (cl-letf (((symbol-function 'chime--notify) (lambda (msg) nil)) + ;; Mock day-wide time to return false + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil)) + ((symbol-function 'chime-day-wide-notifications) + (lambda (events) + (setq day-wide-called t) + '()))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "All Day Event") + (intervals . ()))) + (events (list event))) + (chime--process-notifications events) + ;; Day-wide function should not be called + (should-not day-wide-called))))) + (test-chime-process-notifications-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-process-notifications-boundary-empty-events-no-notifications () + "Test that empty events list produces no notifications. + +REFACTORED: No timestamps used" + (test-chime-process-notifications-setup) + (unwind-protect + (let ((notify-called nil)) + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) (setq notify-called t))) + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil))) + (let ((events '())) + (chime--process-notifications events) + ;; Should not call notify + (should-not notify-called)))) + (test-chime-process-notifications-teardown))) + +(ert-deftest test-chime-process-notifications-boundary-events-with-no-matches-no-notifications () + "Test that events with no matching notifications don't call notify. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-process-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at 15:00 (60 minutes away, doesn't match 10 min interval) + (event-time (test-time-today-at 15 0)) + (timestamp-str (test-timestamp-string event-time)) + (notify-called nil)) + (with-test-time now + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) (setq notify-called t))) + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Future Event") + (intervals . ((10 . medium))))) + (events (list event))) + (chime--process-notifications events) + ;; Should not call notify + (should-not notify-called))))) + (test-chime-process-notifications-teardown))) + +(ert-deftest test-chime-process-notifications-boundary-single-event-edge-case () + "Test processing single event works correctly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-process-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time)) + (notify-count 0)) + (with-test-time now + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) (setq notify-count (1+ notify-count)))) + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Single Event") + (intervals . ((10 . medium))))) + (events (list event))) + (chime--process-notifications events) + ;; Should call notify exactly once + (should (= 1 notify-count)))))) + (test-chime-process-notifications-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-process-notifications-error-nil-events-handles-gracefully () + "Test that nil events parameter doesn't crash. + +REFACTORED: No timestamps used" + (test-chime-process-notifications-setup) + (unwind-protect + (let ((notify-called nil)) + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) (setq notify-called t))) + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil))) + ;; Should not error with nil events + (should-not (condition-case nil + (progn (chime--process-notifications nil) nil) + (error t))) + ;; Should not call notify + (should-not notify-called))) + (test-chime-process-notifications-teardown))) + +(ert-deftest test-chime-process-notifications-error-invalid-event-structure-handles-gracefully () + "Test that invalid event structure doesn't crash. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-process-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (notify-called nil)) + (with-test-time now + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) (setq notify-called t))) + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil))) + (let* (;; Invalid event: missing required fields + (events (list '((invalid . "structure"))))) + ;; Should not crash even with invalid events + (should-not (condition-case nil + (progn (chime--process-notifications events) nil) + (error t))))))) + (test-chime-process-notifications-teardown))) + +(ert-deftest test-chime-process-notifications-error-mixed-valid-invalid-events-processes-valid () + "Test that mix of valid and invalid events processes valid ones. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-process-notifications-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Valid event + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time)) + (notify-count 0)) + (with-test-time now + (cl-letf (((symbol-function 'chime--notify) + (lambda (msg) (setq notify-count (1+ notify-count)))) + ((symbol-function 'chime-current-time-is-day-wide-time) + (lambda () nil))) + (let* ((valid-event `((times . ((,timestamp-str . ,event-time))) + (title . "Valid Event") + (intervals . ((10 . medium))))) + ;; Invalid event + (invalid-event '((invalid . "data"))) + (events (list valid-event invalid-event))) + ;; Should not crash + (should-not (condition-case nil + (progn (chime--process-notifications events) nil) + (error t))) + ;; Should process at least the valid event + (should (>= notify-count 1)))))) + (test-chime-process-notifications-teardown))) + +(provide 'test-chime-process-notifications) +;;; test-chime-process-notifications.el ends here diff --git a/tests/test-chime-sanitize-title.el b/tests/test-chime-sanitize-title.el new file mode 100644 index 0000000..8329abd --- /dev/null +++ b/tests/test-chime-sanitize-title.el @@ -0,0 +1,402 @@ +;;; test-chime-sanitize-title.el --- Tests for chime--sanitize-title -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--sanitize-title function. +;; Tests cover: +;; - Unmatched opening delimiters (parentheses, brackets, braces) +;; - Unmatched closing delimiters +;; - Mixed unmatched delimiters +;; - Already balanced delimiters (no-op) +;; - Nil and empty strings +;; - Real-world bug cases that triggered the issue + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-sanitize-title-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-sanitize-title-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases - Already Balanced + +(ert-deftest test-chime-sanitize-title-balanced-parens-unchanged () + "Test that balanced parentheses are unchanged. + +REFACTORED: No timestamps used" + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting (Team Sync)") + (result (chime--sanitize-title title))) + (should (string-equal title result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-balanced-brackets-unchanged () + "Test that balanced brackets are unchanged. + +REFACTORED: No timestamps used" + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Review [PR #123]") + (result (chime--sanitize-title title))) + (should (string-equal title result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-balanced-braces-unchanged () + "Test that balanced braces are unchanged. + +REFACTORED: No timestamps used" + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Code Review {urgent}") + (result (chime--sanitize-title title))) + (should (string-equal title result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-mixed-balanced-unchanged () + "Test that mixed balanced delimiters are unchanged. + +REFACTORED: No timestamps used" + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting [Team] (Sync) {Urgent}") + (result (chime--sanitize-title title))) + (should (string-equal title result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-nested-balanced-unchanged () + "Test that nested balanced delimiters are unchanged. + +REFACTORED: No timestamps used" + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Review (PR [#123] {urgent})") + (result (chime--sanitize-title title))) + (should (string-equal title result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-no-delimiters-unchanged () + "Test that titles without delimiters are unchanged. + +REFACTORED: No timestamps used" + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Simple Meeting Title") + (result (chime--sanitize-title title))) + (should (string-equal title result))) + (test-chime-sanitize-title-teardown))) + +;;; Unmatched Opening Delimiters + +(ert-deftest test-chime-sanitize-title-unmatched-opening-paren () + "Test that unmatched opening parenthesis is closed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "CTO/COO XLT (Extended Leadership") + (result (chime--sanitize-title title))) + (should (string-equal "CTO/COO XLT (Extended Leadership)" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-unmatched-opening-paren-at-end () + "Test that unmatched opening parenthesis at end is closed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Spice Cake (") + (result (chime--sanitize-title title))) + (should (string-equal "Spice Cake ()" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-multiple-unmatched-opening-parens () + "Test that multiple unmatched opening parentheses are closed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting (Team (Sync") + (result (chime--sanitize-title title))) + (should (string-equal "Meeting (Team (Sync))" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-unmatched-opening-bracket () + "Test that unmatched opening bracket is closed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Review [PR #123") + (result (chime--sanitize-title title))) + (should (string-equal "Review [PR #123]" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-unmatched-opening-brace () + "Test that unmatched opening brace is closed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Code Review {urgent") + (result (chime--sanitize-title title))) + (should (string-equal "Code Review {urgent}" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-mixed-unmatched-opening-delimiters () + "Test that mixed unmatched opening delimiters are all closed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting [Team (Sync {Urgent") + (result (chime--sanitize-title title))) + (should (string-equal "Meeting [Team (Sync {Urgent})]" result))) + (test-chime-sanitize-title-teardown))) + +;;; Unmatched Closing Delimiters + +(ert-deftest test-chime-sanitize-title-unmatched-closing-paren () + "Test that unmatched closing parenthesis is removed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting Title)") + (result (chime--sanitize-title title))) + (should (string-equal "Meeting Title" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-multiple-unmatched-closing-parens () + "Test that multiple unmatched closing parentheses are removed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting Title))") + (result (chime--sanitize-title title))) + (should (string-equal "Meeting Title" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-unmatched-closing-bracket () + "Test that unmatched closing bracket is removed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Review PR]") + (result (chime--sanitize-title title))) + (should (string-equal "Review PR" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-unmatched-closing-brace () + "Test that unmatched closing brace is removed." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Code Review}") + (result (chime--sanitize-title title))) + (should (string-equal "Code Review" result))) + (test-chime-sanitize-title-teardown))) + +;;; Complex Mixed Cases + +(ert-deftest test-chime-sanitize-title-opening-and-closing-mixed () + "Test title with both unmatched opening and closing delimiters." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting (Team) Extra)") + (result (chime--sanitize-title title))) + ;; Should remove the extra closing paren + (should (string-equal "Meeting (Team) Extra" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-complex-nesting-with-unmatched () + "Test complex nested delimiters with some unmatched." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting [Team (Sync] Extra") + (result (chime--sanitize-title title))) + ;; The ']' doesn't match the '[' (because '(' is in between) + ;; So it's removed, and we close the '(' and '[' properly: ')' and ']' + (should (string-equal "Meeting [Team (Sync Extra)]" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-all-types-unmatched () + "Test with all three delimiter types unmatched." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting (Team [Project {Status") + (result (chime--sanitize-title title))) + (should (string-equal "Meeting (Team [Project {Status}])" result))) + (test-chime-sanitize-title-teardown))) + +;;; Edge Cases + +(ert-deftest test-chime-sanitize-title-nil-returns-empty-string () + "Test that nil title returns empty string." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((result (chime--sanitize-title nil))) + (should (string-equal "" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-empty-string-unchanged () + "Test that empty string is unchanged." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "") + (result (chime--sanitize-title title))) + (should (string-equal "" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-only-opening-delimiters () + "Test title with only opening delimiters." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "([{") + (result (chime--sanitize-title title))) + (should (string-equal "([{}])" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-only-closing-delimiters () + "Test title with only closing delimiters." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title ")]}") + (result (chime--sanitize-title title))) + (should (string-equal "" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-very-long-title-with-unmatched () + "Test very long title with unmatched delimiter." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "This is a very long meeting title that contains many words and might wrap in the notification display (Extended Info") + (result (chime--sanitize-title title))) + (should (string-equal "This is a very long meeting title that contains many words and might wrap in the notification display (Extended Info)" result))) + (test-chime-sanitize-title-teardown))) + +;;; Real-World Bug Cases + +(ert-deftest test-chime-sanitize-title-bug-case-extended-leadership () + "Test the actual bug case from vineti.meetings.org." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "1:01pm CTO/COO XLT (Extended Leadership") + (result (chime--sanitize-title title))) + (should (string-equal "1:01pm CTO/COO XLT (Extended Leadership)" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-bug-case-spice-cake () + "Test the actual bug case from journal/2023-11-22.org." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Spice Cake (") + (result (chime--sanitize-title title))) + (should (string-equal "Spice Cake ()" result))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-lisp-serialization-safety () + "Test that sanitized title can be safely read by Lisp reader." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((title "Meeting (Team Sync") + (sanitized (chime--sanitize-title title)) + ;; Simulate what happens in async serialization + (serialized (format "'((title . \"%s\"))" sanitized))) + ;; This should not signal an error + (should (listp (read serialized))) + (should (string-equal "Meeting (Team Sync)" sanitized))) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-sanitize-title-async-serialization-with-unmatched-parens () + "Test that titles with unmatched parens won't break async serialization." + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((problematic-titles '("Meeting (Team" + "Review [PR" + "Code {Status" + "Event ((" + "Task ))"))) + (dolist (title problematic-titles) + (let* ((sanitized (chime--sanitize-title title)) + (serialized (format "'((title . \"%s\"))" sanitized))) + ;; Should not signal 'invalid-read-syntax error + (should (listp (read serialized)))))) + (test-chime-sanitize-title-teardown))) + +;;; Integration with chime--extract-title + +(ert-deftest test-chime-extract-title-sanitizes-output () + "Test that chime--extract-title applies sanitization. + +REFACTORED: Uses dynamic timestamps" + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO Meeting (Team Sync\n%s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) ; Enable org-mode + (goto-char (point-min)) + ;; Search for the heading + (re-search-forward "^\\* TODO" nil t) + (beginning-of-line) + (let* ((marker (point-marker)) + (title (chime--extract-title marker))) + ;; Should be sanitized with closing paren added + (should (string-equal "Meeting (Team Sync)" title)))) + (kill-buffer test-buffer)) + (test-chime-sanitize-title-teardown))) + +(ert-deftest test-chime-extract-title-handles-nil () + "Test that chime--extract-title handles nil gracefully. + +REFACTORED: Uses dynamic timestamps" + (test-chime-sanitize-title-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (test-timestamp-string time)) + (test-file (chime-create-temp-test-file-with-content + (format "* TODO\n%s\n" timestamp))) + (test-buffer (find-file-noselect test-file))) + (with-current-buffer test-buffer + (org-mode) ; Enable org-mode + (goto-char (point-min)) + ;; Search for the heading + (re-search-forward "^\\* TODO" nil t) + (beginning-of-line) + (let* ((marker (point-marker)) + (title (chime--extract-title marker))) + ;; Should return empty string for nil title + (should (string-equal "" title)))) + (kill-buffer test-buffer)) + (test-chime-sanitize-title-teardown))) + +(provide 'test-chime-sanitize-title) +;;; test-chime-sanitize-title.el ends here diff --git a/tests/test-chime-time-left.el b/tests/test-chime-time-left.el new file mode 100644 index 0000000..565ec4b --- /dev/null +++ b/tests/test-chime-time-left.el @@ -0,0 +1,305 @@ +;;; test-chime-time-left.el --- Tests for chime--time-left -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--time-left function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) + +;;; Setup and Teardown + +(defun test-chime-time-left-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset format strings to defaults + (setq chime-time-left-format-at-event "right now") + (setq chime-time-left-format-short "in %M") + (setq chime-time-left-format-long "in %H %M")) + +(defun test-chime-time-left-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-time-left-one-minute-formats-correctly () + "Test that 1 minute formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 60))) + (should (stringp result)) + (should (string-match-p "in 1 minute" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-five-minutes-formats-correctly () + "Test that 5 minutes formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 300))) + (should (stringp result)) + (should (string-match-p "in 5 minutes" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-ten-minutes-formats-correctly () + "Test that 10 minutes formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 600))) + (should (stringp result)) + (should (string-match-p "in 10 minutes" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-thirty-minutes-formats-correctly () + "Test that 30 minutes formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 1800))) + (should (stringp result)) + (should (string-match-p "in 30 minutes" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-one-hour-formats-correctly () + "Test that 1 hour formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 3600))) + (should (stringp result)) + ;; At exactly 1 hour (3600s), still shows minutes format + (should (string-match-p "in 60 minutes" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-two-hours-formats-correctly () + "Test that 2 hours formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 7200))) + (should (stringp result)) + (should (string-match-p "in 2 hours" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-one-hour-thirty-minutes-formats-correctly () + "Test that 1 hour 30 minutes formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 5400))) + (should (stringp result)) + ;; Should show both hours and minutes + (should (string-match-p "in 1 hour 30 minutes" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-three-hours-fifteen-minutes-formats-correctly () + "Test that 3 hours 15 minutes formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 11700))) + (should (stringp result)) + (should (string-match-p "in 3 hours 15 minutes" result))) + (test-chime-time-left-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-time-left-zero-seconds-returns-right-now () + "Test that 0 seconds returns 'right now'." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 0))) + (should (stringp result)) + (should (string-equal "right now" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-one-second-shows-right-now () + "Test that 1 second shows 'right now'." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 1))) + (should (stringp result)) + ;; Less than a minute, but format-seconds might show "in 0 minutes" + ;; or the implementation might handle this specially + (should result)) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-fifty-nine-seconds-shows-minutes () + "Test that 59 seconds shows in minutes format." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 59))) + (should (stringp result)) + ;; Should use minutes format (< 1 hour) + (should (string-match-p "in" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-exactly-one-hour-shows-minutes-format () + "Test that exactly 1 hour shows minutes format." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 3600))) + (should (stringp result)) + ;; At exactly 3600s, still uses minutes format (boundary case) + (should (string-match-p "in 60 minutes" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-fifty-nine-minutes-shows-minutes-only () + "Test that 59 minutes shows minutes format only." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 3540))) ; 59 minutes + (should (stringp result)) + (should (string-match-p "in 59 minutes" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-twenty-four-hours-formats-correctly () + "Test that 24 hours formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 86400))) + (should (stringp result)) + (should (string-match-p "in 24 hours" result))) + (test-chime-time-left-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-time-left-negative-value-returns-right-now () + "Test that negative value returns 'right now'." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left -60))) + (should (stringp result)) + (should (string-equal "right now" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-large-negative-returns-right-now () + "Test that large negative value returns 'right now'." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left -3600))) + (should (stringp result)) + (should (string-equal "right now" result))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-very-large-value-formats-correctly () + "Test that very large value (1 week) formats correctly." + (test-chime-time-left-setup) + (unwind-protect + (let ((result (chime--time-left 604800))) ; 1 week + (should (stringp result)) + ;; Should format with days/hours + (should (string-match-p "in" result))) + (test-chime-time-left-teardown))) + +;;; Custom Format Cases + +(ert-deftest test-chime-time-left-custom-compact-format-short () + "Test custom compact format for short duration (in 5m)." + (test-chime-time-left-setup) + (unwind-protect + (progn + (setq chime-time-left-format-short "in %mm") + (let ((result (chime--time-left 300))) ; 5 minutes + (should (stringp result)) + (should (string-equal "in 5m" result)))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-custom-compact-format-long () + "Test custom compact format for long duration (in 1h 37m)." + (test-chime-time-left-setup) + (unwind-protect + (progn + (setq chime-time-left-format-long "in %hh %mm") + (let ((result (chime--time-left 5820))) ; 1 hour 37 minutes + (should (stringp result)) + (should (string-equal "in 1h 37m" result)))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-custom-parentheses-format () + "Test custom format with parentheses ((1 hr 37 min))." + (test-chime-time-left-setup) + (unwind-protect + (progn + (setq chime-time-left-format-long "(%h hr %m min)") + (let ((result (chime--time-left 5820))) ; 1 hour 37 minutes + (should (stringp result)) + (should (string-equal "(1 hr 37 min)" result)))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-custom-no-prefix-format () + "Test custom format without 'in' prefix (1h37m)." + (test-chime-time-left-setup) + (unwind-protect + (progn + (setq chime-time-left-format-long "%hh%mm") + (let ((result (chime--time-left 5820))) ; 1 hour 37 minutes + (should (stringp result)) + (should (string-equal "1h37m" result)))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-custom-at-event-message () + "Test custom at-event message (NOW!)." + (test-chime-time-left-setup) + (unwind-protect + (progn + (setq chime-time-left-format-at-event "NOW!") + (let ((result (chime--time-left 0))) + (should (stringp result)) + (should (string-equal "NOW!" result)))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-custom-short-with-unit-text () + "Test custom short format with custom unit text (5 min)." + (test-chime-time-left-setup) + (unwind-protect + (progn + (setq chime-time-left-format-short "%m min") + (let ((result (chime--time-left 300))) ; 5 minutes + (should (stringp result)) + (should (string-equal "5 min" result)))) + (test-chime-time-left-teardown))) + +(ert-deftest test-chime-time-left-custom-emoji-format () + "Test custom format with emoji (🕐 1h37m)." + (test-chime-time-left-setup) + (unwind-protect + (progn + (setq chime-time-left-format-long "🕐 %hh%mm") + (let ((result (chime--time-left 5820))) ; 1 hour 37 minutes + (should (stringp result)) + (should (string-equal "🕐 1h37m" result)))) + (test-chime-time-left-teardown))) + +(provide 'test-chime-time-left) +;;; test-chime-time-left.el ends here diff --git a/tests/test-chime-timestamp-parse.el b/tests/test-chime-timestamp-parse.el new file mode 100644 index 0000000..32cdace --- /dev/null +++ b/tests/test-chime-timestamp-parse.el @@ -0,0 +1,413 @@ +;;; test-chime-timestamp-parse.el --- Tests for chime--timestamp-parse -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--timestamp-parse function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-timestamp-parse-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-timestamp-parse-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-timestamp-parse-standard-timestamp-returns-time-list () + "Test that a standard timestamp with time component returns a time list. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 30)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + ;; Should return a time list (list of integers) + (should (listp result)) + (should (= (length result) 2)) + (should (integerp (car result))) + (should (integerp (cadr result))) + ;; Result should not be nil + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-scheduled-timestamp-returns-time-list () + "Test that a SCHEDULED timestamp parses correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 9 0)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-deadline-timestamp-returns-time-list () + "Test that a DEADLINE timestamp parses correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 17 0)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-timestamp-with-weekly-repeater-returns-time-list () + "Test that a timestamp with +1w repeater parses correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (format-time-string "<%Y-%m-%d %a %H:%M +1w>" time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-timestamp-with-completion-repeater-returns-time-list () + "Test that a timestamp with .+1d repeater parses correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 8 0)) + (timestamp (format-time-string "<%Y-%m-%d %a %H:%M .+1d>" time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-timestamp-with-catchup-repeater-returns-time-list () + "Test that a timestamp with ++1w repeater parses correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 10 30)) + (timestamp (format-time-string "<%Y-%m-%d %a %H:%M ++1w>" time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-timestamp-with-time-range-returns-start-time () + "Test that a timestamp with time range returns the start time. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 14 0)) + (timestamp (format-time-string "<%Y-%m-%d %a %H:%M-15:30>" time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-timestamp-with-date-range-returns-start-date () + "Test that a timestamp with date range returns start date time. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time1 (test-time-tomorrow-at 10 0)) + (time2 (test-time-days-from-now 2)) + (timestamp (concat (test-timestamp-string time1) "--" + (format-time-string "<%Y-%m-%d %a %H:%M>" time2))) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-timestamp-parse-midnight-timestamp-returns-time-list () + "Test that midnight (00:00) timestamp parses correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 0 0)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-last-minute-of-day-returns-time-list () + "Test that last minute of day (23:59) timestamp parses correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 23 59)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-year-boundary-new-years-eve-returns-time-list () + "Test that New Year's Eve timestamp parses correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((now (test-time-now)) + (decoded (decode-time now)) + (year (nth 5 decoded)) + ;; Create Dec 31 at 23:30 for current test year + (time (encode-time 0 30 23 31 12 year)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-year-boundary-new-years-day-returns-time-list () + "Test that New Year's Day timestamp parses correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((now (test-time-now)) + (decoded (decode-time now)) + (year (1+ (nth 5 decoded))) ; Next year + ;; Create Jan 1 at 00:30 for next test year + (time (encode-time 0 30 0 1 1 year)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-single-digit-time-returns-time-list () + "Test that single-digit hours and minutes parse correctly. + +REFACTORED: Uses dynamic timestamps" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((time (test-time-tomorrow-at 1 5)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-leap-year-feb-29-returns-time-list () + "Test that Feb 29 in leap year parses correctly. + +REFACTORED: Uses dynamic timestamps (2024 leap year)" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* (;; Use 2024 as a known leap year + (time (encode-time 0 0 14 29 2 2024)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-month-boundary-end-of-month-returns-time-list () + "Test that end of month timestamp parses correctly. + +REFACTORED: Uses dynamic timestamps (Oct 31)" + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((now (test-time-now)) + (decoded (decode-time now)) + (year (nth 5 decoded)) + ;; Create Oct 31 at 14:00 for current test year + (time (encode-time 0 0 14 31 10 year)) + (timestamp (test-timestamp-string time)) + (result (chime--timestamp-parse timestamp))) + (should (listp result)) + (should (= (length result) 2)) + (should result)) + (test-chime-timestamp-parse-teardown))) + +;;; Bug Reproduction Tests + +(ert-deftest test-chime-timestamp-parse-tomorrow-timestamp-returns-correct-date () + "Test that a tomorrow timestamp is parsed as tomorrow, not today. +This reproduces the bug where timestamps like <2025-11-03 Mon 10:00-10:30> +on Nov 02 are incorrectly grouped as 'Today' instead of 'Tomorrow'." + (test-chime-timestamp-parse-setup) + (unwind-protect + (with-test-time (encode-time 0 23 11 2 11 2025) ; Nov 02, 2025 11:23:00 AM + (let* ((tomorrow-timestamp "<2025-11-03 Mon 10:00-10:30>") + (parsed (chime--timestamp-parse tomorrow-timestamp)) + (now (current-time))) + ;; Should parse successfully + (should parsed) + ;; Convert parsed time (HIGH LOW) to full time by appending (0 0) + (let* ((parsed-time (append parsed '(0 0))) + (parsed-decoded (decode-time parsed-time)) + (time-diff-seconds (- (time-to-seconds parsed-time) + (time-to-seconds now)))) + ;; Verify the parsed date is Nov 03, 2025 (not Nov 02!) + (should (= 3 (decoded-time-day parsed-decoded))) + (should (= 11 (decoded-time-month parsed-decoded))) + (should (= 2025 (decoded-time-year parsed-decoded))) + ;; Verify the parsed time is 10:00 + (should (= 10 (decoded-time-hour parsed-decoded))) + (should (= 0 (decoded-time-minute parsed-decoded))) + ;; Time difference should be ~22h 37m (81420 seconds) + (should (> time-diff-seconds 81360)) ; At least 22h 36m + (should (< time-diff-seconds 81480))))) ; At most 22h 38m + (test-chime-timestamp-parse-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-timestamp-parse-empty-string-returns-nil () + "Test that empty string returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp "") + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-nil-input-returns-nil () + "Test that nil input returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp nil) + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-missing-opening-bracket-returns-nil () + "Test that timestamp missing opening bracket returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp "2025-10-24 Fri 14:00>") + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-missing-closing-bracket-returns-nil () + "Test that timestamp missing closing bracket returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp "<2025-10-24 Fri 14:00") + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-invalid-date-format-returns-nil () + "Test that invalid date format returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp "<10-24-2025 Fri 14:00>") + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-invalid-month-returns-nil () + "Test that invalid month value returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp "<2025-13-24 Fri 14:00>") + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-invalid-day-returns-nil () + "Test that invalid day value returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp "<2025-10-32 Fri 14:00>") + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-invalid-time-hour-returns-nil () + "Test that invalid hour value returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp "<2025-10-24 Fri 25:00>") + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-invalid-time-minute-returns-nil () + "Test that invalid minute value returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp "<2025-10-24 Fri 14:60>") + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(ert-deftest test-chime-timestamp-parse-date-only-no-time-returns-nil () + "Test that day-wide timestamp without time returns nil." + (test-chime-timestamp-parse-setup) + (unwind-protect + (let* ((timestamp "<2025-10-24 Fri>") + (result (chime--timestamp-parse timestamp))) + (should (null result))) + (test-chime-timestamp-parse-teardown))) + +(provide 'test-chime-timestamp-parse) +;;; test-chime-timestamp-parse.el ends here diff --git a/tests/test-chime-timestamp-within-interval-p.el b/tests/test-chime-timestamp-within-interval-p.el new file mode 100644 index 0000000..bf67dc2 --- /dev/null +++ b/tests/test-chime-timestamp-within-interval-p.el @@ -0,0 +1,325 @@ +;;; test-chime-timestamp-within-interval-p.el --- Tests for chime--timestamp-within-interval-p -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--timestamp-within-interval-p function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-timestamp-within-interval-p-setup () + "Setup function run before each test." + (chime-create-test-base-dir)) + +(defun test-chime-timestamp-within-interval-p-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-chime-timestamp-within-interval-p-exactly-at-interval-returns-t () + "Test that timestamp exactly at interval returns t. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Timestamp at 14:10 (10 minutes from 14:00) + (timestamp (test-time-today-at 14 10)) + (interval 10)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-zero-interval-returns-t () + "Test that zero interval (notify now) returns t for current time. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 30)) + ;; Timestamp at exactly current time (14:30) + (timestamp (test-time-today-at 14 30)) + (interval 0)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-five-minutes-returns-t () + "Test that 5-minute interval works correctly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 25)) + ;; Timestamp at 14:30 (5 minutes from 14:25) + (timestamp (test-time-today-at 14 30)) + (interval 5)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-sixty-minutes-returns-t () + "Test that 60-minute (1 hour) interval works correctly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Timestamp at 15:00 (60 minutes from 14:00) + (timestamp (test-time-today-at 15 0)) + (interval 60)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-large-interval-returns-t () + "Test that large interval (1 day = 1440 minutes) works correctly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Timestamp at 14:00 next day (1440 minutes from now) + ;; Add 86400 seconds (1440 minutes = 1 day) to now + ;; Convert to list format for compatibility + (timestamp (apply #'encode-time (decode-time (time-add now (seconds-to-time 86400))))) + (interval 1440)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-thirty-minutes-returns-t () + "Test that 30-minute interval works correctly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 15)) + ;; Timestamp at 14:45 (30 minutes from 14:15) + (timestamp (test-time-today-at 14 45)) + (interval 30)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-timestamp-within-interval-p-one-minute-before-returns-nil () + "Test that timestamp 1 minute before interval returns nil. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Timestamp at 14:09 (9 minutes from 14:00, not 10) + (timestamp (test-time-today-at 14 9)) + (interval 10)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should-not result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-one-minute-after-returns-nil () + "Test that timestamp 1 minute after interval returns nil. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Timestamp at 14:11 (11 minutes from 14:00, not 10) + (timestamp (test-time-today-at 14 11)) + (interval 10)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should-not result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-crossing-midnight-returns-t () + "Test that interval crossing midnight works correctly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 23 50)) + ;; Timestamp at 00:00 next day (10 minutes from 23:50) + ;; Add 600 seconds (10 minutes) to 23:50 to get 00:00 next day + ;; Convert to list format for compatibility + (timestamp (apply #'encode-time (decode-time (time-add now (seconds-to-time 600))))) + (interval 10)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-crossing-day-boundary-returns-t () + "Test that interval crossing to next day works correctly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 23 30)) + ;; Timestamp at 00:30 next day (60 minutes from 23:30) + ;; Add 3600 seconds (60 minutes) to 23:30 to get 00:30 next day + ;; Convert to list format for compatibility + (timestamp (apply #'encode-time (decode-time (time-add now (seconds-to-time 3600))))) + (interval 60)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-week-interval-returns-t () + "Test that very large interval (1 week = 10080 minutes) works. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Timestamp at 14:00 one week later (10080 minutes = 7 days from now) + ;; Add 604800 seconds (10080 minutes = 7 days) to now + ;; Convert to list format for compatibility + (timestamp (apply #'encode-time (decode-time (time-add now (seconds-to-time 604800))))) + (interval 10080)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-at-midnight-returns-t () + "Test that timestamp at exact midnight works correctly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 23 50)) + ;; Timestamp at midnight (10 minutes from 23:50) + ;; Add 600 seconds (10 minutes) to 23:50 to get 00:00 next day + ;; Convert to list format for compatibility + (timestamp (apply #'encode-time (decode-time (time-add now (seconds-to-time 600))))) + (interval 10)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-timestamp-within-interval-p-nil-timestamp-returns-nil () + "Test that nil timestamp returns nil. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (timestamp nil) + (interval 10)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should-not result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-nil-interval-returns-nil () + "Test that nil interval returns nil. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (timestamp (test-time-today-at 14 10)) + (interval nil)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should-not result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-negative-interval-returns-nil () + "Test that negative interval returns nil (past timestamps). + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Timestamp 10 minutes in the past (13:50) + (timestamp (test-time-today-at 13 50)) + (interval -10)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-invalid-timestamp-returns-nil () + "Test that invalid timestamp format returns nil. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (timestamp "not-a-timestamp") + (interval 10)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should-not result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(ert-deftest test-chime-timestamp-within-interval-p-float-interval-works () + "Test that float interval gets converted properly. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-timestamp-within-interval-p-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Timestamp at 14:10 (10 minutes from 14:00) + (timestamp (test-time-today-at 14 10)) + (interval 10.5)) + (with-test-time now + (let ((result (chime--timestamp-within-interval-p timestamp interval))) + (should result)))) + (test-chime-timestamp-within-interval-p-teardown))) + +(provide 'test-chime-timestamp-within-interval-p) +;;; test-chime-timestamp-within-interval-p.el ends here diff --git a/tests/test-chime-tooltip-bugs.el b/tests/test-chime-tooltip-bugs.el new file mode 100644 index 0000000..dce2fa5 --- /dev/null +++ b/tests/test-chime-tooltip-bugs.el @@ -0,0 +1,392 @@ +;;; test-chime-tooltip-bugs.el --- Tests for tooltip bugs -*- lexical-binding: t; -*- + +;; Tests for reported issues: +;; 1. Duplicate "in in" in countdown text +;; 2. Duplicate events in tooltip +;; 3. Missing future events + +;;; Code: + +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) +(load (expand-file-name "../chime.el") nil t) +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-tooltip-bugs-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + (setq test-tooltip-bugs--orig-lookahead chime-modeline-lookahead-minutes) + (setq test-tooltip-bugs--orig-tooltip-lookahead chime-tooltip-lookahead-hours) + (setq chime-modeline-lookahead-minutes 1440) + (setq chime-tooltip-lookahead-hours 8760)) ; 1 year + +(defun test-tooltip-bugs-teardown () + "Teardown function run after each test." + (setq chime-modeline-lookahead-minutes test-tooltip-bugs--orig-lookahead) + (setq chime-tooltip-lookahead-hours test-tooltip-bugs--orig-tooltip-lookahead) + (chime-delete-test-base-dir)) + +(defvar test-tooltip-bugs--orig-lookahead nil) +(defvar test-tooltip-bugs--orig-tooltip-lookahead nil) + +;;; Helper functions + +(defun test-tooltip-bugs--create-gcal-event (title time-str) + "Create test org content for a gcal event." + (concat + (format "* %s\n" title) + ":PROPERTIES:\n" + ":entry-id: test@google.com\n" + ":END:\n" + ":org-gcal:\n" + (format "%s\n" time-str) + ":END:\n")) + +(defun test-tooltip-bugs--gather-events (content) + "Process CONTENT and return events list." + (let* ((test-file (chime-create-temp-test-file-with-content content)) + (test-buffer (find-file-noselect test-file)) + (events nil)) + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (while (re-search-forward "^\\*+ " nil t) + (beginning-of-line) + (let* ((marker (point-marker)) + (info (chime--gather-info marker))) + (push info events)) + (forward-line 1))) + (kill-buffer test-buffer) + (nreverse events))) + +(defun test-tooltip-bugs--count-in-string (regexp string) + "Count occurrences of REGEXP in STRING." + (let ((count 0) + (start 0)) + (while (string-match regexp string start) + (setq count (1+ count)) + (setq start (match-end 0))) + count)) + +;;; Test 1: No duplicate "in" in countdown text + +(ert-deftest test-tooltip-no-duplicate-in () + "Test that tooltip doesn't have duplicate 'in in' in countdown. + +Issue: Tooltip showed '(in in 1h 4m)' instead of '(in 1h 4m)'." + (test-tooltip-bugs-setup) + (unwind-protect + (let* ((now (test-time-now)) + (event-time (time-add now (seconds-to-time (* 90 60)))) ; 90 min from now + (time-str (format-time-string "<%Y-%m-%d %a %H:%M>" event-time)) + (content (test-tooltip-bugs--create-gcal-event "Test Event" time-str)) + (events (test-tooltip-bugs--gather-events content))) + + (with-test-time now + (chime--update-modeline events)) + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Should NOT have "in in" + (should-not (string-match-p "in in" tooltip)) + + ;; Should have single "in" (from format string) + (should (string-match-p "(in " tooltip)))) + (test-tooltip-bugs-teardown))) + +;;; Test 2: No duplicate events in tooltip + +(ert-deftest test-tooltip-no-duplicate-events () + "Test that same event doesn't appear multiple times in tooltip. + +Issue: Events appeared multiple times in tooltip." + (test-tooltip-bugs-setup) + (unwind-protect + (let* ((now (test-time-now)) + (event1-time (time-add now (seconds-to-time (* 60 60)))) ; 1 hour + (event1-str (format-time-string "<%Y-%m-%d %a %H:%M>" event1-time)) + (event2-time (time-add now (seconds-to-time (* 120 60)))) ; 2 hours + (event2-str (format-time-string "<%Y-%m-%d %a %H:%M>" event2-time)) + (content (concat + (test-tooltip-bugs--create-gcal-event "Task 1" event1-str) + (test-tooltip-bugs--create-gcal-event "Task 2" event2-str))) + (events (test-tooltip-bugs--gather-events content))) + + (with-test-time now + (chime--update-modeline events)) + + ;; Should have exactly 2 events + (should (= 2 (length chime--upcoming-events))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Each event should appear exactly once + (should (= 1 (test-tooltip-bugs--count-in-string "Task 1" tooltip))) + (should (= 1 (test-tooltip-bugs--count-in-string "Task 2" tooltip))))) + (test-tooltip-bugs-teardown))) + +;;; Test 3: All future events included (not just first few in file) + +(ert-deftest test-tooltip-includes-all-future-events () + "Test that tooltip includes all future events, not just first N in file. + +Issue: Events later in file were being ignored. +This tests that chime doesn't assume events are in chronological order in file." + (test-tooltip-bugs-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Create events in NON-chronological order in file + ;; Far event first, then near events + (event-far-time (time-add now (seconds-to-time (* 48 3600)))) ; 2 days + (event-far-str (format-time-string "<%Y-%m-%d %a %H:%M>" event-far-time)) + (event1-time (time-add now (seconds-to-time (* 60 60)))) ; 1 hour + (event1-str (format-time-string "<%Y-%m-%d %a %H:%M>" event1-time)) + (event2-time (time-add now (seconds-to-time (* 90 60)))) ; 1.5 hours + (event2-str (format-time-string "<%Y-%m-%d %a %H:%M>" event2-time)) + (content (concat + ;; Far event appears FIRST in file + (test-tooltip-bugs--create-gcal-event "Task Far" event-far-str) + ;; Near events appear AFTER in file + (test-tooltip-bugs--create-gcal-event "Task 1" event1-str) + (test-tooltip-bugs--create-gcal-event "Task 2" event2-str))) + (events (test-tooltip-bugs--gather-events content))) + + ;; Mock time to prevent timing-related flakiness + (with-test-time now + (chime--update-modeline events)) + + ;; Should have all 3 events + (should (= 3 (length chime--upcoming-events))) + + ;; Events should be in chronological order (not file order) + (let* ((item1 (nth 0 chime--upcoming-events)) + (item2 (nth 1 chime--upcoming-events)) + (item3 (nth 2 chime--upcoming-events)) + (title1 (cdr (assoc 'title (car item1)))) + (title2 (cdr (assoc 'title (car item2)))) + (title3 (cdr (assoc 'title (car item3))))) + ;; Should be sorted chronologically + (should (string= title1 "Task 1")) + (should (string= title2 "Task 2")) + (should (string= title3 "Task Far"))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; All events should appear in tooltip + (should (string-match-p "Task 1" tooltip)) + (should (string-match-p "Task 2" tooltip)) + (should (string-match-p "Task Far" tooltip)) + + ;; Chronological order: Task 1 before Task 2 before Task Far + (let ((pos1 (string-match "Task 1" tooltip)) + (pos2 (string-match "Task 2" tooltip)) + (pos-far (string-match "Task Far" tooltip))) + (should (< pos1 pos2)) + (should (< pos2 pos-far))))) + (test-tooltip-bugs-teardown))) + +;;; Test 4: Exact replication of reported duplicate event issue + +(ert-deftest test-tooltip-exact-duplicate-bug () + "Replicate exact bug: Task 2 appearing twice, Task 3 appearing twice. + +From user report: +Task 1 at 9:00 PM (in 1h 4m) +Task 2 at 10:00 PM (in 2h 4m) <-- appears +Task 3 at 10:00 PM (in 2h 4m) <-- appears +Task 2 at 10:00 PM (in 2h 4m) <-- DUPLICATE +Task 4 at 01:00 PM (in 17h 4m)" + (test-tooltip-bugs-setup) + (unwind-protect + (let* ((now (current-time)) + ;; Create exactly the scenario from the report + (task1-time (time-add now (seconds-to-time (* 64 60)))) ; ~1h 4m + (task1-str (format-time-string "<%Y-%m-%d %a %H:%M>" task1-time)) + (task2-time (time-add now (seconds-to-time (* 124 60)))) ; ~2h 4m + (task2-str (format-time-string "<%Y-%m-%d %a %H:%M>" task2-time)) + (task3-time (time-add now (seconds-to-time (* 124 60)))) ; same time as task2 + (task3-str (format-time-string "<%Y-%m-%d %a %H:%M>" task3-time)) + (task4-time (time-add now (seconds-to-time (* 1024 60)))) ; ~17h 4m + (task4-str (format-time-string "<%Y-%m-%d %a %H:%M>" task4-time)) + (content (concat + (test-tooltip-bugs--create-gcal-event "Task 1" task1-str) + (test-tooltip-bugs--create-gcal-event "Task 2" task2-str) + (test-tooltip-bugs--create-gcal-event "Task 3" task3-str) + (test-tooltip-bugs--create-gcal-event "Task 4" task4-str))) + (events (test-tooltip-bugs--gather-events content))) + + (chime--update-modeline events) + + ;; Should have exactly 4 events + (should (= 4 (length chime--upcoming-events))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Each event should appear exactly once (no duplicates) + (should (= 1 (test-tooltip-bugs--count-in-string "Task 1" tooltip))) + (should (= 1 (test-tooltip-bugs--count-in-string "Task 2" tooltip))) + (should (= 1 (test-tooltip-bugs--count-in-string "Task 3" tooltip))) + (should (= 1 (test-tooltip-bugs--count-in-string "Task 4" tooltip))) + + ;; Verify chronological order + (let ((pos1 (string-match "Task 1" tooltip)) + (pos2 (string-match "Task 2" tooltip)) + (pos3 (string-match "Task 3" tooltip)) + (pos4 (string-match "Task 4" tooltip))) + (should (< pos1 pos2)) + (should (< pos2 pos4))))) + (test-tooltip-bugs-teardown))) + +;;; Test 5: Missing events that appear later in file + +(ert-deftest test-tooltip-missing-later-events () + "Replicate exact bug: Tasks 5, 6, 7 missing even though they're in future. + +From user report: 'Earlier in the same org-gcal file, I have Tasks 5, 6, 7 +and many more in the future.' + +This tests the concern: 'I worry chime is somehow assuming events and dates +are always listed in chronological order.'" + (test-tooltip-bugs-setup) + (unwind-protect + (let* ((now (current-time)) + ;; Create scenario where early events appear AFTER later events in file + ;; This mimics how org-gcal might organize events + (task5-time (time-add now (seconds-to-time (* 30 3600)))) ; 30 hours (tomorrow) + (task5-str (format-time-string "<%Y-%m-%d %a %H:%M>" task5-time)) + (task6-time (time-add now (seconds-to-time (* 48 3600)))) ; 48 hours (2 days) + (task6-str (format-time-string "<%Y-%m-%d %a %H:%M>" task6-time)) + (task7-time (time-add now (seconds-to-time (* 72 3600)))) ; 72 hours (3 days) + (task7-str (format-time-string "<%Y-%m-%d %a %H:%M>" task7-time)) + (task1-time (time-add now (seconds-to-time (* 2 3600)))) ; 2 hours (soon!) + (task1-str (format-time-string "<%Y-%m-%d %a %H:%M>" task1-time)) + ;; Put far events FIRST in file, near event LAST + (content (concat + (test-tooltip-bugs--create-gcal-event "Task 5" task5-str) + (test-tooltip-bugs--create-gcal-event "Task 6" task6-str) + (test-tooltip-bugs--create-gcal-event "Task 7" task7-str) + (test-tooltip-bugs--create-gcal-event "Task 1" task1-str))) + (events (test-tooltip-bugs--gather-events content))) + + (chime--update-modeline events) + + ;; Should have all 4 events + (should (= 4 (length chime--upcoming-events))) + + ;; Events should be sorted chronologically (not file order) + (let* ((item1 (nth 0 chime--upcoming-events)) + (item2 (nth 1 chime--upcoming-events)) + (item3 (nth 2 chime--upcoming-events)) + (item4 (nth 3 chime--upcoming-events)) + (title1 (cdr (assoc 'title (car item1)))) + (title2 (cdr (assoc 'title (car item2)))) + (title3 (cdr (assoc 'title (car item3)))) + (title4 (cdr (assoc 'title (car item4))))) + (should (string= title1 "Task 1")) ; soonest + (should (string= title2 "Task 5")) + (should (string= title3 "Task 6")) + (should (string= title4 "Task 7"))) ; furthest + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; ALL events should be present + (should (string-match-p "Task 1" tooltip)) + (should (string-match-p "Task 5" tooltip)) + (should (string-match-p "Task 6" tooltip)) + (should (string-match-p "Task 7" tooltip)) + + ;; Verify chronological order in tooltip + (let ((pos1 (string-match "Task 1" tooltip)) + (pos5 (string-match "Task 5" tooltip)) + (pos6 (string-match "Task 6" tooltip)) + (pos7 (string-match "Task 7" tooltip))) + (should (< pos1 pos5)) + (should (< pos5 pos6)) + (should (< pos6 pos7))))) + (test-tooltip-bugs-teardown))) + +;;; Test 6: Only 5 events shown when many more exist + +(ert-deftest test-tooltip-only-first-5-shown () + "Replicate bug: Only 5 events shown in tooltip despite having many more. + +From user report: Tooltip showed 5 events, then '...and that's all' even +though there were many more future events in the file. + +This might be due to default max-events=5." + (test-tooltip-bugs-setup) + (unwind-protect + (let* ((now (test-time-now)) + (content "") + (events nil)) + + ;; Create 10 events + (dotimes (i 10) + (let* ((hours-offset (+ 1 i)) ; 1, 2, 3... 10 hours + (event-time (time-add now (seconds-to-time (* hours-offset 3600)))) + (time-str (format-time-string "<%Y-%m-%d %a %H:%M>" event-time)) + (title (format "Task %d" (1+ i)))) + (setq content (concat content + (test-tooltip-bugs--create-gcal-event title time-str))))) + + (setq events (test-tooltip-bugs--gather-events content)) + + ;; Set to default: max-events=5 + (let ((chime-modeline-tooltip-max-events 5)) + (with-test-time now + (chime--update-modeline events)) + + ;; All 10 events should be in upcoming-events + (should (= 10 (length chime--upcoming-events))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Tooltip should show first 5 events + (should (string-match-p "Task 1" tooltip)) + (should (string-match-p "Task 2" tooltip)) + (should (string-match-p "Task 3" tooltip)) + (should (string-match-p "Task 4" tooltip)) + (should (string-match-p "Task 5" tooltip)) + + ;; Should NOT show tasks 6-10 + (should-not (string-match-p "Task 6" tooltip)) + (should-not (string-match-p "Task 10" tooltip)) + + ;; Should show "... and 5 more events" + (should (string-match-p "\\.\\.\\..*and 5 more events" tooltip))))) + (test-tooltip-bugs-teardown))) + +;;; Test 7: Events at exactly same time + +(ert-deftest test-tooltip-events-same-time () + "Test events scheduled at exactly the same time. + +From user report: Task 2 and Task 3 both at 10:00 PM. +Should both appear, should not duplicate." + (test-tooltip-bugs-setup) + (unwind-protect + (let* ((now (current-time)) + (same-time (time-add now (seconds-to-time (* 120 60)))) ; 2 hours + (time-str (format-time-string "<%Y-%m-%d %a %H:%M>" same-time)) + (content (concat + (test-tooltip-bugs--create-gcal-event "Meeting A" time-str) + (test-tooltip-bugs--create-gcal-event "Meeting B" time-str) + (test-tooltip-bugs--create-gcal-event "Meeting C" time-str))) + (events (test-tooltip-bugs--gather-events content))) + + (chime--update-modeline events) + + ;; Should have all 3 events + (should (= 3 (length chime--upcoming-events))) + + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; All 3 events should appear exactly once + (should (= 1 (test-tooltip-bugs--count-in-string "Meeting A" tooltip))) + (should (= 1 (test-tooltip-bugs--count-in-string "Meeting B" tooltip))) + (should (= 1 (test-tooltip-bugs--count-in-string "Meeting C" tooltip))))) + (test-tooltip-bugs-teardown))) + +(provide 'test-chime-tooltip-bugs) +;;; test-chime-tooltip-bugs.el ends here diff --git a/tests/test-chime-tooltip-day-calculation.el b/tests/test-chime-tooltip-day-calculation.el new file mode 100644 index 0000000..5d0901d --- /dev/null +++ b/tests/test-chime-tooltip-day-calculation.el @@ -0,0 +1,326 @@ +;;; test-chime-tooltip-day-calculation.el --- Tests for tooltip day/hour calculation -*- lexical-binding: t; -*- + +;;; Commentary: +;; Comprehensive tests for tooltip time-until formatting, especially day/hour calculations. +;; +;; Tests cover: +;; - Boundary cases (23h59m, 24h, 25h) +;; - Midnight boundaries +;; - Multiple days with fractional hours +;; - Exact day boundaries (48h, 72h) +;; - Edge cases that could trigger truncation bugs + +;;; Code: + +(require 'ert) +(require 'package) +(setq package-user-dir (expand-file-name "~/.emacs.d/elpa")) +(package-initialize) +(load (expand-file-name "../chime.el") nil t) +(require 'testutil-time (expand-file-name "testutil-time.el")) +(require 'testutil-general (expand-file-name "testutil-general.el")) + +(ert-deftest test-chime-tooltip-day-calculation-fractional-days () + "Test that fractional days show both days and hours correctly. + +User scenario: Viewing tooltip on Sunday 9pm, sees: +- Tuesday 9pm event: 48 hours = exactly 2 days → 'in 2 days' +- Wednesday 2pm event: 65 hours = 2.7 days → 'in 2 days 17 hours' + +This test prevents regression of the integer division truncation bug." + (chime-create-test-base-dir) + (unwind-protect + (let* ((now (test-time-today-at 21 0)) ; Sunday 9pm + ;; Create events at specific future times + (tuesday-9pm (time-add now (seconds-to-time (* 48 3600)))) ; +48 hours + (wednesday-2pm (time-add now (seconds-to-time (* 65 3600)))) ; +65 hours + (content (format "* Tuesday Event\n<%s>\n* Wednesday Event\n<%s>\n" + (format-time-string "<%Y-%m-%d %a %H:%M>" tuesday-9pm) + (format-time-string "<%Y-%m-%d %a %H:%M>" wednesday-2pm))) + (test-file (chime-create-temp-test-file-with-content content)) + (test-buffer (find-file-noselect test-file)) + (events nil)) + + ;; Gather events + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (while (re-search-forward "^\\*+ " nil t) + (beginning-of-line) + (push (chime--gather-info (point-marker)) events) + (forward-line 1))) + (kill-buffer test-buffer) + (setq events (nreverse events)) + + ;; Set lookahead to cover events (7 days) + (setq chime-modeline-lookahead-minutes 10080) + (setq chime-tooltip-lookahead-hours 168) + + (with-test-time now + ;; Update modeline and get tooltip + (chime--update-modeline events) + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + + ;; Verify tooltip contains both events + (should (string-match-p "Tuesday Event" tooltip)) + (should (string-match-p "Wednesday Event" tooltip)) + + ;; Print tooltip for manual inspection + (message "TOOLTIP CONTENT:\n%s" tooltip) + + ;; AFTER FIX: Tuesday shows "in 2 days", Wednesday shows "in 2 days 17 hours" + ;; Verify Tuesday shows exactly 2 days (no "hours" in countdown) + (should (string-match-p "Tuesday Event.*(in 2 days)" tooltip)) + ;; Make sure Tuesday doesn't have hours + (should-not (string-match-p "Tuesday Event.*hours" tooltip)) + + ;; Verify Wednesday shows 2 days AND 17 hours + (should (string-match-p "Wednesday Event.*(in 2 days 17 hours)" tooltip)) + + ;; Verify they show DIFFERENT countdowns + (let ((tuesday-line (progn + (string-match "Tuesday Event[^\n]*" tooltip) + (match-string 0 tooltip))) + (wednesday-line (progn + (string-match "Wednesday Event[^\n]*" tooltip) + (match-string 0 tooltip)))) + (should-not (string= tuesday-line wednesday-line)))))) + + (chime-delete-test-base-dir))) + +;;; Helper function for creating test events + +(defun test-chime-tooltip-day-calculation--create-event-at-hours (base-time title hours-from-now) + "Create event with TITLE at HOURS-FROM-NOW hours from BASE-TIME. +Returns formatted org content string." + (let* ((event-time (time-add base-time (seconds-to-time (* hours-from-now 3600))))) + (format "* %s\n<%s>\n" + title + (format-time-string "%Y-%m-%d %a %H:%M" event-time)))) + +(defun test-chime-tooltip-day-calculation--get-formatted-line (tooltip event-name) + "Extract the formatted countdown line for EVENT-NAME from TOOLTIP." + (when (string-match (format "%s[^\n]*" event-name) tooltip) + (match-string 0 tooltip))) + +;;; Boundary Cases - Critical thresholds + +(ert-deftest test-chime-tooltip-day-calculation-boundary-exactly-24-hours () + "Test event exactly 24 hours away shows 'in 1 day' not hours." + (chime-create-test-base-dir) + (unwind-protect + (let* ((now (test-time-today-at 12 0)) + (content (test-chime-tooltip-day-calculation--create-event-at-hours now "Tomorrow Same Time" 24)) + (test-file (chime-create-temp-test-file-with-content content)) + (events (with-current-buffer (find-file-noselect test-file) + (org-mode) + (goto-char (point-min)) + (let ((evs nil)) + (while (re-search-forward "^\\*+ " nil t) + (push (chime--gather-info (point-marker)) evs)) + (nreverse evs))))) + (kill-buffer (get-file-buffer test-file)) + + (setq chime-modeline-lookahead-minutes 10080) + (setq chime-tooltip-lookahead-hours 168) + + (with-test-time now + (chime--update-modeline events) + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Should show "in 1 day" not hours + (should (string-match-p "(in 1 day)" tooltip)) + (should-not (string-match-p "hours" tooltip))))) + (chime-delete-test-base-dir))) + +(ert-deftest test-chime-tooltip-day-calculation-boundary-23-hours-59-minutes () + "Test event 23h59m away shows hours, not days (just under 24h threshold)." + (chime-create-test-base-dir) + (unwind-protect + (let* ((now (test-time-today-at 12 0)) + ;; 23 hours 59 minutes = 1439 minutes = just under 1440 + (event-time (time-add now (seconds-to-time (* 1439 60)))) + (content (format "* Almost Tomorrow\n<%s>\n" + (format-time-string "%Y-%m-%d %a %H:%M" event-time))) + (test-file (chime-create-temp-test-file-with-content content)) + (events (with-current-buffer (find-file-noselect test-file) + (org-mode) + (goto-char (point-min)) + (let ((evs nil)) + (while (re-search-forward "^\\*+ " nil t) + (push (chime--gather-info (point-marker)) evs)) + (nreverse evs))))) + (kill-buffer (get-file-buffer test-file)) + + (setq chime-modeline-lookahead-minutes 10080) + (setq chime-tooltip-lookahead-hours 168) + + (with-test-time now + (chime--update-modeline events) + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Should show hours format (< 24 hours) + (should (string-match-p "hours" tooltip)) + (should-not (string-match-p "days?" tooltip))))) + (chime-delete-test-base-dir))) + +(ert-deftest test-chime-tooltip-day-calculation-boundary-25-hours () + "Test event 25 hours away shows 'in 1 day 1 hour'." + (chime-create-test-base-dir) + (unwind-protect + (let* ((now (test-time-today-at 12 0)) + (content (test-chime-tooltip-day-calculation--create-event-at-hours now "Day Plus One" 25)) + (test-file (chime-create-temp-test-file-with-content content)) + (events (with-current-buffer (find-file-noselect test-file) + (org-mode) + (goto-char (point-min)) + (let ((evs nil)) + (while (re-search-forward "^\\*+ " nil t) + (push (chime--gather-info (point-marker)) evs)) + (nreverse evs))))) + (kill-buffer (get-file-buffer test-file)) + + (setq chime-modeline-lookahead-minutes 10080) + (setq chime-tooltip-lookahead-hours 168) + + (with-test-time now + (chime--update-modeline events) + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Should show "in 1 day 1 hour" + (should (string-match-p "(in 1 day 1 hour)" tooltip))))) + (chime-delete-test-base-dir))) + +(ert-deftest test-chime-tooltip-day-calculation-boundary-exactly-48-hours () + "Test event exactly 48 hours away shows 'in 2 days' without hours." + (chime-create-test-base-dir) + (unwind-protect + (let* ((now (test-time-today-at 12 0)) + (content (test-chime-tooltip-day-calculation--create-event-at-hours now "Two Days Exact" 48)) + (test-file (chime-create-temp-test-file-with-content content)) + (events (with-current-buffer (find-file-noselect test-file) + (org-mode) + (goto-char (point-min)) + (let ((evs nil)) + (while (re-search-forward "^\\*+ " nil t) + (push (chime--gather-info (point-marker)) evs)) + (nreverse evs))))) + (kill-buffer (get-file-buffer test-file)) + + (setq chime-modeline-lookahead-minutes 10080) + (setq chime-tooltip-lookahead-hours 168) + + (with-test-time now + (chime--update-modeline events) + (let ((tooltip (chime--make-tooltip chime--upcoming-events)) + (line (test-chime-tooltip-day-calculation--get-formatted-line + (chime--make-tooltip chime--upcoming-events) "Two Days Exact"))) + ;; Should show exactly "in 2 days" with NO hours + (should (string-match-p "(in 2 days)" tooltip)) + ;; Verify the line doesn't contain "hour" (would be "2 days 0 hours") + (should-not (string-match-p "hour" line))))) + (chime-delete-test-base-dir))) + +;;; Midnight Boundaries + +(ert-deftest test-chime-tooltip-day-calculation-midnight-crossing-shows-correct-days () + "Test event crossing midnight boundary calculates days correctly. + +Scenario: 11pm now, event at 2am (3 hours later, next calendar day) +Should show hours, not '1 day' since it's only 3 hours away." + (chime-create-test-base-dir) + (unwind-protect + (let* ((now (test-time-today-at 23 0)) ; 11pm + ;; 3 hours later = 2am next day + (content (test-chime-tooltip-day-calculation--create-event-at-hours now "Early Morning" 3)) + (test-file (chime-create-temp-test-file-with-content content)) + (events (with-current-buffer (find-file-noselect test-file) + (org-mode) + (goto-char (point-min)) + (let ((evs nil)) + (while (re-search-forward "^\\*+ " nil t) + (push (chime--gather-info (point-marker)) evs)) + (nreverse evs))))) + (kill-buffer (get-file-buffer test-file)) + + (setq chime-modeline-lookahead-minutes 10080) + (setq chime-tooltip-lookahead-hours 168) + + (with-test-time now + (chime--update-modeline events) + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Should show "in 3 hours" not "in 1 day" + (should (string-match-p "3 hours" tooltip)) + (should-not (string-match-p "days?" tooltip))))) + (chime-delete-test-base-dir))) + +(ert-deftest test-chime-tooltip-day-calculation-midnight-plus-one-day () + "Test event at midnight tomorrow (24h exactly) shows '1 day'." + (chime-create-test-base-dir) + (unwind-protect + (let* ((now (test-time-today-at 0 0)) ; Midnight today + (content (test-chime-tooltip-day-calculation--create-event-at-hours now "Midnight Tomorrow" 24)) + (test-file (chime-create-temp-test-file-with-content content)) + (events (with-current-buffer (find-file-noselect test-file) + (org-mode) + (goto-char (point-min)) + (let ((evs nil)) + (while (re-search-forward "^\\*+ " nil t) + (push (chime--gather-info (point-marker)) evs)) + (nreverse evs))))) + (kill-buffer (get-file-buffer test-file)) + + (setq chime-modeline-lookahead-minutes 10080) + (setq chime-tooltip-lookahead-hours 168) + + (with-test-time now + (chime--update-modeline events) + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (should (string-match-p "(in 1 day)" tooltip)) + (should-not (string-match-p "hour" tooltip))))) + (chime-delete-test-base-dir))) + +;;; Multiple Events - Verify distinct formatting + +(ert-deftest test-chime-tooltip-day-calculation-multiple-events-distinct () + "Test multiple events at different fractional-day offsets show distinct times." + (chime-create-test-base-dir) + (unwind-protect + (let* ((now (test-time-today-at 12 0)) + (content (concat + (test-chime-tooltip-day-calculation--create-event-at-hours now "Event 1 Day" 24) + (test-chime-tooltip-day-calculation--create-event-at-hours now "Event 1.5 Days" 36) + (test-chime-tooltip-day-calculation--create-event-at-hours now "Event 2 Days" 48) + (test-chime-tooltip-day-calculation--create-event-at-hours now "Event 2.75 Days" 66))) + (test-file (chime-create-temp-test-file-with-content content)) + (events (with-current-buffer (find-file-noselect test-file) + (org-mode) + (goto-char (point-min)) + (let ((evs nil)) + (while (re-search-forward "^\\*+ " nil t) + (push (chime--gather-info (point-marker)) evs)) + (nreverse evs))))) + (kill-buffer (get-file-buffer test-file)) + + (setq chime-modeline-lookahead-minutes 10080) + (setq chime-tooltip-lookahead-hours 168) + + (with-test-time now + (chime--update-modeline events) + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Verify each event shows correctly + (should (string-match-p "Event 1 Day.*(in 1 day)" tooltip)) + (should (string-match-p "Event 1.5 Days.*(in 1 day 12 hours)" tooltip)) + (should (string-match-p "Event 2 Days.*(in 2 days)" tooltip)) + (should (string-match-p "Event 2.75 Days.*(in 2 days 18 hours)" tooltip)) + + ;; Verify they're all different + (let ((lines (split-string tooltip "\n"))) + (let ((countdowns (cl-remove-if-not + (lambda (line) (string-match-p "Event.*day" line)) + lines))) + ;; Should have 4 distinct countdown lines + (should (= 4 (length countdowns))) + ;; All should be unique + (should (= 4 (length (delete-dups (copy-sequence countdowns)))))))))) + (chime-delete-test-base-dir))) + +(provide 'test-chime-tooltip-day-calculation) +;;; test-chime-tooltip-day-calculation.el ends here diff --git a/tests/test-chime-update-modeline-helpers.el b/tests/test-chime-update-modeline-helpers.el new file mode 100644 index 0000000..106a7e2 --- /dev/null +++ b/tests/test-chime-update-modeline-helpers.el @@ -0,0 +1,166 @@ +;;; test-chime-update-modeline-helpers.el --- Tests for modeline helper functions -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for the refactored modeline helper functions: +;; - chime--find-soonest-time-in-window +;; - chime--build-upcoming-events-list +;; - chime--find-soonest-modeline-event + +;;; Code: + +(require 'ert) +(require 'package) +(setq package-user-dir (expand-file-name "~/.emacs.d/elpa")) +(package-initialize) +(load (expand-file-name "../chime.el") nil t) +(require 'testutil-time (expand-file-name "testutil-time.el")) +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-events (expand-file-name "testutil-events.el")) + +;;;; Tests for chime--find-soonest-time-in-window + +(ert-deftest test-chime-find-soonest-time-empty-list () + "Test that empty times list returns nil." + (let ((now (test-time-now)) + (times '())) + (should (null (chime--find-soonest-time-in-window times now 60))))) + +(ert-deftest test-chime-find-soonest-time-single-within-window () + "Test single time within window returns that time." + (let* ((now (test-time-now)) + (event-time (time-add now (seconds-to-time 1800))) ; 30 minutes + (times (list (cons "<2025-01-01 Wed 12:30>" event-time)))) + (let ((result (chime--find-soonest-time-in-window times now 60))) + (should result) + (should (equal (nth 0 result) "<2025-01-01 Wed 12:30>")) + (should (time-equal-p (nth 1 result) event-time)) + (should (< (abs (- (nth 2 result) 30)) 1))))) ; ~30 minutes + +(ert-deftest test-chime-find-soonest-time-multiple-returns-soonest () + "Test multiple times returns the soonest one." + (let* ((now (test-time-now)) + (time1 (time-add now (seconds-to-time 3600))) ; 60 min + (time2 (time-add now (seconds-to-time 1800))) ; 30 min (soonest) + (time3 (time-add now (seconds-to-time 5400))) ; 90 min + (times (list (cons "<2025-01-01 Wed 13:00>" time1) + (cons "<2025-01-01 Wed 12:30>" time2) + (cons "<2025-01-01 Wed 13:30>" time3)))) + (let ((result (chime--find-soonest-time-in-window times now 120))) + (should result) + (should (equal (nth 0 result) "<2025-01-01 Wed 12:30>")) + (should (< (abs (- (nth 2 result) 30)) 1))))) ; ~30 minutes + +(ert-deftest test-chime-find-soonest-time-outside-window () + "Test times outside window returns nil." + (let* ((now (test-time-now)) + (event-time (time-add now (seconds-to-time 7200))) ; 120 minutes + (times (list (cons "<2025-01-01 Wed 14:00>" event-time)))) + (should (null (chime--find-soonest-time-in-window times now 60))))) + +(ert-deftest test-chime-find-soonest-time-mix-inside-outside () + "Test mix of times inside/outside window returns soonest inside." + (let* ((now (test-time-now)) + (time-outside (time-add now (seconds-to-time 7200))) ; 120 min (outside) + (time-inside (time-add now (seconds-to-time 1800))) ; 30 min (inside, soonest) + (times (list (cons "<2025-01-01 Wed 14:00>" time-outside) + (cons "<2025-01-01 Wed 12:30>" time-inside)))) + (let ((result (chime--find-soonest-time-in-window times now 60))) + (should result) + (should (equal (nth 0 result) "<2025-01-01 Wed 12:30>"))))) + +(ert-deftest test-chime-find-soonest-time-past-event () + "Test past events are excluded." + (let* ((now (test-time-now)) + (past-time (time-subtract now (seconds-to-time 1800))) ; -30 minutes + (times (list (cons "<2025-01-01 Wed 11:30>" past-time)))) + (should (null (chime--find-soonest-time-in-window times now 60))))) + +;;;; Tests for chime--build-upcoming-events-list + +(ert-deftest test-chime-build-upcoming-empty-events () + "Test empty events list returns empty." + (let ((now (test-time-now)) + (events '())) + (should (null (chime--build-upcoming-events-list events now 1440 t))))) + +(ert-deftest test-chime-build-upcoming-single-event () + "Test single event within window is included." + (with-test-setup + (let* ((now (test-time-now)) + (event-time (time-add now (seconds-to-time 1800))) + (content (test-create-org-event "Meeting" event-time)) + (events (test-gather-events-from-content content)) + (result (chime--build-upcoming-events-list events now 1440 t))) + (should (= (length result) 1)) + (should (string= (cdr (assoc 'title (car (car result)))) "Meeting"))))) + +(ert-deftest test-chime-build-upcoming-sorted-by-time () + "Test multiple events are sorted by time (soonest first)." + (with-test-setup + (let* ((now (test-time-now)) + (time1 (time-add now (seconds-to-time 5400))) ; 90 min + (time2 (time-add now (seconds-to-time 1800))) ; 30 min (soonest) + (time3 (time-add now (seconds-to-time 3600))) ; 60 min + (content (test-create-org-events + `(("Meeting 1" ,time1) + ("Meeting 2" ,time2) + ("Meeting 3" ,time3)))) + (events (test-gather-events-from-content content)) + (result (chime--build-upcoming-events-list events now 1440 t))) + (should (= (length result) 3)) + ;; First should be Meeting 2 (soonest at 30 min) + (should (string= (cdr (assoc 'title (car (nth 0 result)))) "Meeting 2")) + ;; Second should be Meeting 3 (60 min) + (should (string= (cdr (assoc 'title (car (nth 1 result)))) "Meeting 3")) + ;; Third should be Meeting 1 (90 min) + (should (string= (cdr (assoc 'title (car (nth 2 result)))) "Meeting 1"))))) + +(ert-deftest test-chime-build-upcoming-excludes-outside-window () + "Test events outside lookahead window are excluded." + (with-test-setup + (let* ((now (test-time-now)) + (near-time (time-add now (seconds-to-time 1800))) ; 30 min (included) + (far-time (time-add now (seconds-to-time 10800))) ; 180 min (excluded) + (content (test-create-org-events + `(("Near Meeting" ,near-time) + ("Far Meeting" ,far-time)))) + (events (test-gather-events-from-content content)) + (result (chime--build-upcoming-events-list events now 60 t))) ; 60 min window + (should (= (length result) 1)) + (should (string= (cdr (assoc 'title (car (car result)))) "Near Meeting"))))) + +;;;; Tests for chime--find-soonest-modeline-event + +(ert-deftest test-chime-find-soonest-modeline-empty-events () + "Test empty events list returns nil." + (let ((now (test-time-now)) + (events '())) + (should (null (chime--find-soonest-modeline-event events now 60))))) + +(ert-deftest test-chime-find-soonest-modeline-single-timed-event () + "Test single timed event within window is returned." + (with-test-setup + (let* ((now (test-time-now)) + (event-time (time-add now (seconds-to-time 1800))) + (content (test-create-org-event "Meeting" event-time)) + (events (test-gather-events-from-content content)) + (result (chime--find-soonest-modeline-event events now 60))) + (should result) + (should (string= (cdr (assoc 'title (nth 0 result))) "Meeting"))))) + +(ert-deftest test-chime-find-soonest-modeline-excludes-all-day () + "Test all-day events are excluded from modeline." + (with-test-setup + (let* ((now (test-time-today-at 10 0)) + (all-day-time (test-time-today-at 0 0)) + (timed-time (time-add now (seconds-to-time 1800))) + (content (test-create-org-events + `(("All Day Event" ,all-day-time nil t) + ("Timed Event" ,timed-time)))) + (events (test-gather-events-from-content content)) + (result (chime--find-soonest-modeline-event events now 60))) + (should result) + (should (string= (cdr (assoc 'title (nth 0 result))) "Timed Event"))))) + +(provide 'test-chime-update-modeline-helpers) +;;; test-chime-update-modeline-helpers.el ends here diff --git a/tests/test-chime-update-modeline.el b/tests/test-chime-update-modeline.el new file mode 100644 index 0000000..764334c --- /dev/null +++ b/tests/test-chime-update-modeline.el @@ -0,0 +1,474 @@ +;;; test-chime-update-modeline.el --- Tests for chime--update-modeline -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for chime--update-modeline function. +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defun test-chime-update-modeline-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset modeline settings + (setq chime-modeline-string nil) + (setq chime-enable-modeline t) + (setq chime-modeline-lookahead-minutes 30) + (setq chime-modeline-format " ⏰ %s") + ;; Disable no-events indicator for tests that expect nil modeline + (setq chime-modeline-no-events-text nil) + (setq chime-tooltip-lookahead-hours nil)) ; Use modeline lookahead + +(defun test-chime-update-modeline-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir) + (setq chime-modeline-string nil)) + +;;; Normal Cases + +(ert-deftest test-chime-update-modeline-single-event-within-window-updates-modeline () + "Test that single event within lookahead window updates modeline. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at 14:10 (10 minutes from now) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Team Meeting"))) + (events (list event))) + (chime--update-modeline events) + ;; Should set modeline string + (should chime-modeline-string) + (should (stringp chime-modeline-string)) + (should (string-match-p "Team Meeting" chime-modeline-string)) + (should (string-match-p "10 minutes" chime-modeline-string)))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-multiple-events-picks-soonest () + "Test that with multiple events, soonest one is shown. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event 1 at 14:05 (5 minutes - soonest) + (event-time-1 (test-time-today-at 14 5)) + (timestamp-str-1 (test-timestamp-string event-time-1)) + ;; Event 2 at 14:25 (25 minutes) + (event-time-2 (test-time-today-at 14 25)) + (timestamp-str-2 (test-timestamp-string event-time-2))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event1 `((times . ((,timestamp-str-1 . ,event-time-1))) + (title . "Standup"))) + (event2 `((times . ((,timestamp-str-2 . ,event-time-2))) + (title . "Code Review"))) + (events (list event1 event2))) + (chime--update-modeline events) + ;; Should show the soonest event + (should chime-modeline-string) + (should (string-match-p "Standup" chime-modeline-string)) + (should-not (string-match-p "Code Review" chime-modeline-string)))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-event-outside-window-no-update () + "Test that event outside lookahead window doesn't update modeline. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at 15:10 (70 minutes from now, outside 30 minute window) + (event-time (test-time-today-at 15 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Far Future Event"))) + (events (list event))) + (chime--update-modeline events) + ;; Should NOT set modeline string + (should-not chime-modeline-string))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-zero-lookahead-clears-modeline () + "Test that zero lookahead clears modeline. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Team Meeting"))) + (events (list event))) + (setq chime-modeline-lookahead-minutes 0) + (chime--update-modeline events) + ;; Should clear modeline + (should-not chime-modeline-string))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-normal-disabled-clears-modeline () + "Test that chime-enable-modeline nil clears modeline even with valid event. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Team Meeting"))) + (events (list event))) + (setq chime-enable-modeline nil) + (chime--update-modeline events) + ;; Should NOT set modeline string when disabled + (should-not chime-modeline-string))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-normal-enabled-updates-modeline () + "Test that chime-enable-modeline t allows normal modeline updates. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Team Meeting"))) + (events (list event))) + (setq chime-enable-modeline t) + (chime--update-modeline events) + ;; Should set modeline string when enabled + (should chime-modeline-string) + (should (string-match-p "Team Meeting" chime-modeline-string)))))) + (test-chime-update-modeline-teardown))) + +;;; Boundary Cases + +(ert-deftest test-chime-update-modeline-no-events-clears-modeline () + "Test that no events clears modeline. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let ((now (test-time-today-at 14 0))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let ((events '())) + (chime--update-modeline events) + (should-not chime-modeline-string))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-day-wide-events-filtered-out () + "Test that day-wide events are filtered out. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 0 0)) + (timestamp-str (test-timestamp-string event-time t))) ; Day-wide + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "All Day Event"))) + (events (list event))) + (chime--update-modeline events) + (should-not chime-modeline-string))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-event-at-exact-boundary-included () + "Test that event at exact lookahead boundary is included. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at 14:30 (exactly 30 minutes, at boundary) + (event-time (test-time-today-at 14 30)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Boundary Event"))) + (events (list event))) + (chime--update-modeline events) + (should chime-modeline-string) + (should (string-match-p "Boundary Event" chime-modeline-string)))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-boundary-disabled-overrides-lookahead () + "Test that chime-enable-modeline nil overrides positive lookahead. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Team Meeting"))) + (events (list event))) + ;; Even with positive lookahead, disabled should prevent updates + (setq chime-enable-modeline nil) + (setq chime-modeline-lookahead-minutes 30) + (chime--update-modeline events) + (should-not chime-modeline-string))))) + (test-chime-update-modeline-teardown))) + +;;; Error Cases + +(ert-deftest test-chime-update-modeline-past-events-not-shown () + "Test that past events are not shown in modeline. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event at 13:50 (10 minutes ago) + (event-time (test-time-today-at 13 50)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Past Event"))) + (events (list event))) + (chime--update-modeline events) + (should-not chime-modeline-string))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-error-nil-events-handles-gracefully () + "Test that nil events parameter doesn't crash. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let ((now (test-time-today-at 14 0))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + ;; Should not error with nil events + (should-not (condition-case nil + (progn (chime--update-modeline nil) nil) + (error t))) + ;; Modeline should remain unset or cleared + (should-not chime-modeline-string)))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-error-invalid-event-structure-handles-gracefully () + "Test that invalid event structure doesn't crash. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let ((now (test-time-today-at 14 0))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* (;; Event missing required fields + (invalid-event '((invalid . "structure"))) + (events (list invalid-event))) + ;; Should not crash even with invalid events + (should-not (condition-case nil + (progn (chime--update-modeline events) nil) + (error t))))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-error-event-with-nil-times-handles-gracefully () + "Test that event with nil times field doesn't crash. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let ((now (test-time-today-at 14 0))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event '((times . nil) + (title . "Event with nil times"))) + (events (list event))) + ;; Should not crash + (should-not (condition-case nil + (progn (chime--update-modeline events) nil) + (error t))) + ;; Modeline should not be set + (should-not chime-modeline-string))))) + (test-chime-update-modeline-teardown))) + +;;; Upcoming Events State Tests + +(ert-deftest test-chime-update-modeline-upcoming-events-populated () + "Test that chime--upcoming-events is populated with all events in window. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Three events within 30 minute window + (event1-time (test-time-today-at 14 5)) + (timestamp-str-1 (test-timestamp-string event1-time)) + (event2-time (test-time-today-at 14 10)) + (timestamp-str-2 (test-timestamp-string event2-time)) + (event3-time (test-time-today-at 14 25)) + (timestamp-str-3 (test-timestamp-string event3-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event1 `((times . ((,timestamp-str-1 . ,event1-time))) + (title . "Event 1") + (marker . nil))) + (event2 `((times . ((,timestamp-str-2 . ,event2-time))) + (title . "Event 2") + (marker . nil))) + (event3 `((times . ((,timestamp-str-3 . ,event3-time))) + (title . "Event 3") + (marker . nil))) + (events (list event1 event2 event3))) + (chime--update-modeline events) + ;; Should populate chime--upcoming-events + (should chime--upcoming-events) + ;; Should have all 3 events + (should (= 3 (length chime--upcoming-events))))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-upcoming-events-sorted () + "Test that chime--upcoming-events are sorted by time (soonest first). + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Add events in reverse order + (event1-time (test-time-today-at 14 25)) + (timestamp-str-1 (test-timestamp-string event1-time)) + (event2-time (test-time-today-at 14 10)) + (timestamp-str-2 (test-timestamp-string event2-time)) + (event3-time (test-time-today-at 14 5)) + (timestamp-str-3 (test-timestamp-string event3-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event1 `((times . ((,timestamp-str-1 . ,event1-time))) + (title . "Latest Event") + (marker . nil))) + (event2 `((times . ((,timestamp-str-2 . ,event2-time))) + (title . "Middle Event") + (marker . nil))) + (event3 `((times . ((,timestamp-str-3 . ,event3-time))) + (title . "Soonest Event") + (marker . nil))) + (events (list event1 event2 event3))) + (chime--update-modeline events) + ;; First event should be soonest + (let* ((first-event (car chime--upcoming-events)) + (first-event-obj (car first-event)) + (first-title (cdr (assoc 'title first-event-obj)))) + (should (string= "Soonest Event" first-title))))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-upcoming-events-cleared-when-disabled () + "Test that chime--upcoming-events is cleared when modeline disabled. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + (event-time (test-time-today-at 14 10)) + (timestamp-str (test-timestamp-string event-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event `((times . ((,timestamp-str . ,event-time))) + (title . "Test Event") + (marker . nil))) + (events (list event))) + ;; First populate with modeline enabled + (setq chime-enable-modeline t) + (chime--update-modeline events) + (should chime--upcoming-events) + ;; Now disable modeline + (setq chime-enable-modeline nil) + (chime--update-modeline events) + ;; Should clear chime--upcoming-events + (should-not chime--upcoming-events))))) + (test-chime-update-modeline-teardown))) + +(ert-deftest test-chime-update-modeline-upcoming-events-only-within-window () + "Test that only events within lookahead window are stored. + +REFACTORED: Uses dynamic timestamps and with-test-time" + (test-chime-update-modeline-setup) + (unwind-protect + (let* ((now (test-time-today-at 14 0)) + ;; Event within window (10 minutes) + (event1-time (test-time-today-at 14 10)) + (timestamp-str-1 (test-timestamp-string event1-time)) + ;; Event outside window (60 minutes, window is 30) + (event2-time (test-time-today-at 15 0)) + (timestamp-str-2 (test-timestamp-string event2-time))) + (with-test-time now + (cl-letf (((symbol-function 'force-mode-line-update) (lambda (&optional _)))) + (let* ((event1 `((times . ((,timestamp-str-1 . ,event1-time))) + (title . "Within Window") + (marker . nil))) + (event2 `((times . ((,timestamp-str-2 . ,event2-time))) + (title . "Outside Window") + (marker . nil))) + (events (list event1 event2))) + (setq chime-modeline-lookahead-minutes 30) + (setq chime-tooltip-lookahead-hours 0.5) ; Also set tooltip lookahead + (chime--update-modeline events) + ;; Should only have 1 event (within window) + (should (= 1 (length chime--upcoming-events))))))) + (test-chime-update-modeline-teardown))) + +(provide 'test-chime-update-modeline) +;;; test-chime-update-modeline.el ends here diff --git a/tests/test-chime-validate-configuration.el b/tests/test-chime-validate-configuration.el new file mode 100644 index 0000000..fe0555f --- /dev/null +++ b/tests/test-chime-validate-configuration.el @@ -0,0 +1,279 @@ +;;; test-chime-validate-configuration.el --- Tests for chime-validate-configuration -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Craig Jennings + +;;; Commentary: + +;; Unit tests for chime-validate-configuration function. +;; Tests validation of chime's runtime environment and configuration. +;; +;; Test categories: +;; - Normal Cases: Valid configurations that should pass +;; - Boundary Cases: Edge conditions (single file, empty strings, etc.) +;; - Error Cases: Invalid configurations that should fail +;; +;; External dependencies mocked: +;; - file-exists-p (file I/O) +;; - require (package loading) +;; - display-warning (UI side effect) +;; +;; NOT mocked: +;; - Validation logic itself +;; - org-agenda-files variable (use let to set test values) + +;;; Code: + +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'dash) +(require 'org-agenda) +(load (expand-file-name "../chime.el") nil t) +(require 'cl-lib) + +;;; Setup and Teardown + +(defun test-chime-validate-configuration-setup () + "Set up test environment before each test." + ;; No persistent state to set up - each test uses let-bindings + nil) + +(defun test-chime-validate-configuration-teardown () + "Clean up test environment after each test." + ;; No cleanup needed - let-bindings automatically unwind + nil) + +;;; Test Helper Functions + +(defun test-chime-validate-configuration--has-error-p (issues) + "Return t if ISSUES contains at least one :error severity item." + (cl-some (lambda (issue) (eq (car issue) :error)) issues)) + +(defun test-chime-validate-configuration--has-warning-p (issues) + "Return t if ISSUES contains at least one :warning severity item." + (cl-some (lambda (issue) (eq (car issue) :warning)) issues)) + +(defun test-chime-validate-configuration--count-issues (issues severity) + "Count number of ISSUES with given SEVERITY (:error, :warning, or :info)." + (length (cl-remove-if-not (lambda (i) (eq (car i) severity)) issues))) + +;;; Normal Cases - Valid Configurations + +(ert-deftest test-chime-validate-configuration-normal-valid-config-returns-nil () + "Test validation passes with valid org-agenda-files and all dependencies loaded." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '("/tmp/inbox.org" "/tmp/work.org")) + (chime-enable-modeline t) + (global-mode-string '(""))) + (cl-letf (((symbol-function 'file-exists-p) (lambda (_) t)) + ((symbol-function 'require) (lambda (_ &optional _ _) t)) + ((symbol-function 'display-warning) (lambda (&rest _) nil))) + (should (null (chime-validate-configuration))))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-normal-multiple-files-returns-nil () + "Test validation passes with multiple org-agenda files that all exist." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '("/tmp/one.org" "/tmp/two.org" "/tmp/three.org")) + (chime-enable-modeline t) + (global-mode-string '(""))) + (cl-letf (((symbol-function 'file-exists-p) (lambda (_) t)) + ((symbol-function 'require) (lambda (_ &optional _ _) t)) + ((symbol-function 'display-warning) (lambda (&rest _) nil))) + (should (null (chime-validate-configuration))))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-normal-modeline-disabled-skips-check () + "Test validation skips global-mode-string check when modeline is disabled." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '("/tmp/inbox.org")) + (chime-enable-modeline nil)) + (cl-letf (((symbol-function 'file-exists-p) (lambda (_) t)) + ((symbol-function 'require) (lambda (_ &optional _ _) t)) + ((symbol-function 'display-warning) (lambda (&rest _) nil)) + ((symbol-function 'boundp) + (lambda (sym) (not (eq sym 'global-mode-string))))) ; Only global-mode-string unbound + (should (null (chime-validate-configuration))))) + (test-chime-validate-configuration-teardown)) + +;;; Boundary Cases - Edge Conditions + +(ert-deftest test-chime-validate-configuration-boundary-single-file-returns-nil () + "Test validation passes with exactly one org-agenda file." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '("/tmp/single.org")) + (chime-enable-modeline t) + (global-mode-string '(""))) + (cl-letf (((symbol-function 'file-exists-p) (lambda (_) t)) + ((symbol-function 'require) (lambda (_ &optional _ _) t)) + ((symbol-function 'display-warning) (lambda (&rest _) nil))) + (should (null (chime-validate-configuration))))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-boundary-some-files-missing-returns-warning () + "Test validation warns when some but not all files are missing." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '("/exists.org" "/missing.org" "/also-missing.org")) + (chime-enable-modeline t) + (global-mode-string '(""))) + (cl-letf (((symbol-function 'file-exists-p) + (lambda (f) (string= f "/exists.org"))) + ((symbol-function 'require) (lambda (_ &optional _ _) t)) + ((symbol-function 'display-warning) (lambda (&rest _) nil))) + (let ((issues (chime-validate-configuration))) + (should (= 1 (length issues))) + (should (eq :warning (caar issues))) + (should (string-match-p "2 org-agenda-files don't exist" (cadar issues))) + (should (string-match-p "/missing.org" (cadar issues))) + (should (string-match-p "/also-missing.org" (cadar issues)))))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-boundary-all-files-missing-returns-warning () + "Test validation warns when all org-agenda files are missing." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '("/missing1.org" "/missing2.org")) + (chime-enable-modeline t) + (global-mode-string '(""))) + (cl-letf (((symbol-function 'file-exists-p) (lambda (_) nil)) + ((symbol-function 'require) (lambda (_ &optional _ _) t)) + ((symbol-function 'display-warning) (lambda (&rest _) nil))) + (let ((issues (chime-validate-configuration))) + (should (= 1 (length issues))) + (should (eq :warning (caar issues))) + (should (string-match-p "2 org-agenda-files don't exist" (cadar issues)))))) + (test-chime-validate-configuration-teardown)) + +;;; Error Cases - Invalid Configurations + +(ert-deftest test-chime-validate-configuration-error-nil-org-agenda-files-returns-error () + "Test validation returns error when org-agenda-files is nil." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files nil) + (chime-enable-modeline t)) + (cl-letf (((symbol-function 'display-warning) (lambda (&rest _) nil))) + (let ((issues (chime-validate-configuration))) + (should issues) + (should (test-chime-validate-configuration--has-error-p issues)) + (should (string-match-p "not set or empty" (cadar issues)))))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-error-empty-list-returns-error () + "Test validation returns error when org-agenda-files is empty list." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '()) + (chime-enable-modeline t)) + (cl-letf (((symbol-function 'display-warning) (lambda (&rest _) nil))) + (let ((issues (chime-validate-configuration))) + (should issues) + (should (eq :error (caar issues))) + (should (string-match-p "not set or empty" (cadar issues)))))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-error-unbound-org-agenda-files-returns-error () + "Test validation returns error when org-agenda-files variable is not bound." + (test-chime-validate-configuration-setup) + (cl-letf (((symbol-function 'boundp) + (lambda (sym) (not (eq sym 'org-agenda-files)))) + ((symbol-function 'display-warning) (lambda (&rest _) nil))) + (let ((issues (chime-validate-configuration))) + (should issues) + (should (test-chime-validate-configuration--has-error-p issues)) + (should (string-match-p "not set or empty" (cadar issues))))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-error-non-list-org-agenda-files-returns-error () + "Test validation returns error when org-agenda-files is not a list." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files "/tmp/inbox.org") ; string instead of list + (chime-enable-modeline t)) + (cl-letf (((symbol-function 'display-warning) (lambda (&rest _) nil))) + (let ((issues (chime-validate-configuration))) + (should issues) + (should (test-chime-validate-configuration--has-error-p issues))))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-error-org-agenda-not-loadable-returns-error () + "Test validation returns error when org-agenda cannot be loaded." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '("/tmp/test.org")) + (chime-enable-modeline t) + (global-mode-string '(""))) + (cl-letf (((symbol-function 'file-exists-p) (lambda (_) t)) + ((symbol-function 'require) + (lambda (feature &optional _ _) + (if (eq feature 'org-agenda) nil t))) + ((symbol-function 'display-warning) (lambda (&rest _) nil))) + (let ((issues (chime-validate-configuration))) + (should issues) + (should (cl-some (lambda (i) + (and (eq (car i) :error) + (string-match-p "org-agenda" (cadr i)))) + issues))))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-error-multiple-errors-returns-all () + "Test validation returns all errors when multiple issues exist." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files nil) ; Error 1: nil org-agenda-files + (chime-enable-modeline t)) + (cl-letf (((symbol-function 'require) + (lambda (feature &optional _ _) + (if (eq feature 'org-agenda) nil t))) ; Error 2: can't load org-agenda + ((symbol-function 'display-warning) (lambda (&rest _) nil))) + (let ((issues (chime-validate-configuration))) + (should (>= (test-chime-validate-configuration--count-issues issues :error) 2))))) + (test-chime-validate-configuration-teardown)) + +;;; Warning Cases - Non-Critical Issues + +(ert-deftest test-chime-validate-configuration-warning-missing-global-mode-string-returns-warning () + "Test validation warns when global-mode-string is not available but modeline is enabled." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '("/tmp/inbox.org")) + (chime-enable-modeline t)) + (cl-letf (((symbol-function 'file-exists-p) (lambda (_) t)) + ((symbol-function 'require) (lambda (_ &optional _ _) t)) + ((symbol-function 'boundp) + (lambda (sym) (not (eq sym 'global-mode-string)))) + ((symbol-function 'display-warning) (lambda (&rest _) nil))) + (let ((issues (chime-validate-configuration))) + (should (test-chime-validate-configuration--has-warning-p issues)) + (should (string-match-p "global-mode-string not available" (cadar issues)))))) + (test-chime-validate-configuration-teardown)) + +;;; Interactive Behavior Tests + +(ert-deftest test-chime-validate-configuration-interactive-calls-display-warning () + "Test validation displays warnings when called interactively." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files nil) + (warning-called nil) + (chime-enable-modeline t)) + (cl-letf (((symbol-function 'display-warning) + (lambda (&rest _) (setq warning-called t))) + ((symbol-function 'called-interactively-p) (lambda (_) t))) + (chime-validate-configuration) + (should warning-called))) + (test-chime-validate-configuration-teardown)) + +(ert-deftest test-chime-validate-configuration-interactive-success-shows-message () + "Test validation shows success message when called interactively with valid config." + (test-chime-validate-configuration-setup) + (let ((org-agenda-files '("/tmp/inbox.org")) + (message-shown nil) + (chime-enable-modeline t) + (global-mode-string '(""))) + (cl-letf (((symbol-function 'file-exists-p) (lambda (_) t)) + ((symbol-function 'require) (lambda (_ &optional _ _) t)) + ((symbol-function 'message) + (lambda (fmt &rest _) + (when (string-match-p "validation checks passed" fmt) + (setq message-shown t)))) + ((symbol-function 'called-interactively-p) (lambda (_) t))) + (chime-validate-configuration) + (should message-shown))) + (test-chime-validate-configuration-teardown)) + +(provide 'test-chime-validate-configuration) +;;; test-chime-validate-configuration.el ends here diff --git a/tests/test-chime-validation-retry.el b/tests/test-chime-validation-retry.el new file mode 100644 index 0000000..b35fd29 --- /dev/null +++ b/tests/test-chime-validation-retry.el @@ -0,0 +1,435 @@ +;;; test-chime-validation-retry.el --- Tests for chime validation retry logic -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for chime's configuration validation retry mechanism. +;; +;; Tests cover the graceful retry behavior when org-agenda-files is not +;; immediately available (e.g., loaded asynchronously via idle timer). +;; +;; The retry mechanism allows chime to wait for org-agenda-files to be +;; populated before showing configuration errors, providing a better UX +;; for users with async initialization code. +;; +;; Components tested: +;; - chime--validation-retry-count tracking +;; - chime-validation-max-retries configuration +;; - chime-check validation retry logic +;; - chime--stop retry counter reset +;; - Message display behavior (waiting vs error) + +;;; Code: + +(require 'ert) + +;; Initialize package system to make installed packages available in batch mode +(require 'package) +(setq package-user-dir (expand-file-name "~/.emacs.d/elpa")) +(package-initialize) + +;; Load chime from parent directory (which will load its dependencies) +(load (expand-file-name "../chime.el") nil t) + +;;; Setup and Teardown + +(defvar test-chime-validation-retry--original-max-retries nil + "Original value of chime-validation-max-retries for restoration.") + +(defvar test-chime-validation-retry--original-agenda-files nil + "Original value of org-agenda-files for restoration.") + +(defun test-chime-validation-retry-setup () + "Set up test environment before each test." + ;; Save original values + (setq test-chime-validation-retry--original-max-retries chime-validation-max-retries) + (setq test-chime-validation-retry--original-agenda-files org-agenda-files) + + ;; Reset validation state + (setq chime--validation-done nil) + (setq chime--validation-retry-count 0) + + ;; Set predictable defaults + (setq chime-validation-max-retries 3)) + +(defun test-chime-validation-retry-teardown () + "Clean up test environment after each test." + ;; Restore original values + (setq chime-validation-max-retries test-chime-validation-retry--original-max-retries) + (setq org-agenda-files test-chime-validation-retry--original-agenda-files) + + ;; Reset validation state + (setq chime--validation-done nil) + (setq chime--validation-retry-count 0)) + +;;; Normal Cases - Retry Behavior + +(ert-deftest test-chime-validation-retry-normal-first-failure-shows-waiting () + "Test first validation failure shows waiting message, not error. + +When org-agenda-files is empty on the first check, chime should show +a friendly waiting message instead of immediately displaying the full +error. This accommodates async org-agenda-files initialization." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + ;; Empty org-agenda-files to trigger validation failure + (setq org-agenda-files nil) + + ;; Capture message output + (let ((messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages))) + ;; Mock fetch to prevent actual agenda processing + ((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil))) + + ;; Call chime-check + (chime-check) + + ;; Should show waiting message + (should (= chime--validation-retry-count 1)) + (should-not chime--validation-done) + (should (cl-some (lambda (msg) + (string-match-p "Waiting for org-agenda-files" msg)) + messages)) + ;; Should NOT show error message + (should-not (cl-some (lambda (msg) + (string-match-p "Configuration errors detected" msg)) + messages))))) + (test-chime-validation-retry-teardown))) + +(ert-deftest test-chime-validation-retry-normal-success-resets-counter () + "Test successful validation after retry resets counter to zero. + +When validation succeeds on a retry attempt, the retry counter should +be reset to 0, allowing fresh retry attempts if validation fails again +later (e.g., after mode restart)." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + ;; Simulate one failed attempt + (setq chime--validation-retry-count 1) + + ;; Set valid org-agenda-files + (setq org-agenda-files '("/tmp/test.org")) + + ;; Mock fetch to prevent actual agenda processing + (cl-letf (((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil))) + + ;; Call chime-check - should succeed + (chime-check) + + ;; Counter should be reset + (should (= chime--validation-retry-count 0)) + ;; Validation marked as done + (should chime--validation-done))) + (test-chime-validation-retry-teardown))) + +(ert-deftest test-chime-validation-retry-normal-multiple-retries-increment () + "Test multiple validation failures increment counter correctly. + +Each validation failure should increment the retry counter by 1, +allowing the system to track how many retries have been attempted." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + ;; Empty org-agenda-files + (setq org-agenda-files nil) + + ;; Mock fetch + (cl-letf (((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil)) + ((symbol-function 'message) + (lambda (&rest args) nil))) + + ;; First attempt + (chime-check) + (should (= chime--validation-retry-count 1)) + + ;; Second attempt + (chime-check) + (should (= chime--validation-retry-count 2)) + + ;; Third attempt + (chime-check) + (should (= chime--validation-retry-count 3)))) + (test-chime-validation-retry-teardown))) + +(ert-deftest test-chime-validation-retry-normal-successful-validation-proceeds () + "Test successful validation proceeds with event checking. + +When validation passes, chime-check should proceed to fetch and +process events normally." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + ;; Valid org-agenda-files + (setq org-agenda-files '("/tmp/test.org")) + + ;; Track if fetch was called + (let ((fetch-called nil)) + (cl-letf (((symbol-function 'chime--fetch-and-process) + (lambda (callback) + (setq fetch-called t)))) + + ;; Call chime-check + (chime-check) + + ;; Should proceed to fetch + (should fetch-called) + (should chime--validation-done) + (should (= chime--validation-retry-count 0))))) + (test-chime-validation-retry-teardown))) + +;;; Boundary Cases - Edge Conditions + +(ert-deftest test-chime-validation-retry-boundary-max-retries-zero () + "Test max-retries=0 shows error immediately without retrying. + +When chime-validation-max-retries is set to 0, validation failures +should immediately show the full error message without any retry +attempts." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + ;; Set max retries to 0 + (setq chime-validation-max-retries 0) + + ;; Empty org-agenda-files + (setq org-agenda-files nil) + + ;; Capture message output + (let ((messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages))) + ((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil))) + + ;; Call chime-check + (chime-check) + + ;; Counter incremented + (should (= chime--validation-retry-count 1)) + ;; Should show error, not waiting message + (should (cl-some (lambda (msg) + (string-match-p "Configuration errors detected" msg)) + messages)) + (should-not (cl-some (lambda (msg) + (string-match-p "Waiting for" msg)) + messages))))) + (test-chime-validation-retry-teardown))) + +(ert-deftest test-chime-validation-retry-boundary-max-retries-one () + "Test max-retries=1 allows one retry before showing error. + +First attempt should show waiting message, second attempt should +show full error." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + ;; Set max retries to 1 + (setq chime-validation-max-retries 1) + + ;; Empty org-agenda-files + (setq org-agenda-files nil) + + (cl-letf (((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil))) + + ;; First attempt - should show waiting + (let ((messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (chime-check) + (should (= chime--validation-retry-count 1)) + (should (cl-some (lambda (msg) + (string-match-p "Waiting for" msg)) + messages)))) + + ;; Second attempt - should show error + (let ((messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (chime-check) + (should (= chime--validation-retry-count 2)) + (should (cl-some (lambda (msg) + (string-match-p "Configuration errors detected" msg)) + messages)))))) + (test-chime-validation-retry-teardown))) + +(ert-deftest test-chime-validation-retry-boundary-exactly-at-threshold () + "Test behavior exactly at max-retries threshold. + +The (retry_count + 1)th attempt should show the error message." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + ;; Default max retries = 3 + (setq chime-validation-max-retries 3) + (setq org-agenda-files nil) + + (cl-letf (((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil))) + + ;; Attempts 1-3: waiting messages + (dotimes (_ 3) + (let ((messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (chime-check) + (should (cl-some (lambda (msg) + (string-match-p "Waiting for" msg)) + messages))))) + + ;; Attempt 4: should show error + (let ((messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (chime-check) + (should (= chime--validation-retry-count 4)) + (should (cl-some (lambda (msg) + (string-match-p "Configuration errors detected" msg)) + messages)))))) + (test-chime-validation-retry-teardown))) + +(ert-deftest test-chime-validation-retry-boundary-stop-resets-counter () + "Test chime--stop resets retry counter to zero. + +When chime-mode is stopped, the retry counter should be reset to +allow fresh retry attempts on next start." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + ;; Simulate some failed attempts + (setq chime--validation-retry-count 5) + (setq chime--validation-done nil) + + ;; Call stop + (chime--stop) + + ;; Counter should be reset + (should (= chime--validation-retry-count 0)) + (should-not chime--validation-done)) + (test-chime-validation-retry-teardown))) + +(ert-deftest test-chime-validation-retry-boundary-empty-agenda-files () + "Test empty org-agenda-files list triggers retry. + +An empty list should be treated the same as nil - both should +trigger validation failure and retry." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + ;; Empty list (not nil) + (setq org-agenda-files '()) + + (let ((messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages))) + ((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil))) + + ;; Should trigger retry + (chime-check) + (should (= chime--validation-retry-count 1)) + (should (cl-some (lambda (msg) + (string-match-p "Waiting for" msg)) + messages))))) + (test-chime-validation-retry-teardown))) + +;;; Error Cases - Failure Scenarios + +(ert-deftest test-chime-validation-retry-error-exceeding-max-shows-full-error () + "Test exceeding max retries shows full error with details. + +After max retries exceeded, the full validation error should be +displayed with all error details in the *Messages* buffer." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + (setq chime-validation-max-retries 2) + (setq org-agenda-files nil) + + (cl-letf (((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil))) + + ;; Exhaust retries + (dotimes (_ 3) + (let ((messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (chime-check)))) + + ;; Verify error message on next attempt + (let ((messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (format-string &rest args) + (push (apply #'format format-string args) messages)))) + (chime-check) + ;; Should show error message (detailed error with retry count goes to *Messages* buffer via chime--log-silently) + (should (cl-some (lambda (msg) + (string-match-p "Configuration errors detected" msg)) + messages)))))) + (test-chime-validation-retry-teardown))) + +(ert-deftest test-chime-validation-retry-error-persistent-failure () + "Test validation failure persisting through all retries. + +If org-agenda-files remains empty through all retry attempts, +validation should never be marked as done." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + (setq chime-validation-max-retries 3) + (setq org-agenda-files nil) + + (cl-letf (((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil)) + ((symbol-function 'message) + (lambda (&rest args) nil))) + + ;; Multiple attempts, all failing + (dotimes (_ 10) + (chime-check) + ;; Should never mark as done + (should-not chime--validation-done)) + + ;; Counter keeps incrementing + (should (= chime--validation-retry-count 10)))) + (test-chime-validation-retry-teardown))) + +(ert-deftest test-chime-validation-retry-error-counter-large-value () + "Test retry counter handles large values without overflow. + +The retry counter should continue incrementing correctly even with +many retry attempts, ensuring no integer overflow issues." + (test-chime-validation-retry-setup) + (unwind-protect + (progn + (setq chime-validation-max-retries 1000) + (setq org-agenda-files nil) + + (cl-letf (((symbol-function 'chime--fetch-and-process) + (lambda (callback) nil)) + ((symbol-function 'message) + (lambda (&rest args) nil))) + + ;; Many attempts + (dotimes (i 100) + (chime-check) + (should (= chime--validation-retry-count (1+ i)))) + + ;; Should still be counting correctly + (should (= chime--validation-retry-count 100)))) + (test-chime-validation-retry-teardown))) + +(provide 'test-chime-validation-retry) +;;; test-chime-validation-retry.el ends here diff --git a/tests/test-chime-whitelist-blacklist-conflicts.el b/tests/test-chime-whitelist-blacklist-conflicts.el new file mode 100644 index 0000000..ba7d00a --- /dev/null +++ b/tests/test-chime-whitelist-blacklist-conflicts.el @@ -0,0 +1,253 @@ +;;; test-chime-whitelist-blacklist-conflicts.el --- Tests for whitelist/blacklist conflicts -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Tests for conflicts when the same keyword or tag appears in both +;; whitelist and blacklist. These tests verify which takes precedence. +;; +;; Current implementation: whitelist is applied first, then blacklist. +;; Therefore, blacklist wins in conflicts - items matching both lists +;; are filtered out. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) + +;;; Setup and Teardown + +(defun test-chime-conflicts-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Reset all whitelist/blacklist settings + (setq chime-keyword-whitelist nil) + (setq chime-tags-whitelist nil) + (setq chime-predicate-whitelist nil) + (setq chime-keyword-blacklist nil) + (setq chime-tags-blacklist nil) + (setq chime-predicate-blacklist nil)) + +(defun test-chime-conflicts-teardown () + "Teardown function run after each test." + (chime-delete-test-base-dir) + (setq chime-keyword-whitelist nil) + (setq chime-tags-whitelist nil) + (setq chime-predicate-whitelist nil) + (setq chime-keyword-blacklist nil) + (setq chime-tags-blacklist nil) + (setq chime-predicate-blacklist nil)) + +;;; Keyword Conflict Tests + +(ert-deftest test-chime-conflict-same-keyword-in-both-lists () + "Test behavior when same keyword is in both whitelist and blacklist. +Current behavior: blacklist wins (item is filtered out)." + (test-chime-conflicts-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1\n") + (insert "* DONE Task 2\n") + (insert "* TODO Task 3\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker)) + (chime-keyword-whitelist '("TODO" "DONE")) + (chime-keyword-blacklist '("DONE"))) ; DONE in both lists + ;; Apply both filters (simulating what happens in chime--gather-timestamps) + (let* ((after-whitelist (chime--apply-whitelist (list marker1 marker2 marker3))) + (after-blacklist (chime--apply-blacklist after-whitelist))) + ;; Whitelist should keep all three (all are TODO or DONE) + (should (= (length after-whitelist) 3)) + (should (member marker1 after-whitelist)) + (should (member marker2 after-whitelist)) + (should (member marker3 after-whitelist)) + ;; Blacklist should then remove DONE (marker2) + ;; So only TODO items (markers 1 and 3) should remain + (should (= (length after-blacklist) 2)) + (should (member marker1 after-blacklist)) + (should-not (member marker2 after-blacklist)) + (should (member marker3 after-blacklist))))))) + (test-chime-conflicts-teardown))) + +(ert-deftest test-chime-conflict-all-keywords-in-both-lists () + "Test behavior when all keywords are in both whitelist and blacklist. +Current behavior: blacklist wins, all items filtered out." + (test-chime-conflicts-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1\n") + (insert "* DONE Task 2\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker)) + (chime-keyword-whitelist '("TODO" "DONE")) + (chime-keyword-blacklist '("TODO" "DONE"))) + (let* ((after-whitelist (chime--apply-whitelist (list marker1 marker2))) + (after-blacklist (chime--apply-blacklist after-whitelist))) + ;; Whitelist should keep both + (should (= (length after-whitelist) 2)) + ;; Blacklist should remove both + (should (= (length after-blacklist) 0)))))) + (test-chime-conflicts-teardown))) + +;;; Tag Conflict Tests + +(ert-deftest test-chime-conflict-same-tag-in-both-lists () + "Test behavior when same tag is in both whitelist and blacklist. +Current behavior: blacklist wins (item is filtered out)." + (test-chime-conflicts-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* Task 1 :urgent:\n") + (insert "* Task 2 :normal:\n") + (insert "* Task 3 :important:\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker)) + (chime-tags-whitelist '("urgent" "important")) + (chime-tags-blacklist '("urgent"))) ; urgent in both lists + (let* ((after-whitelist (chime--apply-whitelist (list marker1 marker2 marker3))) + (after-blacklist (chime--apply-blacklist after-whitelist))) + ;; Whitelist should keep urgent and important (markers 1 and 3) + (should (= (length after-whitelist) 2)) + (should (member marker1 after-whitelist)) + (should (member marker3 after-whitelist)) + ;; Blacklist should then remove urgent (marker1) + ;; So only important (marker3) should remain + (should (= (length after-blacklist) 1)) + (should-not (member marker1 after-blacklist)) + (should (member marker3 after-blacklist))))))) + (test-chime-conflicts-teardown))) + +;;; Mixed Keyword and Tag Conflict Tests + +(ert-deftest test-chime-conflict-keyword-whitelisted-tag-blacklisted () + "Test when item has whitelisted keyword but blacklisted tag. +Current behavior: blacklist wins (OR logic means tag match filters it out)." + (test-chime-conflicts-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1 :urgent:\n") + (insert "* DONE Task 2 :normal:\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker)) + (chime-keyword-whitelist '("TODO")) + (chime-tags-blacklist '("urgent"))) + (let* ((after-whitelist (chime--apply-whitelist (list marker1 marker2))) + (after-blacklist (chime--apply-blacklist after-whitelist))) + ;; Whitelist should keep TODO (marker1) + (should (= (length after-whitelist) 1)) + (should (member marker1 after-whitelist)) + ;; Blacklist should remove items with urgent tag (marker1) + (should (= (length after-blacklist) 0)))))) + (test-chime-conflicts-teardown))) + +(ert-deftest test-chime-conflict-tag-whitelisted-keyword-blacklisted () + "Test when item has whitelisted tag but blacklisted keyword. +Current behavior: blacklist wins (OR logic means keyword match filters it out)." + (test-chime-conflicts-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1 :urgent:\n") + (insert "* DONE Task 2 :normal:\n") + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker)) + (chime-tags-whitelist '("urgent")) + (chime-keyword-blacklist '("TODO"))) + (let* ((after-whitelist (chime--apply-whitelist (list marker1 marker2))) + (after-blacklist (chime--apply-blacklist after-whitelist))) + ;; Whitelist should keep urgent tag (marker1) + (should (= (length after-whitelist) 1)) + (should (member marker1 after-whitelist)) + ;; Blacklist should remove items with TODO keyword (marker1) + (should (= (length after-blacklist) 0)))))) + (test-chime-conflicts-teardown))) + +;;; Complex Conflict Tests + +(ert-deftest test-chime-conflict-multiple-items-partial-conflicts () + "Test multiple items with some having conflicts and some not. +Current behavior: only items with conflicts are filtered out." + (test-chime-conflicts-setup) + (unwind-protect + (with-temp-buffer + (org-mode) + (insert "* TODO Task 1 :urgent:\n") ; whitelisted keyword, blacklisted tag + (insert "* DONE Task 2 :normal:\n") ; not whitelisted + (insert "* TODO Task 3 :urgent:\n") ; whitelisted keyword, blacklisted tag + (insert "* TODO Task 4 :normal:\n") ; whitelisted keyword, ok tag + (goto-char (point-min)) + (let ((marker1 (point-marker))) + (forward-line 1) + (let ((marker2 (point-marker))) + (forward-line 1) + (let ((marker3 (point-marker))) + (forward-line 1) + (let ((marker4 (point-marker)) + (chime-keyword-whitelist '("TODO")) + (chime-tags-blacklist '("urgent"))) + (let* ((after-whitelist (chime--apply-whitelist (list marker1 marker2 marker3 marker4))) + (after-blacklist (chime--apply-blacklist after-whitelist))) + ;; Whitelist should keep TODO (markers 1, 3, 4) + (should (= (length after-whitelist) 3)) + (should (member marker1 after-whitelist)) + (should (member marker3 after-whitelist)) + (should (member marker4 after-whitelist)) + ;; Blacklist should remove items with urgent tag (markers 1, 3) + ;; Only marker4 (TODO with normal tag) should remain + (should (= (length after-blacklist) 1)) + (should (member marker4 after-blacklist)) + (should-not (member marker1 after-blacklist)) + (should-not (member marker3 after-blacklist)))))))) + (test-chime-conflicts-teardown))) + +(provide 'test-chime-whitelist-blacklist-conflicts) +;;; test-chime-whitelist-blacklist-conflicts.el ends here diff --git a/tests/test-convert-org-contacts-birthdays.el b/tests/test-convert-org-contacts-birthdays.el new file mode 100644 index 0000000..960a998 --- /dev/null +++ b/tests/test-convert-org-contacts-birthdays.el @@ -0,0 +1,674 @@ +;;; test-convert-org-contacts-birthdays.el --- Tests for convert-org-contacts-birthdays.el -*- 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for convert-org-contacts-birthdays.el +;; Tests the conversion of org-contacts BIRTHDAY properties to plain timestamps. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load the conversion utility from parent directory +(load (expand-file-name "../convert-org-contacts-birthdays.el") nil t) + +;;; Tests for birthday parsing + +(ert-deftest test-convert-normal-parse-birthday-with-year-returns-year-month-day () + "Test parsing YYYY-MM-DD format returns (YEAR MONTH DAY)." + (let ((result (chime--parse-birthday "2000-03-15"))) + (should (equal result '(2000 3 15))))) + +(ert-deftest test-convert-normal-parse-birthday-without-year-returns-nil-month-day () + "Test parsing MM-DD format returns (nil MONTH DAY)." + (let ((result (chime--parse-birthday "03-15"))) + (should (equal result '(nil 3 15))))) + +(ert-deftest test-convert-normal-parse-birthday-december-returns-correct-month () + "Test parsing December date returns month 12." + (let ((result (chime--parse-birthday "1985-12-25"))) + (should (equal result '(1985 12 25))))) + +(ert-deftest test-convert-normal-parse-birthday-january-returns-correct-month () + "Test parsing January date returns month 1." + (let ((result (chime--parse-birthday "01-01"))) + (should (equal result '(nil 1 1))))) + +(ert-deftest test-convert-error-parse-birthday-invalid-format-signals-error () + "Test parsing invalid format signals user-error." + (should-error (chime--parse-birthday "2000/03/15") :type 'user-error) + (should-error (chime--parse-birthday "March 15, 2000") :type 'user-error) + (should-error (chime--parse-birthday "15-03-2000") :type 'user-error)) + +;;; Tests for birthday formatting + +(ert-deftest test-convert-normal-format-birthday-timestamp-with-year-returns-yearly-repeater () + "Test formatting with year returns yearly repeating timestamp." + (let ((result (chime--format-birthday-timestamp 2000 3 15))) + ;; Should contain year, date, and +1y repeater + (should (string-match-p "<2000-03-15 [A-Za-z]\\{3\\} \\+1y>" result)))) + +(ert-deftest test-convert-normal-format-birthday-timestamp-without-year-uses-current-year () + "Test formatting without year uses current year." + (let* ((current-year (nth 5 (decode-time))) + (result (chime--format-birthday-timestamp nil 3 15))) + ;; Should contain current year + (should (string-match-p (format "<%d-03-15 [A-Za-z]\\{3\\} \\+1y>" current-year) result)))) + +(ert-deftest test-convert-boundary-format-birthday-timestamp-leap-day-returns-valid-timestamp () + "Test formatting February 29 (leap day) returns valid timestamp." + (let ((result (chime--format-birthday-timestamp 2000 2 29))) + (should (string-match-p "<2000-02-29 [A-Za-z]\\{3\\} \\+1y>" result)))) + +(ert-deftest test-convert-boundary-format-birthday-timestamp-december-31-returns-valid-timestamp () + "Test formatting December 31 returns valid timestamp." + (let ((result (chime--format-birthday-timestamp 2000 12 31))) + (should (string-match-p "<2000-12-31 [A-Za-z]\\{3\\} \\+1y>" result)))) + +;;; Tests for backup file creation + +(ert-deftest test-convert-normal-backup-file-creates-timestamped-backup () + "Test that backup file is created with timestamp." + (let* ((temp-file (make-temp-file "test-contacts")) + (backup-path nil)) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Test Contact\n:PROPERTIES:\n:BIRTHDAY: 2000-01-01\n:END:\n")) + (setq backup-path (chime--backup-contacts-file temp-file)) + (should (file-exists-p backup-path)) + (should (string-match-p "\\.backup-[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}-[0-9]\\{6\\}$" backup-path))) + (when (file-exists-p temp-file) (delete-file temp-file)) + (when (and backup-path (file-exists-p backup-path)) (delete-file backup-path))))) + +(ert-deftest test-convert-normal-backup-file-preserves-content () + "Test that backup file contains exact copy of original." + (let* ((temp-file (make-temp-file "test-contacts")) + (content "* Test Contact\n:PROPERTIES:\n:BIRTHDAY: 2000-01-01\n:END:\n") + (backup-path nil)) + (unwind-protect + (progn + (with-temp-file temp-file (insert content)) + (setq backup-path (chime--backup-contacts-file temp-file)) + (with-temp-buffer + (insert-file-contents backup-path) + (should (string= (buffer-string) content)))) + (when (file-exists-p temp-file) (delete-file temp-file)) + (when (and backup-path (file-exists-p backup-path)) (delete-file backup-path))))) + +;;; Tests for timestamp insertion + +(ert-deftest test-convert-normal-insert-timestamp-after-drawer-adds-timestamp () + "Test that timestamp is inserted after properties drawer." + (with-temp-buffer + (org-mode) + (insert "* Test Contact\n:PROPERTIES:\n:EMAIL: test@example.com\n:BIRTHDAY: 2000-03-15\n:END:\n") + (goto-char (point-min)) + (re-search-forward "^\\* ") + (beginning-of-line) + (chime--insert-birthday-timestamp-after-drawer "2000-03-15") + (should (string-match-p "<2000-03-15 [A-Za-z]\\{3\\} \\+1y>" (buffer-string))))) + +(ert-deftest test-convert-normal-insert-timestamp-preserves-properties () + "Test that inserting timestamp doesn't modify properties." + (with-temp-buffer + (org-mode) + (insert "* Test Contact\n:PROPERTIES:\n:EMAIL: test@example.com\n:BIRTHDAY: 2000-03-15\n:END:\n") + (goto-char (point-min)) + (re-search-forward "^\\* ") + (beginning-of-line) + (chime--insert-birthday-timestamp-after-drawer "2000-03-15") + (should (string-match-p ":EMAIL: test@example.com" (buffer-string))) + (should (string-match-p ":BIRTHDAY: 2000-03-15" (buffer-string))))) + +(ert-deftest test-convert-boundary-insert-timestamp-without-year-uses-current () + "Test that MM-DD format uses current year." + (let ((current-year (nth 5 (decode-time)))) + (with-temp-buffer + (org-mode) + (insert "* Test Contact\n:PROPERTIES:\n:BIRTHDAY: 03-15\n:END:\n") + (goto-char (point-min)) + (re-search-forward "^\\* ") + (beginning-of-line) + (chime--insert-birthday-timestamp-after-drawer "03-15") + (should (string-match-p (format "<%d-03-15" current-year) (buffer-string)))))) + +;;; Tests for contact entry processing + +(ert-deftest test-convert-normal-process-contact-with-birthday-returns-true () + "Test that processing contact with birthday returns t." + (with-temp-buffer + (org-mode) + (insert "* Test Contact\n:PROPERTIES:\n:BIRTHDAY: 2000-03-15\n:END:\n") + (goto-char (point-min)) + (re-search-forward "^\\* ") + (beginning-of-line) + (should (eq t (chime--process-contact-entry))))) + +(ert-deftest test-convert-normal-process-contact-without-birthday-returns-nil () + "Test that processing contact without birthday returns nil." + (with-temp-buffer + (org-mode) + (insert "* Test Contact\n:PROPERTIES:\n:EMAIL: test@example.com\n:END:\n") + (goto-char (point-min)) + (re-search-forward "^\\* ") + (beginning-of-line) + (should (null (chime--process-contact-entry))))) + +;;; Tests for file conversion + +(ert-deftest test-convert-normal-convert-file-multiple-contacts-returns-correct-count () + "Test converting file with multiple contacts returns correct count." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\n:PROPERTIES:\n:BIRTHDAY: 1985-03-15\n:END:\n\n") + (insert "* Bob Baker\n:PROPERTIES:\n:BIRTHDAY: 1990-07-22\n:END:\n\n") + (insert "* Carol Chen\n:PROPERTIES:\n:BIRTHDAY: 12-25\n:END:\n")) + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (count (car result)) + (backup-file (cdr result))) + (should (= count 3)) + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-boundary-convert-file-no-contacts-returns-zero () + "Test converting file with no contacts returns zero." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "#+TITLE: Empty File\n\nSome text but no contacts.\n")) + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (count (car result)) + (backup-file (cdr result))) + (should (= count 0)) + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-boundary-convert-file-contacts-without-birthdays-returns-zero () + "Test converting file with contacts but no birthdays returns zero." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\n:PROPERTIES:\n:EMAIL: alice@example.com\n:END:\n\n") + (insert "* Bob Baker\n:PROPERTIES:\n:EMAIL: bob@example.com\n:END:\n")) + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (count (car result)) + (backup-file (cdr result))) + (should (= count 0)) + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-boundary-convert-file-contact-without-properties-drawer () + "Test converting contact without properties drawer is skipped." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\nJust some text, no properties.\n")) + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (count (car result)) + (backup-file (cdr result))) + (should (= count 0)) + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-boundary-convert-file-preserves-existing-timestamps () + "Test that existing timestamps in contact body are not modified." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\n:PROPERTIES:\n:BIRTHDAY: 1985-03-15\n:END:\n") + (insert "Meeting scheduled <2025-11-15 Sat>\n")) + (chime--convert-contacts-file-in-place temp-file) + (with-temp-buffer + (insert-file-contents temp-file) + (let ((content (buffer-string))) + ;; Should have birthday timestamp + (should (string-match-p "<1985-03-15 [A-Za-z]\\{3\\} \\+1y>" content)) + ;; Should still have meeting timestamp + (should (string-match-p "<2025-11-15 Sat>" content)) + ;; Should have exactly 2 timestamps + (should (= 2 (how-many "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}" (point-min) (point-max))))))) + (when (file-exists-p temp-file) (delete-file temp-file)) + (let ((backup (concat temp-file ".backup-"))) + (dolist (file (directory-files (file-name-directory temp-file) t (regexp-quote (file-name-nondirectory backup)))) + (delete-file file)))))) + +(ert-deftest test-convert-normal-convert-file-timestamp-inserted-after-drawer () + "Test that timestamp is inserted immediately after properties drawer." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\n:PROPERTIES:\n:BIRTHDAY: 1985-03-15\n:END:\n") + (insert "Some notes about Alice.\n")) + (chime--convert-contacts-file-in-place temp-file) + (with-temp-buffer + (insert-file-contents temp-file) + (let ((content (buffer-string))) + ;; Timestamp should come after :END: but before notes + (should (string-match-p ":END:\n<1985-03-15 [A-Za-z]\\{3\\} \\+1y>\nSome notes" content))))) + (when (file-exists-p temp-file) (delete-file temp-file)) + (let ((backup (concat temp-file ".backup-"))) + (dolist (file (directory-files (file-name-directory temp-file) t (regexp-quote (file-name-nondirectory backup)))) + (delete-file file)))))) + +;;; Tests for year extraction + +(ert-deftest test-convert-normal-extract-birthday-year-with-year-returns-year () + "Test extracting year from YYYY-MM-DD returns the year." + (let ((result (chime--extract-birthday-year "2000-03-15"))) + (should (equal result 2000)))) + +(ert-deftest test-convert-normal-extract-birthday-year-without-year-returns-nil () + "Test extracting year from MM-DD returns nil." + (let ((result (chime--extract-birthday-year "03-15"))) + (should (null result)))) + +(ert-deftest test-convert-boundary-extract-birthday-year-very-old-date-returns-year () + "Test extracting year from very old date (1900s) returns the year." + (let ((result (chime--extract-birthday-year "1920-01-01"))) + (should (equal result 1920)))) + +(ert-deftest test-convert-boundary-extract-birthday-year-future-date-returns-year () + "Test extracting year from future date returns the year." + (let ((result (chime--extract-birthday-year "2100-12-31"))) + (should (equal result 2100)))) + +;;; Edge Case Tests + +(ert-deftest test-convert-edge-duplicate-timestamps-not-added () + "Test that running conversion twice doesn't add duplicate timestamps." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\n:PROPERTIES:\n:BIRTHDAY: 2000-01-01\n:END:\n")) + + ;; Run conversion once + (let ((backup1 (cdr (chime--convert-contacts-file-in-place temp-file)))) + ;; Clean up first backup to avoid file-already-exists error + (when (file-exists-p backup1) (delete-file backup1)) + + ;; Run conversion again + (let ((backup2 (cdr (chime--convert-contacts-file-in-place temp-file)))) + + (with-temp-buffer + (insert-file-contents temp-file) + (let ((content (buffer-string))) + ;; Should have exactly one birthday timestamp + (should (= 1 (how-many "<2000-01-01 [A-Za-z]\\{3\\} \\+1y>" (point-min) (point-max)))))) + + (when (file-exists-p backup2) (delete-file backup2))))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-edge-malformed-properties-drawer-missing-end () + "Test handling of properties drawer missing :END:." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\n:PROPERTIES:\n:BIRTHDAY: 2000-01-01\nMissing END tag\n")) + + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (count (car result)) + (backup-file (cdr result))) + ;; Should handle gracefully (likely returns 0 if drawer malformed) + (should (numberp count)) + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-edge-empty-birthday-property () + "Test handling of empty BIRTHDAY property value." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\n:PROPERTIES:\n:BIRTHDAY: \n:END:\n")) + + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (count (car result)) + (backup-file (cdr result))) + ;; Should skip empty birthday + (should (= count 0)) + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-edge-whitespace-in-birthday () + "Test handling of whitespace in BIRTHDAY property." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\n:PROPERTIES:\n:BIRTHDAY: 2000-01-01 \n:END:\n")) + + (chime--convert-contacts-file-in-place temp-file) + + (with-temp-buffer + (insert-file-contents temp-file) + (let ((content (buffer-string))) + ;; Should handle whitespace and create timestamp + (should (string-match-p "<2000-01-01 [A-Za-z]\\{3\\} \\+1y>" content))))) + (when (file-exists-p temp-file) (delete-file temp-file)) + (let ((backup (concat temp-file ".backup-"))) + (dolist (file (directory-files (file-name-directory temp-file) t (regexp-quote (file-name-nondirectory backup)))) + (delete-file file)))))) + +(ert-deftest test-convert-edge-very-large-file () + "Test conversion of file with many contacts (performance test)." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (dotimes (i 100) + (insert (format "* Contact %d\n:PROPERTIES:\n:BIRTHDAY: 1990-01-01\n:END:\n\n" i)))) + + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (count (car result)) + (backup-file (cdr result))) + (should (= count 100)) + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +;;; Integration Tests + +(ert-deftest test-convert-integration-full-workflow-with-realistic-contacts () + "Integration test with realistic contacts file structure." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "#+TITLE: My Contacts\n#+STARTUP: overview\n\n") + (insert "* Alice Anderson\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: alice@example.com\n") + (insert ":PHONE: 555-0101\n") + (insert ":BIRTHDAY: 1985-03-15\n") + (insert ":ADDRESS: 123 Main St\n") + (insert ":END:\n") + (insert "Met at conference 2023.\n\n") + (insert "* Bob Baker\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: bob@example.com\n") + (insert ":BIRTHDAY: 1990-07-22\n") + (insert ":END:\n\n") + (insert "* Carol Chen\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: carol@example.com\n") + (insert ":END:\n") + (insert "No birthday for Carol.\n")) + + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (count (car result)) + (backup-file (cdr result))) + + ;; Verify conversion count + (should (= count 2)) + + ;; Verify backup exists + (should (file-exists-p backup-file)) + + ;; Verify converted file content + (with-temp-buffer + (insert-file-contents temp-file) + (let ((content (buffer-string))) + ;; File header preserved + (should (string-search "#+TITLE: My Contacts" content)) + + ;; Alice's properties preserved + (should (string-search ":EMAIL: alice@example.com" content)) + (should (string-search ":ADDRESS: 123 Main St" content)) + (should (string-search ":BIRTHDAY: 1985-03-15" content)) + + ;; Alice's birthday timestamp added + (should (string-match-p "<1985-03-15 [A-Za-z]\\{3\\} \\+1y>" content)) + + ;; Alice's notes preserved + (should (string-search "Met at conference 2023" content)) + + ;; Bob's birthday timestamp added + (should (string-match-p "<1990-07-22 [A-Za-z]\\{3\\} \\+1y>" content)) + + ;; Carol has no timestamp added + (goto-char (point-min)) + (re-search-forward "\\* Carol Chen") + (let ((carol-section-start (point)) + (carol-section-end (or (re-search-forward "^\\* " nil t) (point-max)))) + (goto-char carol-section-start) + (should-not (re-search-forward "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}" carol-section-end t))))) + + ;; Clean up backup + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-integration-mixed-birthday-formats () + "Integration test with both YYYY-MM-DD and MM-DD formats." + (let ((temp-file (make-temp-file "test-contacts" nil ".org")) + (current-year (nth 5 (decode-time)))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice Anderson\n:PROPERTIES:\n:BIRTHDAY: 1985-03-15\n:END:\n\n") + (insert "* Bob Baker\n:PROPERTIES:\n:BIRTHDAY: 07-04\n:END:\n")) + + (chime--convert-contacts-file-in-place temp-file) + + (with-temp-buffer + (insert-file-contents temp-file) + (let ((content (buffer-string))) + ;; Alice has year from property + (should (string-match-p "<1985-03-15" content)) + ;; Bob uses current year + (should (string-match-p (format "<%d-07-04" current-year) content))))) + (when (file-exists-p temp-file) (delete-file temp-file)) + (let ((backup (concat temp-file ".backup-"))) + (dolist (file (directory-files (file-name-directory temp-file) t (regexp-quote (file-name-nondirectory backup)))) + (delete-file file)))))) + +(ert-deftest test-convert-integration-file-with-existing-content-preserved () + "Integration test verifying all existing content is preserved." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "#+TITLE: Contacts\n") + (insert "#+FILETAGS: :contacts:\n\n") + (insert "Some introductory text.\n\n") + (insert "* Alice Anderson\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert ":END:\n") + (insert "** Notes\n") + (insert "Some nested content.\n") + (insert "*** Deep nested\n") + (insert "More content.\n\n") + (insert "* Final Section\n") + (insert "Closing remarks.\n")) + + (chime--convert-contacts-file-in-place temp-file) + + (with-temp-buffer + (insert-file-contents temp-file) + (let ((content (buffer-string))) + ;; All content preserved + (should (string-search "Some introductory text" content)) + (should (string-search "** Notes" content)) + (should (string-search "Some nested content" content)) + (should (string-search "*** Deep nested" content)) + (should (string-search "Closing remarks" content)) + (should (string-search "#+FILETAGS: :contacts:" content))))) + (when (file-exists-p temp-file) (delete-file temp-file)) + (let ((backup (concat temp-file ".backup-"))) + (dolist (file (directory-files (file-name-directory temp-file) t (regexp-quote (file-name-nondirectory backup)))) + (delete-file file)))))) + +;;; Workflow Tests (Backup → Convert → Verify) + +(ert-deftest test-convert-workflow-backup-created-before-modification () + "Test that backup file is created before original is modified." + (let ((temp-file (make-temp-file "test-contacts" nil ".org")) + (original-content "* Alice Anderson\n:PROPERTIES:\n:BIRTHDAY: 2000-01-01\n:END:\n")) + (unwind-protect + (progn + (with-temp-file temp-file + (insert original-content)) + + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (backup-file (cdr result))) + + ;; Backup exists + (should (file-exists-p backup-file)) + + ;; Backup contains original content (unmodified) + (with-temp-buffer + (insert-file-contents backup-file) + (should (string= (buffer-string) original-content))) + + ;; Original file is modified + (with-temp-buffer + (insert-file-contents temp-file) + (should (string-match-p "<2000-01-01 [A-Za-z]\\{3\\} \\+1y>" (buffer-string)))) + + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-workflow-backup-content-matches-original () + "Test that backup file contains exact copy of original content." + (let ((temp-file (make-temp-file "test-contacts" nil ".org")) + (original-content "#+TITLE: Contacts\n\n* Alice\n:PROPERTIES:\n:BIRTHDAY: 1985-03-15\n:END:\n")) + (unwind-protect + (progn + (with-temp-file temp-file + (insert original-content)) + + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (backup-file (cdr result))) + + ;; Backup content matches original + (with-temp-buffer + (insert-file-contents backup-file) + (should (string= (buffer-string) original-content))) + + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-workflow-original-modified-with-timestamps () + "Test that original file is modified with timestamps after conversion." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice\n:PROPERTIES:\n:BIRTHDAY: 1985-03-15\n:END:\n") + (insert "* Bob\n:PROPERTIES:\n:BIRTHDAY: 1990-07-22\n:END:\n")) + + ;; Get original content + (let ((original-content nil)) + (with-temp-buffer + (insert-file-contents temp-file) + (setq original-content (buffer-string))) + + ;; Should not have timestamps before conversion + (should-not (string-match-p "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [A-Za-z]\\{3\\} \\+1y>" original-content)) + + ;; Convert + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (count (car result)) + (backup-file (cdr result))) + + (should (= count 2)) + + ;; Modified content should have timestamps + (with-temp-buffer + (insert-file-contents temp-file) + (let ((modified-content (buffer-string))) + (should (string-match-p "<1985-03-15 [A-Za-z]\\{3\\} \\+1y>" modified-content)) + (should (string-match-p "<1990-07-22 [A-Za-z]\\{3\\} \\+1y>" modified-content)))) + + (when (file-exists-p backup-file) (delete-file backup-file))))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-workflow-rollback-via-backup () + "Test that conversion can be rolled back by restoring from backup." + (let ((temp-file (make-temp-file "test-contacts" nil ".org")) + (original-content "* Alice\n:PROPERTIES:\n:BIRTHDAY: 2000-01-01\n:END:\n")) + (unwind-protect + (progn + (with-temp-file temp-file + (insert original-content)) + + ;; Convert + (let* ((result (chime--convert-contacts-file-in-place temp-file)) + (backup-file (cdr result))) + + ;; Verify file was modified + (with-temp-buffer + (insert-file-contents temp-file) + (should (string-match-p "<2000-01-01 [A-Za-z]\\{3\\} \\+1y>" (buffer-string)))) + + ;; Rollback by copying backup over original + (copy-file backup-file temp-file t) + + ;; Verify rollback worked + (with-temp-buffer + (insert-file-contents temp-file) + (let ((content (buffer-string))) + (should (string= content original-content)) + (should-not (string-match-p "<2000-01-01 [A-Za-z]\\{3\\} \\+1y>" content)))) + + (when (file-exists-p backup-file) (delete-file backup-file)))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(ert-deftest test-convert-workflow-multiple-backups-distinct-timestamps () + "Test that multiple conversions create backups with distinct timestamps." + (let ((temp-file (make-temp-file "test-contacts" nil ".org"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "* Alice\n:PROPERTIES:\n:BIRTHDAY: 2000-01-01\n:END:\n")) + + ;; First conversion + (let ((backup1 (cdr (chime--convert-contacts-file-in-place temp-file)))) + ;; Small delay to ensure different timestamp + (sleep-for 1) + ;; Second conversion + (let ((backup2 (cdr (chime--convert-contacts-file-in-place temp-file)))) + + ;; Backup files should have different names + (should-not (string= backup1 backup2)) + + ;; Both should exist + (should (file-exists-p backup1)) + (should (file-exists-p backup2)) + + (when (file-exists-p backup1) (delete-file backup1)) + (when (file-exists-p backup2) (delete-file backup2))))) + (when (file-exists-p temp-file) (delete-file temp-file))))) + +(provide 'test-convert-org-contacts-birthdays) +;;; test-convert-org-contacts-birthdays.el ends here diff --git a/tests/test-integration-recurring-events-tooltip.el b/tests/test-integration-recurring-events-tooltip.el new file mode 100644 index 0000000..6acfac3 --- /dev/null +++ b/tests/test-integration-recurring-events-tooltip.el @@ -0,0 +1,370 @@ +;;; test-integration-recurring-events-tooltip.el --- Integration tests for recurring events in tooltip -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Integration tests for bug001: Recurring Events Show Duplicate Entries in Tooltip +;; +;; Tests the complete workflow from org entries with recurring timestamps +;; through org-agenda-list expansion to final tooltip display. +;; +;; Components integrated: +;; - org-agenda-list (org-mode function that expands recurring events) +;; - chime--gather-info (extracts event information from org markers) +;; - chime--deduplicate-events-by-title (deduplicates expanded recurring events) +;; - chime--update-modeline (updates modeline and upcoming events list) +;; - chime--make-tooltip (generates tooltip from upcoming events) +;; +;; Validates: +;; - Recurring events expanded by org-agenda-list are deduplicated correctly +;; - Tooltip shows each recurring event only once (the soonest occurrence) +;; - Multiple different events are all preserved +;; - Mixed recurring and non-recurring events work correctly + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(setq chime-debug t) +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defvar test-integration-recurring--orig-agenda-files nil + "Original org-agenda-files value before test.") + +(defvar test-integration-recurring--orig-modeline-lookahead nil + "Original chime-modeline-lookahead-minutes value.") + +(defvar test-integration-recurring--orig-tooltip-lookahead nil + "Original chime-tooltip-lookahead-hours value.") + +(defun test-integration-recurring-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Save original values + (setq test-integration-recurring--orig-agenda-files org-agenda-files) + (setq test-integration-recurring--orig-modeline-lookahead chime-modeline-lookahead-minutes) + (setq test-integration-recurring--orig-tooltip-lookahead chime-tooltip-lookahead-hours) + ;; Set lookahead to 1 year for testing recurring events + (setq chime-modeline-lookahead-minutes 525600) ; 365 days + (setq chime-tooltip-lookahead-hours 8760)) ; 365 days + +(defun test-integration-recurring-teardown () + "Teardown function run after each test." + ;; Restore original values + (setq org-agenda-files test-integration-recurring--orig-agenda-files) + (setq chime-modeline-lookahead-minutes test-integration-recurring--orig-modeline-lookahead) + (setq chime-tooltip-lookahead-hours test-integration-recurring--orig-tooltip-lookahead) + (chime-delete-test-base-dir)) + +;;; Helper Functions + +(defun test-integration-recurring--create-org-file (content) + "Create org file with CONTENT and set it as org-agenda-files. +Returns the file path." + (let* ((base-file (chime-create-temp-test-file "recurring-test-")) + (org-file (concat base-file ".org"))) + ;; Rename to have .org extension + (rename-file base-file org-file) + ;; Write content to the .org file + (with-temp-buffer + (insert content) + (write-file org-file)) + ;; Set as agenda file + (setq org-agenda-files (list org-file)) + org-file)) + +(defun test-integration-recurring--run-agenda-and-gather-events (agenda-span) + "Run org-agenda-list with AGENDA-SPAN and gather event markers. +Returns list of event info gathered from markers." + (let ((markers nil) + (events nil) + (org-buffers nil)) + ;; Remember which buffers are open before agenda + (setq org-buffers (buffer-list)) + ;; org-agenda-list doesn't return the buffer, it creates "*Org Agenda*" + (org-agenda-list agenda-span (org-read-date nil nil "today")) + (with-current-buffer "*Org Agenda*" + ;; Extract all org-markers from agenda buffer + (setq markers + (->> (org-split-string (buffer-string) "\n") + (--map (plist-get + (org-fix-agenda-info (text-properties-at 0 it)) + 'org-marker)) + (-non-nil)))) + ;; Gather info for each marker BEFORE killing buffers + ;; (markers point to the org file buffers, which must stay alive) + (setq events (-map 'chime--gather-info markers)) + ;; Now kill agenda buffer to clean up + (when (get-buffer "*Org Agenda*") + (kill-buffer "*Org Agenda*")) + events)) + +(defun test-integration-recurring--count-in-string (regexp string) + "Count occurrences of REGEXP in STRING." + (let ((count 0) + (start 0)) + (while (string-match regexp string start) + (setq count (1+ count)) + (setq start (match-end 0))) + count)) + +;;; Normal Cases - Recurring Event Deduplication + +(ert-deftest test-integration-recurring-events-tooltip-daily-repeater-shows-once () + "Test that daily recurring event appears only once in tooltip. + +When org-agenda-list expands a daily recurring event (e.g., +1d) over a +year-long lookahead window, it creates ~365 separate agenda entries. +The tooltip should show only the soonest occurrence, not all 365. + +Components integrated: +- org-agenda-list (expands recurring events into separate instances) +- chime--gather-info (extracts info from each expanded marker) +- chime--deduplicate-events-by-title (removes duplicate titles) +- chime--update-modeline (updates upcoming events list) +- chime--make-tooltip (generates tooltip display) + +Validates: +- Recurring event expanded 365 times is deduplicated to one entry +- Tooltip shows event title exactly once +- The shown occurrence is the soonest one" + (test-integration-recurring-setup) + (unwind-protect + (let* ((now (test-time-now)) + (event-time (test-time-today-at 22 0)) ; 10 PM today + (timestamp (test-timestamp-repeating event-time "+1d")) + (content (format "* Daily Wrap Up\n%s\n" timestamp))) + + ;; Create org file with recurring event + (test-integration-recurring--create-org-file content) + + (with-test-time now + ;; Run org-agenda-list to expand recurring events (365 days) + (let* ((events (test-integration-recurring--run-agenda-and-gather-events 365))) + + ;; Verify org-agenda-list expanded the recurring event multiple times + ;; (should be ~365 instances, one per day) + (should (> (length events) 300)) + + ;; All events should have the same title + (let ((titles (mapcar (lambda (e) (cdr (assoc 'title e))) events))) + (should (cl-every (lambda (title) (string= "Daily Wrap Up" title)) titles))) + + ;; Update modeline with these events (this triggers deduplication) + (chime--update-modeline events) + + ;; The upcoming events list should have only ONE entry + (should (= 1 (length chime--upcoming-events))) + + ;; Verify it's the right event + (let* ((item (car chime--upcoming-events)) + (event (car item)) + (title (cdr (assoc 'title event)))) + (should (string= "Daily Wrap Up" title))) + + ;; Generate tooltip and verify event appears only once + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (should (stringp tooltip)) + ;; Count occurrences of "Daily Wrap Up" in tooltip + (let ((count (test-integration-recurring--count-in-string + "Daily Wrap Up" tooltip))) + (should (= 1 count))))))) + (test-integration-recurring-teardown))) + +(ert-deftest test-integration-recurring-events-tooltip-weekly-repeater-shows-once () + "Test that weekly recurring event appears only once in tooltip. + +Weekly repeater (+1w) over 1 year creates ~52 instances. +Should be deduplicated to show only the soonest. + +Components integrated: +- org-agenda-list (expands +1w into 52 instances) +- chime--gather-info (processes each instance) +- chime--deduplicate-events-by-title (deduplicates by title) +- chime--update-modeline (updates events list) +- chime--make-tooltip (generates display) + +Validates: +- Weekly recurring events are deduplicated correctly +- Tooltip shows only soonest occurrence" + (test-integration-recurring-setup) + (unwind-protect + (let* ((now (test-time-now)) + (event-time (test-time-tomorrow-at 14 0)) ; 2 PM tomorrow + (timestamp (test-timestamp-repeating event-time "+1w")) + (content (format "* Weekly Team Sync\n%s\n" timestamp))) + + (test-integration-recurring--create-org-file content) + + (with-test-time now + (let* ((events (test-integration-recurring--run-agenda-and-gather-events 365))) + + ;; Should have ~52 weekly instances + (should (> (length events) 45)) + (should (< (length events) 60)) + + ;; Update modeline + (chime--update-modeline events) + + ;; Should be deduplicated to one + (should (= 1 (length chime--upcoming-events))) + + ;; Verify tooltip shows it once + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (let ((count (test-integration-recurring--count-in-string + "Weekly Team Sync" tooltip))) + (should (= 1 count))))))) + (test-integration-recurring-teardown))) + +(ert-deftest test-integration-recurring-events-tooltip-mixed-recurring-and-unique () + "Test mix of recurring and non-recurring events in tooltip. + +When multiple events exist, some recurring and some not, all should +appear but recurring ones should be deduplicated. + +Components integrated: +- org-agenda-list (expands only the recurring event) +- chime--gather-info (processes all events) +- chime--deduplicate-events-by-title (deduplicates recurring only) +- chime--update-modeline (updates events list) +- chime--make-tooltip (generates display) + +Validates: +- Recurring event appears once +- Non-recurring events all appear +- Total count is correct (recurring deduplicated, others preserved)" + (test-integration-recurring-setup) + (unwind-protect + (let* ((now (test-time-now)) + (daily-time (test-time-today-at 22 0)) + (meeting-time (test-time-tomorrow-at 14 0)) + (review-time (test-time-days-from-now 2 15 0)) + (daily-ts (test-timestamp-repeating daily-time "+1d")) + (meeting-ts (test-timestamp-string meeting-time)) + (review-ts (test-timestamp-string review-time)) + (content (concat + (format "* Daily Standup\n%s\n\n" daily-ts) + (format "* Project Review\n%s\n\n" meeting-ts) + (format "* Client Meeting\n%s\n" review-ts)))) + + (test-integration-recurring--create-org-file content) + + (with-test-time now + (let* ((events (test-integration-recurring--run-agenda-and-gather-events 365))) + + ;; Should have many events due to daily repeater + (should (> (length events) 300)) + + ;; Update modeline (triggers deduplication) + (chime--update-modeline events) + + ;; Should have exactly 3 events (daily deduplicated + 2 unique) + (should (= 3 (length chime--upcoming-events))) + + ;; Verify all three titles appear + (let ((titles (mapcar (lambda (item) + (cdr (assoc 'title (car item)))) + chime--upcoming-events))) + (should (member "Daily Standup" titles)) + (should (member "Project Review" titles)) + (should (member "Client Meeting" titles)) + ;; No duplicates + (should (= 3 (length (delete-dups (copy-sequence titles)))))) + + ;; Verify tooltip shows each event once + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (should (= 1 (test-integration-recurring--count-in-string + "Daily Standup" tooltip))) + (should (= 1 (test-integration-recurring--count-in-string + "Project Review" tooltip))) + (should (= 1 (test-integration-recurring--count-in-string + "Client Meeting" tooltip))))))) + (test-integration-recurring-teardown))) + +;;; Boundary Cases + +(ert-deftest test-integration-recurring-events-tooltip-multiple-different-recurring () + "Test multiple different recurring events are all shown once. + +When there are multiple recurring events with different titles, +each should appear once in the tooltip. + +Components integrated: +- org-agenda-list (expands all recurring events) +- chime--gather-info (processes all expanded instances) +- chime--deduplicate-events-by-title (deduplicates each title separately) +- chime--update-modeline (updates events list) +- chime--make-tooltip (generates display) + +Validates: +- Each recurring event title appears exactly once +- Different recurring frequencies handled correctly +- Deduplication works independently for each title" + (test-integration-recurring-setup) + (unwind-protect + (let* ((now (test-time-now)) + (daily-time (test-time-today-at 11 0)) ; 11 AM (after 10 AM now) + (weekly-time (test-time-tomorrow-at 14 0)) ; 2 PM tomorrow + (daily-ts (test-timestamp-repeating daily-time "+1d")) + (weekly-ts (test-timestamp-repeating weekly-time "+1w")) + (content (concat + (format "* Daily Standup\n%s\n\n" daily-ts) + (format "* Weekly Review\n%s\n" weekly-ts)))) + + (test-integration-recurring--create-org-file content) + + (with-test-time now + (let* ((events (test-integration-recurring--run-agenda-and-gather-events 365))) + + ;; Should have many events (~365 daily + ~52 weekly) + (should (> (length events) 400)) + + ;; Update modeline + (chime--update-modeline events) + + ;; Should be deduplicated to 2 events + (should (= 2 (length chime--upcoming-events))) + + ;; Verify tooltip shows each once + (let ((tooltip (chime--make-tooltip chime--upcoming-events))) + (should (= 1 (test-integration-recurring--count-in-string + "Daily Standup" tooltip))) + (should (= 1 (test-integration-recurring--count-in-string + "Weekly Review" tooltip))))))) + (test-integration-recurring-teardown))) + +(provide 'test-integration-recurring-events-tooltip) +;;; test-integration-recurring-events-tooltip.el ends here diff --git a/tests/test-integration-startup.el b/tests/test-integration-startup.el new file mode 100644 index 0000000..f7e0861 --- /dev/null +++ b/tests/test-integration-startup.el @@ -0,0 +1,328 @@ +;;; test-integration-startup.el --- Integration tests for chime startup flow -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Integration tests for chime startup and configuration validation. +;; +;; Tests the complete startup workflow: +;; - Valid org-agenda-files configuration +;; - chime-validate-configuration checks +;; - chime-check async event gathering +;; - Modeline population +;; +;; Components integrated: +;; - chime-validate-configuration (validates runtime requirements) +;; - chime-check (async event gathering via org-agenda-list) +;; - chime--update-modeline (updates modeline string) +;; - org-agenda-list (expands events from org files) +;; +;; Validates: +;; - Chime finds correct number of events from org-agenda-files +;; - Validation passes with proper configuration +;; - Modeline gets populated after check completes +;; - Mixed event types (scheduled, deadline, TODO states) work correctly + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) + +;; Load dependencies required by chime +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) + +;; Load chime from parent directory +(setq chime-debug t) +(load (expand-file-name "../chime.el") nil t) + +;; Load test utilities +(require 'testutil-general (expand-file-name "testutil-general.el")) +(require 'testutil-time (expand-file-name "testutil-time.el")) + +;;; Setup and Teardown + +(defvar test-integration-startup--orig-agenda-files nil + "Original org-agenda-files value before test.") + +(defvar test-integration-startup--orig-startup-delay nil + "Original chime-startup-delay value.") + +(defvar test-integration-startup--orig-modeline-lookahead nil + "Original chime-modeline-lookahead-minutes value.") + +(defvar test-integration-startup--orig-tooltip-lookahead nil + "Original chime-tooltip-lookahead-hours value.") + +(defun test-integration-startup-setup () + "Setup function run before each test." + (chime-create-test-base-dir) + ;; Save original values + (setq test-integration-startup--orig-agenda-files org-agenda-files) + (setq test-integration-startup--orig-startup-delay chime-startup-delay) + (setq test-integration-startup--orig-modeline-lookahead chime-modeline-lookahead-minutes) + (setq test-integration-startup--orig-tooltip-lookahead chime-tooltip-lookahead-hours) + ;; Set short lookahead for faster tests + (setq chime-modeline-lookahead-minutes (* 24 60)) ; 24 hours + (setq chime-tooltip-lookahead-hours 24) ; 24 hours + (setq chime-startup-delay 1)) ; 1 second for tests + +(defun test-integration-startup-teardown () + "Teardown function run after each test." + ;; Restore original values + (setq org-agenda-files test-integration-startup--orig-agenda-files) + (setq chime-startup-delay test-integration-startup--orig-startup-delay) + (setq chime-modeline-lookahead-minutes test-integration-startup--orig-modeline-lookahead) + (setq chime-tooltip-lookahead-hours test-integration-startup--orig-tooltip-lookahead) + (chime-delete-test-base-dir)) + +;;; Helper Functions + +(defun test-integration-startup--create-org-file (content) + "Create org file with CONTENT and set it as org-agenda-files. +Returns the file path." + (let* ((base-file (chime-create-temp-test-file "startup-test-")) + (org-file (concat base-file ".org"))) + ;; Rename to have .org extension + (rename-file base-file org-file) + ;; Write content to the .org file + (with-temp-buffer + (insert content) + (write-file org-file)) + ;; Set as agenda file + (setq org-agenda-files (list org-file)) + org-file)) + +;;; Normal Cases - Valid Startup Configuration + +(ert-deftest test-integration-startup-valid-config-finds-events () + "Test that chime-check finds events with valid org-agenda-files configuration. + +This is the core startup integration test. Validates that when: +- org-agenda-files is properly configured with real .org files +- Files contain scheduled/deadline events +- chime-check is called (normally triggered by startup timer) + +Then: +- Events are successfully gathered from org-agenda-list +- Event count matches expected number +- Modeline string gets populated + +Components integrated: +- org-agenda-files (user configuration) +- org-agenda-list (expands events from org files) +- chime-check (async wrapper around event gathering) +- chime--gather-info (extracts event details) +- chime--update-modeline (updates modeline display)" + (test-integration-startup-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Create events at various times + (event1-time (test-time-at 0 2 0)) ; 2 hours from now + (event2-time (test-time-at 0 5 0)) ; 5 hours from now + (event3-time (test-time-at 1 0 0)) ; Tomorrow same time + (event4-time (test-time-at -1 0 0)) ; Yesterday (overdue) + ;; Generate timestamps + (ts1 (test-timestamp-string event1-time)) + (ts2 (test-timestamp-string event2-time)) + (ts3 (test-timestamp-string event3-time)) + (ts4 (test-timestamp-string event4-time)) + ;; Create org file content + (content (format "#+TITLE: Startup Test Events + +* TODO Event in 2 hours +SCHEDULED: %s + +* TODO Event in 5 hours +SCHEDULED: %s + +* TODO Event tomorrow +SCHEDULED: %s + +* TODO Overdue event +SCHEDULED: %s + +* DONE Completed event (should not notify) +SCHEDULED: %s +" ts1 ts2 ts3 ts4 ts1))) + + ;; Create org file and set as agenda files + (test-integration-startup--create-org-file content) + + ;; Validate configuration should pass + (let ((issues (chime-validate-configuration))) + (should (null issues))) + + (with-test-time now + ;; Call chime-check synchronously (bypasses async/timer for test reliability) + ;; In real startup, this is called via run-at-time after chime-startup-delay + (let ((event-count 0)) + ;; Mock the async-start to run synchronously for testing + (cl-letf (((symbol-function 'async-start) + (lambda (start-func finish-func) + ;; Call start-func synchronously and pass result to finish-func + (funcall finish-func (funcall start-func))))) + ;; Now call chime-check - it will run synchronously + (chime-check) + + ;; Give it a moment to process + (sleep-for 0.1) + + ;; Verify modeline was updated + (should chime-modeline-string) + + ;; Verify we found events (should be 4 TODO events, DONE excluded) + ;; Note: The exact behavior depends on chime's filtering logic + (should chime--upcoming-events) + (setq event-count (length chime--upcoming-events)) + + ;; Should find at least the non-DONE events within lookahead window + (should (>= event-count 3)))))) ; At least 3 events (2h, 5h, tomorrow) + (test-integration-startup-teardown))) + +(ert-deftest test-integration-startup-validation-passes-minimal-config () + "Test validation passes with minimal valid configuration. + +Validates that chime-validate-configuration returns nil (no issues) when: +- org-agenda-files is set to a list with at least one .org file +- The file exists on disk +- org-agenda package is loadable +- All other dependencies are available + +This ensures the startup validation doesn't block legitimate configurations." + (test-integration-startup-setup) + (unwind-protect + (let ((content "#+TITLE: Minimal Test\n\n* TODO Test event\nSCHEDULED: <2025-12-01 Mon 10:00>\n")) + ;; Create minimal org file + (test-integration-startup--create-org-file content) + + ;; Validation should pass + (let ((issues (chime-validate-configuration))) + (should (null issues)))) + (test-integration-startup-teardown))) + +;;; Error Cases - Configuration Failures + +(ert-deftest test-integration-startup-early-return-on-validation-failure () + "Test that chime-check returns early when validation fails without throwing errors. + +This is a regression test for the bug where chime-check used cl-return-from +without being defined as cl-defun, causing '(no-catch --cl-block-chime-check-- nil)' error. + +When validation fails on first check, chime-check should: +- Log the validation failure +- Return nil early (via cl-return-from) +- NOT throw 'no-catch' error +- NOT proceed to event gathering + +This validates the early-return mechanism works correctly." + (test-integration-startup-setup) + (unwind-protect + (progn + ;; Set up invalid configuration (empty org-agenda-files) + (setq org-agenda-files nil) + + ;; Reset validation state so chime-check will validate on next call + (setq chime--validation-done nil) + + ;; Call chime-check - should return early without error + ;; Before the fix, this would throw: (no-catch --cl-block-chime-check-- nil) + (let ((result (chime-check))) + + ;; Should return nil (early return from validation failure) + (should (null result)) + + ;; Validation should NOT be marked done when it fails + ;; (so it can retry on next check in case dependencies load later) + (should (null chime--validation-done)) + + ;; Should NOT have processed any events (early return worked) + (should (null chime--upcoming-events)) + (should (null chime-modeline-string)))) + (test-integration-startup-teardown))) + +;;; Boundary Cases - Edge Conditions + +(ert-deftest test-integration-startup-single-event-found () + "Test that chime-check correctly finds a single event. + +Boundary case: org-agenda-files with only one event. +Validates that the gathering and modeline logic work with minimal data." + (test-integration-startup-setup) + (unwind-protect + (let* ((now (test-time-now)) + (event-time (test-time-at 0 1 0)) ; 1 hour from now + (ts (test-timestamp-string event-time)) + (content (format "* TODO Single Event\nSCHEDULED: %s\n" ts))) + + (test-integration-startup--create-org-file content) + + (with-test-time now + (cl-letf (((symbol-function 'async-start) + (lambda (start-func finish-func) + (funcall finish-func (funcall start-func))))) + (chime-check) + (sleep-for 0.1) + + ;; Should find exactly 1 event + (should (= 1 (length chime--upcoming-events))) + + ;; Modeline should be populated + (should chime-modeline-string) + (should (string-match-p "Single Event" chime-modeline-string))))) + (test-integration-startup-teardown))) + +(ert-deftest test-integration-startup-no-upcoming-events () + "Test chime-check when org file has no upcoming events within lookahead. + +Boundary case: Events exist but are far in the future (beyond lookahead window). +Validates that chime doesn't error and modeline shows appropriate state." + (test-integration-startup-setup) + (unwind-protect + (let* ((now (test-time-now)) + ;; Event 30 days from now (beyond 24-hour lookahead) + (event-time (test-time-at 30 0 0)) + (ts (test-timestamp-string event-time)) + (content (format "* TODO Future Event\nSCHEDULED: %s\n" ts))) + + (test-integration-startup--create-org-file content) + + (with-test-time now + (cl-letf (((symbol-function 'async-start) + (lambda (start-func finish-func) + (funcall finish-func (funcall start-func))))) + (chime-check) + (sleep-for 0.1) + + ;; Should find 0 events within lookahead window + (should (or (null chime--upcoming-events) + (= 0 (length chime--upcoming-events)))) + + ;; Modeline should handle this gracefully (nil or empty) + ;; No error should occur + ))) + (test-integration-startup-teardown))) + +(provide 'test-integration-startup) +;;; test-integration-startup.el ends here diff --git a/tests/tests-autoloads.el b/tests/tests-autoloads.el new file mode 100644 index 0000000..88449cb --- /dev/null +++ b/tests/tests-autoloads.el @@ -0,0 +1,148 @@ +;;; tests-autoloads.el --- automatically extracted autoloads (do not edit) -*- lexical-binding: t -*- +;; Generated by the `loaddefs-generate' function. + +;; This file is part of GNU Emacs. + +;;; Code: + +(add-to-list 'load-path (or (and load-file-name (directory-file-name (file-name-directory load-file-name))) (car load-path))) + + + +;;; Generated autoloads from test-chime-all-day-events.el + +(register-definition-prefixes "test-chime-all-day-events" '("test-allday--create-event")) + + +;;; Generated autoloads from test-chime-apply-blacklist.el + +(register-definition-prefixes "test-chime-apply-blacklist" '("test-chime-apply-blacklist-")) + + +;;; Generated autoloads from test-chime-apply-whitelist.el + +(register-definition-prefixes "test-chime-apply-whitelist" '("test-chime-apply-whitelist-")) + + +;;; Generated autoloads from test-chime-check-event.el + +(register-definition-prefixes "test-chime-check-event" '("test-chime-check-event-")) + + +;;; Generated autoloads from test-chime-check-interval.el + +(register-definition-prefixes "test-chime-check-interval" '("test-chime-check-interval-")) + + +;;; Generated autoloads from test-chime-extract-time.el + +(register-definition-prefixes "test-chime-extract-time" '("test-chime-extract-time-")) + + +;;; Generated autoloads from test-chime-format-event-for-tooltip.el + +(register-definition-prefixes "test-chime-format-event-for-tooltip" '("test-chime-format-event-for-tooltip-")) + + +;;; Generated autoloads from test-chime-gather-info.el + +(register-definition-prefixes "test-chime-gather-info" '("test-chime-gather-info-")) + + +;;; Generated autoloads from test-chime-group-events-by-day.el + +(register-definition-prefixes "test-chime-group-events-by-day" '("test-chime-")) + + +;;; Generated autoloads from test-chime-has-timestamp.el + +(register-definition-prefixes "test-chime-has-timestamp" '("test-chime-has-timestamp-")) + + +;;; Generated autoloads from test-chime-modeline.el + +(register-definition-prefixes "test-chime-modeline" '("test-chime-modeline-")) + + +;;; Generated autoloads from test-chime-notification-text.el + +(register-definition-prefixes "test-chime-notification-text" '("test-chime-notification-text-")) + + +;;; Generated autoloads from test-chime-notifications.el + +(register-definition-prefixes "test-chime-notifications" '("test-chime-notifications-")) + + +;;; Generated autoloads from test-chime-notify.el + +(register-definition-prefixes "test-chime-notify" '("test-chime-notify-")) + + +;;; Generated autoloads from test-chime-overdue-todos.el + +(register-definition-prefixes "test-chime-overdue-todos" '("test-")) + + +;;; Generated autoloads from test-chime-process-notifications.el + +(register-definition-prefixes "test-chime-process-notifications" '("test-chime-process-notifications-")) + + +;;; Generated autoloads from test-chime-sanitize-title.el + +(register-definition-prefixes "test-chime-sanitize-title" '("test-chime-sanitize-title-")) + + +;;; Generated autoloads from test-chime-time-left.el + +(register-definition-prefixes "test-chime-time-left" '("test-chime-time-left-")) + + +;;; Generated autoloads from test-chime-timestamp-parse.el + +(register-definition-prefixes "test-chime-timestamp-parse" '("test-chime-timestamp-parse-")) + + +;;; Generated autoloads from test-chime-timestamp-within-interval-p.el + +(register-definition-prefixes "test-chime-timestamp-within-interval-p" '("test-chime-timestamp-within-interval-p-")) + + +;;; Generated autoloads from test-chime-tooltip-bugs.el + +(register-definition-prefixes "test-chime-tooltip-bugs" '("test-tooltip-bugs-")) + + +;;; Generated autoloads from test-chime-update-modeline.el + +(register-definition-prefixes "test-chime-update-modeline" '("test-chime-update-modeline-")) + + +;;; Generated autoloads from test-chime-whitelist-blacklist-conflicts.el + +(register-definition-prefixes "test-chime-whitelist-blacklist-conflicts" '("test-chime-conflicts-")) + + +;;; Generated autoloads from testutil-general.el + +(register-definition-prefixes "testutil-general" '("chime-")) + + +;;; Generated autoloads from testutil-time.el + +(register-definition-prefixes "testutil-time" '("test-time" "with-test-time")) + +;;; End of scraped data + +(provide 'tests-autoloads) + +;; Local Variables: +;; version-control: never +;; no-byte-compile: t +;; no-update-autoloads: t +;; no-native-compile: t +;; coding: utf-8-emacs-unix +;; End: + +;;; tests-autoloads.el ends here diff --git a/tests/testutil-events.el b/tests/testutil-events.el new file mode 100644 index 0000000..c69739e --- /dev/null +++ b/tests/testutil-events.el @@ -0,0 +1,214 @@ +;;; testutil-events.el --- Event creation and gathering utilities for tests -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Utilities for creating and gathering events in tests. +;; Reduces duplication across test files that work with org events. +;; +;; Key functions: +;; - test-create-org-event: Create org event content string +;; - test-gather-events-from-content: Create file, gather events, clean up +;; - test-make-event-data: Create event data structure programmatically + +;;; Code: + +(require 'testutil-general) +(require 'testutil-time) + +;;; Event Content Creation + +(defun test-create-org-event (title time &optional scheduled-p all-day-p) + "Create org event content string with TITLE at TIME. +If SCHEDULED-P is non-nil, use SCHEDULED: keyword (default is plain timestamp). +If ALL-DAY-P is non-nil, create all-day event without time component. +Returns formatted org content string. + +Examples: + (test-create-org-event \"Meeting\" (test-time-now)) + => \"* Meeting\\n<2025-01-15 Wed 10:00>\\n\" + + (test-create-org-event \"Call\" (test-time-now) t) + => \"* TODO Call\\nSCHEDULED: <2025-01-15 Wed 10:00>\\n\" + + (test-create-org-event \"Birthday\" (test-time-now) nil t) + => \"* Birthday\\n<2025-01-15 Wed>\\n\"" + (let ((timestamp (test-timestamp-string time all-day-p)) + (todo-kw (if scheduled-p "TODO " ""))) + (if scheduled-p + (format "* %s%s\nSCHEDULED: %s\n" todo-kw title timestamp) + (format "* %s\n%s\n" title timestamp)))) + +(defun test-create-org-events (event-specs) + "Create multiple org events from EVENT-SPECS list. +Each spec is (TITLE TIME &optional SCHEDULED-P ALL-DAY-P). +Returns concatenated org content string. + +Example: + (test-create-org-events + '((\"Meeting\" ,(test-time-at 0 2 0) t) + (\"Call\" ,(test-time-at 0 4 0) t))) + => \"* TODO Meeting\\nSCHEDULED: ...\\n* TODO Call\\nSCHEDULED: ...\\n\"" + (mapconcat (lambda (spec) + (apply #'test-create-org-event spec)) + event-specs + "\n")) + +;;; Event Gathering + +(defun test-gather-events-from-content (content) + "Create temp org file with CONTENT, gather events using chime--gather-info. +Returns list of event data structures. +Automatically creates and cleans up buffer. + +Example: + (let* ((content (test-create-org-event \"Meeting\" (test-time-now) t)) + (events (test-gather-events-from-content content))) + (should (= 1 (length events))) + (should (string= \"Meeting\" (cdr (assoc 'title (car events))))))" + (let* ((test-file (chime-create-temp-test-file-with-content content)) + (test-buffer (find-file-noselect test-file)) + (events nil)) + (unwind-protect + (with-current-buffer test-buffer + (org-mode) + (goto-char (point-min)) + (while (re-search-forward "^\\*+ " nil t) + (beginning-of-line) + (push (chime--gather-info (point-marker)) events) + (forward-line 1)) + (nreverse events)) + (kill-buffer test-buffer)))) + +(defun test-gather-single-event-from-content (content) + "Like test-gather-events-from-content but returns single event (not list). +Signals error if content contains multiple events. +Useful when test expects exactly one event. + +Example: + (let* ((content (test-create-org-event \"Call\" (test-time-now) t)) + (event (test-gather-single-event-from-content content))) + (should (string= \"Call\" (cdr (assoc 'title event)))))" + (let ((events (test-gather-events-from-content content))) + (unless (= 1 (length events)) + (error "Expected exactly 1 event, found %d" (length events))) + (car events))) + +;;; Event Data Structure Creation + +(defun test-make-event-data (title time-alist &optional intervals) + "Create event data structure programmatically. +TITLE is the event title string. +TIME-ALIST is list of (TIMESTAMP-STR . TIME-OBJECT) cons cells. +INTERVALS is optional list of (MINUTES . SEVERITY) cons cells. + +This is useful for creating events without going through org-mode parsing. + +Example: + (let* ((time (test-time-now)) + (ts-str (test-timestamp-string time)) + (event (test-make-event-data + \"Meeting\" + (list (cons ts-str time)) + '((10 . medium))))) + (should (string= \"Meeting\" (cdr (assoc 'title event)))))" + `((times . ,time-alist) + (title . ,title) + (intervals . ,(or intervals '((10 . medium)))))) + +(defun test-make-simple-event (title time &optional interval-minutes severity) + "Create simple event data structure with single time and interval. +TITLE is event title. +TIME is the event time (Emacs time object). +INTERVAL-MINUTES defaults to 10. +SEVERITY defaults to 'medium. + +Convenience wrapper around test-make-event-data for common case. + +Example: + (let ((event (test-make-simple-event \"Call\" (test-time-now) 5 'high))) + (should (string= \"Call\" (cdr (assoc 'title event)))))" + (let* ((ts-str (test-timestamp-string time)) + (interval (or interval-minutes 10)) + (sev (or severity 'medium))) + (test-make-event-data title + (list (cons ts-str time)) + (list (cons interval sev))))) + +;;; Macros for Common Test Patterns + +(defmacro with-test-event-file (content &rest body) + "Create temp org file with CONTENT, execute BODY, clean up. +Binds `test-file' and `test-buffer' in BODY. + +Example: + (with-test-event-file (test-create-org-event \"Meeting\" (test-time-now)) + (with-current-buffer test-buffer + (goto-char (point-min)) + (should (search-forward \"Meeting\" nil t))))" + (declare (indent 1)) + `(let* ((test-file (chime-create-temp-test-file-with-content ,content)) + (test-buffer (find-file-noselect test-file))) + (unwind-protect + (progn ,@body) + (kill-buffer test-buffer)))) + +(defmacro with-gathered-events (content events-var &rest body) + "Create temp file with CONTENT, gather events into EVENTS-VAR, execute BODY. +Automatically creates file, gathers events, and cleans up. + +Example: + (with-gathered-events (test-create-org-event \"Call\" (test-time-now)) + events + (should (= 1 (length events))) + (should (string= \"Call\" (cdr (assoc 'title (car events))))))" + (declare (indent 2)) + `(let ((,events-var (test-gather-events-from-content ,content))) + ,@body)) + +;;; Setup/Teardown Helpers + +(defun test-standard-setup () + "Standard setup for tests: create test base dir. +Most tests can use this instead of custom setup function." + (chime-create-test-base-dir)) + +(defun test-standard-teardown () + "Standard teardown for tests: delete test base dir. +Most tests can use this instead of custom teardown function." + (chime-delete-test-base-dir)) + +(defmacro with-test-setup (&rest body) + "Execute BODY with standard test setup/teardown. +Ensures test base dir is created before and cleaned up after. + +Example: + (ert-deftest test-something () + (with-test-setup + (let ((file (chime-create-temp-test-file))) + (should (file-exists-p file)))))" + (declare (indent 0)) + `(progn + (test-standard-setup) + (unwind-protect + (progn ,@body) + (test-standard-teardown)))) + +(provide 'testutil-events) +;;; testutil-events.el ends here diff --git a/tests/testutil-general.el b/tests/testutil-general.el new file mode 100644 index 0000000..556e520 --- /dev/null +++ b/tests/testutil-general.el @@ -0,0 +1,184 @@ +;;; testutil-general.el --- -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; This library provides general helper functions and constants for managing +;; test directories and files across test suites. +;; +;; It establishes a user-local hidden directory as the root for all test assets, +;; provides utilities to create this directory safely, create temporary files +;; and subdirectories within it, and clean up after tests. +;; +;; This library should be required by test suites to ensure consistent, +;; reliable, and isolated file-system resources. +;; +;;; Code: + +(defconst chime-test-base-dir + (expand-file-name "~/.temp-chime-tests/") + "Base directory for all CHIME test files and directories. +All test file-system artifacts should be created under this hidden +directory in the user's home. This avoids relying on ephemeral system +directories like /tmp and reduces flaky test failures caused by external +cleanup.") + +(defun chime-create-test-base-dir () + "Create the test base directory `chime-test-base-dir' if it does not exist. +Returns the absolute path to the test base directory. +Signals an error if creation fails." + (let ((dir (file-name-as-directory chime-test-base-dir))) + (unless (file-directory-p dir) + (make-directory dir t)) + (if (file-directory-p dir) dir + (error "Failed to create test base directory %s" dir)))) + +(defun chime-create--directory-ensuring-parents (dirpath) + "Create nested directories specified by DIRPATH. +Error if DIRPATH exists already. +Ensure DIRPATH is within `chime-test-base-dir`." + (let* ((base (file-name-as-directory chime-test-base-dir)) + (fullpath (expand-file-name dirpath base))) + (unless (string-prefix-p base fullpath) + (error "Directory path %s is outside base test directory %s" fullpath base)) + (when (file-exists-p fullpath) + (error "Directory path already exists: %s" fullpath)) + (make-directory fullpath t) + fullpath)) + +(defun chime-create--file-ensuring-parents (filepath content &optional executable) + "Create file at FILEPATH (relative to `chime-test-base-dir`) with CONTENT. +Error if file exists already. +Create parent directories as needed. +If EXECUTABLE is non-nil, set execute permissions on file. +Ensure FILEPATH is within `chime-test-base-dir`." + (let* ((base (file-name-as-directory chime-test-base-dir)) + (fullpath (expand-file-name filepath base)) + (parent-dir (file-name-directory fullpath))) + (unless (string-prefix-p base fullpath) + (error "File path %s is outside base test directory %s" fullpath base)) + (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 + (when content + (insert content)) + (write-file fullpath)) + (when executable + (chmod fullpath #o755)) + fullpath)) + +(defun chime-create-directory-or-file-ensuring-parents (path &optional content executable) + "Create a directory or file specified by PATH relative to `chime-test-base-dir`. +If PATH ends with a slash, create nested directories. +Else create a file with optional CONTENT. +If EXECUTABLE is non-nil and creating a file, set executable permissions. +Error if the target path already exists. +Return the full created path." + (let ((is-dir (string-suffix-p "/" path))) + (if is-dir + (chime-create--directory-ensuring-parents path) + (chime-create--file-ensuring-parents path content executable)))) + + +;; (defun chime-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 `chime-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 `chime-test-base-dir`." +;; (let* ((base (file-name-as-directory chime-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 chime-fix-permissions-recursively (dir) + "Recursively set read/write permissions for user under DIR. +Directories get user read, write, and execute permissions to allow recursive +operations." + (when (file-directory-p dir) + (dolist (entry (directory-files-recursively dir ".*" t)) + (when (file-exists-p entry) + (let* ((attrs (file-attributes entry)) + (is-dir (car attrs)) + (mode (file-modes entry)) + (user-r (logand #o400 mode)) + (user-w (logand #o200 mode)) + (user-x (logand #o100 mode)) + new-mode) + (setq new-mode mode) + (unless user-r + (setq new-mode (logior new-mode #o400))) + (unless user-w + (setq new-mode (logior new-mode #o200))) + (when is-dir + ;; Ensure user-execute for directories + (unless user-x + (setq new-mode (logior new-mode #o100)))) + (unless (= mode new-mode) + (set-file-modes entry new-mode))))))) + +(defun chime-delete-test-base-dir () + "Recursively delete test base directory `chime-test-base-dir' and contents. +Ensures all contained files and directories have user read/write permissions +so deletion is not blocked by permissions. +After deletion, verifies that the directory no longer exists. +Signals an error if the directory still exists after deletion attempt." + (let ((dir (file-name-as-directory chime-test-base-dir))) + (when (file-directory-p dir) + (chime-fix-permissions-recursively dir) + (delete-directory dir t)) + (when (file-directory-p dir) + (error "Test base directory %s still exists after deletion" dir)))) + +(defun chime-create-temp-test-file (&optional prefix) + "Create a uniquely named temporary file under `chime-test-base-dir'. +Optional argument PREFIX is a string to prefix the filename, defaults +to \"tempfile-\". Returns the absolute path to the newly created empty file. +Errors if base test directory cannot be created or file creation fails." + (let ((base (chime-create-test-base-dir)) + (file nil)) + (setq file (make-temp-file (expand-file-name (or prefix "tempfile-") base))) + (unless (file-exists-p file) + (error "Failed to create temporary test file under %s" base)) + file)) + +(defun chime-create-test-subdirectory (subdir) + "Ensure subdirectory SUBDIR (relative to `chime-test-base-dir') exists. +Creates parent directories as needed. +Returns the absolute path to the subdirectory. +Signals an error if creation fails. +SUBDIR must be a relative path string." + (let* ((base (chime-create-test-base-dir)) + (fullpath (expand-file-name subdir base))) + (unless (file-directory-p fullpath) + (make-directory fullpath t)) + (if (file-directory-p fullpath) fullpath + (error "Failed to create test subdirectory %s" subdir)))) + +(defun chime-create-temp-test-file-with-content (content &optional prefix) + "Create uniquely named temp file in =chime-test-base-dir= and write CONTENT to it. +Optional PREFIX is a filename prefix string, default \"tempfile-\". +Returns absolute path to the created file." + (let ((file (chime-create-temp-test-file prefix))) + (with-temp-buffer + (insert content) + (write-file file)) + file)) + +(provide 'testutil-general) +;;; testutil-general.el ends here. diff --git a/tests/testutil-time.el b/tests/testutil-time.el new file mode 100644 index 0000000..3a17ae0 --- /dev/null +++ b/tests/testutil-time.el @@ -0,0 +1,143 @@ +;;; testutil-time.el --- Time utilities for dynamic test timestamps -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Utilities for generating dynamic timestamps in tests. +;; Tests should use relative time relationships (TODAY, TOMORROW, etc.) +;; instead of hardcoded dates to avoid test expiration. + +;;; Code: + +(require 'org) + +;;; Core Time Generation + +(defun test-time-now () + "Return a base 'now' time that's always valid. +Uses actual current time + 30 days to ensure tests remain valid. +Always returns 10:00 AM on that day for consistency." + (let* ((now (current-time)) + (decoded (decode-time now)) + (future-time (time-add now (days-to-time 30)))) + ;; Set to 10:00 AM for consistency + (encode-time 0 0 10 + (decoded-time-day (decode-time future-time)) + (decoded-time-month (decode-time future-time)) + (decoded-time-year (decode-time future-time))))) + +(defun test-time-at (days hours minutes) + "Return time relative to test-time-now. +DAYS, HOURS, MINUTES can be positive (future) or negative (past). +Examples: + (test-time-at 0 0 0) ; NOW + (test-time-at 0 2 0) ; 2 hours from now + (test-time-at -1 0 0) ; Yesterday at same time + (test-time-at 1 0 0) ; Tomorrow at same time" + (let* ((base (test-time-now)) + (seconds (+ (* days 86400) + (* hours 3600) + (* minutes 60)))) + (time-add base (seconds-to-time seconds)))) + +;;; Convenience Functions + +(defun test-time-today-at (hour minute) + "Return time for TODAY at HOUR:MINUTE. +Example: (test-time-today-at 14 30) ; Today at 2:30 PM" + (let* ((base (test-time-now)) + (decoded (decode-time base))) + (encode-time 0 minute hour + (decoded-time-day decoded) + (decoded-time-month decoded) + (decoded-time-year decoded)))) + +(defun test-time-yesterday-at (hour minute) + "Return time for YESTERDAY at HOUR:MINUTE." + (test-time-at -1 (- hour 10) minute)) + +(defun test-time-tomorrow-at (hour minute) + "Return time for TOMORROW at HOUR:MINUTE." + (test-time-at 1 (- hour 10) minute)) + +(defun test-time-days-ago (days &optional hour minute) + "Return time for DAYS ago, optionally at HOUR:MINUTE. +If HOUR/MINUTE not provided, uses 10:00 AM." + (let ((h (or hour 10)) + (m (or minute 0))) + (test-time-at (- days) (- h 10) m))) + +(defun test-time-days-from-now (days &optional hour minute) + "Return time for DAYS from now, optionally at HOUR:MINUTE. +If HOUR/MINUTE not provided, uses 10:00 AM." + (let ((h (or hour 10)) + (m (or minute 0))) + (test-time-at days (- h 10) m))) + +;;; Timestamp String Generation + +(defun test-timestamp-string (time &optional all-day-p) + "Convert Emacs TIME to org timestamp string. +If ALL-DAY-P is non-nil, omit time component: <2025-10-24 Thu> +Otherwise include time: <2025-10-24 Thu 14:00> + +Correctly calculates day-of-week name to match the date." + (let* ((decoded (decode-time time)) + (year (decoded-time-year decoded)) + (month (decoded-time-month decoded)) + (day (decoded-time-day decoded)) + (hour (decoded-time-hour decoded)) + (minute (decoded-time-minute decoded)) + (dow (decoded-time-weekday decoded)) + (day-names ["Sun" "Mon" "Tue" "Wed" "Thu" "Fri" "Sat"]) + (day-name (aref day-names dow))) + (if all-day-p + (format "<%04d-%02d-%02d %s>" year month day day-name) + (format "<%04d-%02d-%02d %s %02d:%02d>" year month day day-name hour minute)))) + +(defun test-timestamp-range-string (start-time end-time) + "Create range timestamp from START-TIME to END-TIME. +Example: <2025-10-24 Thu>--<2025-10-27 Sun>" + (format "%s--%s" + (test-timestamp-string start-time t) + (test-timestamp-string end-time t))) + +(defun test-timestamp-repeating (time repeater &optional all-day-p) + "Add REPEATER to timestamp for TIME. +REPEATER should be like '+1w', '.+1d', '++1m' +Example: <2025-10-24 Thu +1w>" + (let ((base-ts (test-timestamp-string time all-day-p))) + ;; Remove closing > and add repeater + (concat (substring base-ts 0 -1) " " repeater ">"))) + +;;; Mock Helpers + +(defmacro with-test-time (base-time &rest body) + "Execute BODY with mocked current-time returning BASE-TIME. +BASE-TIME can be generated with test-time-* functions. + +Example: + (with-test-time (test-time-now) + (do-something-that-uses-current-time))" + `(cl-letf (((symbol-function 'current-time) + (lambda () ,base-time))) + ,@body)) + +(provide 'testutil-time) +;;; testutil-time.el ends here |
