aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/Makefile244
-rw-r--r--tests/test-chime--deduplicate-events-by-title.el200
-rw-r--r--tests/test-chime--time=.el108
-rw-r--r--tests/test-chime--today.el93
-rw-r--r--tests/test-chime--truncate-title.el120
-rw-r--r--tests/test-chime-12hour-format.el227
-rw-r--r--tests/test-chime-all-day-events.el274
-rw-r--r--tests/test-chime-apply-blacklist.el247
-rw-r--r--tests/test-chime-apply-whitelist.el229
-rw-r--r--tests/test-chime-calendar-url.el64
-rw-r--r--tests/test-chime-check-event.el215
-rw-r--r--tests/test-chime-check-interval.el148
-rw-r--r--tests/test-chime-debug-functions.el224
-rw-r--r--tests/test-chime-extract-time.el331
-rw-r--r--tests/test-chime-format-event-for-tooltip.el260
-rw-r--r--tests/test-chime-format-refresh.el150
-rw-r--r--tests/test-chime-gather-info.el475
-rw-r--r--tests/test-chime-group-events-by-day.el268
-rw-r--r--tests/test-chime-has-timestamp.el277
-rw-r--r--tests/test-chime-modeline-no-events-text.el290
-rw-r--r--tests/test-chime-modeline.el1076
-rw-r--r--tests/test-chime-notification-text.el542
-rw-r--r--tests/test-chime-notifications.el259
-rw-r--r--tests/test-chime-notify.el259
-rw-r--r--tests/test-chime-org-contacts.el317
-rw-r--r--tests/test-chime-overdue-todos.el403
-rw-r--r--tests/test-chime-process-notifications.el344
-rw-r--r--tests/test-chime-sanitize-title.el402
-rw-r--r--tests/test-chime-time-left.el305
-rw-r--r--tests/test-chime-timestamp-parse.el413
-rw-r--r--tests/test-chime-timestamp-within-interval-p.el325
-rw-r--r--tests/test-chime-tooltip-bugs.el392
-rw-r--r--tests/test-chime-tooltip-day-calculation.el326
-rw-r--r--tests/test-chime-update-modeline-helpers.el166
-rw-r--r--tests/test-chime-update-modeline.el474
-rw-r--r--tests/test-chime-validate-configuration.el279
-rw-r--r--tests/test-chime-validation-retry.el435
-rw-r--r--tests/test-chime-whitelist-blacklist-conflicts.el253
-rw-r--r--tests/test-convert-org-contacts-birthdays.el674
-rw-r--r--tests/test-integration-recurring-events-tooltip.el370
-rw-r--r--tests/test-integration-startup.el328
-rw-r--r--tests/tests-autoloads.el148
-rw-r--r--tests/testutil-events.el214
-rw-r--r--tests/testutil-general.el184
-rw-r--r--tests/testutil-time.el143
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