summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Makefile12
-rw-r--r--tests/test-wttrin--display-weather.el213
-rw-r--r--wttrin.el196
3 files changed, 365 insertions, 56 deletions
diff --git a/Makefile b/Makefile
index 8a43771..fdedbf5 100644
--- a/Makefile
+++ b/Makefile
@@ -163,7 +163,11 @@ validate-parens:
validate:
@echo "Loading wttrin.el to verify compilation..."
- @$(EMACS_BATCH) -L $(PROJECT_ROOT) \
+ @$(EMACS_BATCH) \
+ --eval "(require 'package)" \
+ --eval "(add-to-list 'package-archives '(\"melpa\" . \"https://melpa.org/packages/\") t)" \
+ --eval "(package-initialize)" \
+ -L $(PROJECT_ROOT) \
--eval "(condition-case err \
(progn \
(load-file \"$(MAIN_FILE)\") \
@@ -176,7 +180,11 @@ validate:
compile:
@echo "Byte-compiling wttrin.el..."
- @$(EMACS_BATCH) -L $(PROJECT_ROOT) \
+ @$(EMACS_BATCH) \
+ --eval "(require 'package)" \
+ --eval "(add-to-list 'package-archives '(\"melpa\" . \"https://melpa.org/packages/\") t)" \
+ --eval "(package-initialize)" \
+ -L $(PROJECT_ROOT) \
--eval "(progn \
(setq byte-compile-error-on-warn nil) \
(batch-byte-compile))" $(MAIN_FILE)
diff --git a/tests/test-wttrin--display-weather.el b/tests/test-wttrin--display-weather.el
new file mode 100644
index 0000000..364908f
--- /dev/null
+++ b/tests/test-wttrin--display-weather.el
@@ -0,0 +1,213 @@
+;;; test-wttrin--display-weather.el --- Tests for wttrin--display-weather -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025 Craig Jennings
+
+;;; Commentary:
+
+;; Unit tests for wttrin--display-weather function.
+;; Tests the extracted display logic that formats and shows weather data.
+
+;;; Code:
+
+(require 'ert)
+(require 'wttrin)
+(require 'testutil-wttrin)
+
+;;; Test Data Fixtures
+
+(defconst test-wttrin--display-weather-sample-raw-data
+ "
+Weather report: Paris, France
+
+ ┌─────────────┐
+ ┌──────────────────────────┐
+ ┌──────────────────────────────┬───────────────────────────────────────────┴───────────────────────────────────────┐
+ │ Monday └──────────────────────────────────────────────────────────────────────────────────────┤
+ │ 2025-11-04 08:00:00 CST
+ │
+ │ Coordinates: 48.8566, 2.3522
+ │
+ \\ / Partly cloudy
+ _ /\".-. 22 °C
+ \\_( ). ↓ 15 km/h
+ /(___(__) 10 km
+ 0.0 mm"
+ "Sample raw weather data with realistic wttr.in structure for testing.")
+
+;;; Test Setup and Teardown
+
+(defun test-wttrin--display-weather-setup ()
+ "Setup for display weather tests."
+ (testutil-wttrin-setup)
+ ;; Kill any existing weather buffer
+ (when (get-buffer "*wttr.in*")
+ (kill-buffer "*wttr.in*")))
+
+(defun test-wttrin--display-weather-teardown ()
+ "Teardown for display weather tests."
+ (testutil-wttrin-teardown)
+ ;; Clean up weather buffer
+ (when (get-buffer "*wttr.in*")
+ (kill-buffer "*wttr.in*")))
+
+;;; Normal Cases
+
+(ert-deftest test-wttrin--display-weather-normal-valid-data-creates-buffer ()
+ "Test that valid weather data creates and displays buffer correctly."
+ (test-wttrin--display-weather-setup)
+ (unwind-protect
+ (progn
+ (wttrin--display-weather "Paris, France" test-wttrin--display-weather-sample-raw-data)
+
+ ;; Buffer should exist
+ (should (get-buffer "*wttr.in*"))
+
+ ;; Buffer should be displayed
+ (should (get-buffer-window "*wttr.in*"))
+
+ ;; Buffer should have content
+ (with-current-buffer "*wttr.in*"
+ (should (> (buffer-size) 0))
+
+ ;; Buffer should be read-only
+ (should buffer-read-only)
+
+ ;; Location should be set
+ (should (equal wttrin--current-location "Paris, France"))))
+ (test-wttrin--display-weather-teardown)))
+
+(ert-deftest test-wttrin--display-weather-normal-valid-data-sets-keybindings ()
+ "Test that keybindings are properly set up in weather buffer."
+ (test-wttrin--display-weather-setup)
+ (unwind-protect
+ (progn
+ (wttrin--display-weather "London" test-wttrin--display-weather-sample-raw-data)
+
+ (with-current-buffer "*wttr.in*"
+ ;; Check that keybindings are set (they should be in the local map)
+ (should (keymapp (current-local-map)))
+ (should (commandp (lookup-key (current-local-map) "q")))
+ (should (commandp (lookup-key (current-local-map) "r")))
+ (should (commandp (lookup-key (current-local-map) "g")))))
+ (test-wttrin--display-weather-teardown)))
+
+(ert-deftest test-wttrin--display-weather-normal-valid-data-contains-instructions ()
+ "Test that help instructions are displayed at bottom of buffer."
+ (test-wttrin--display-weather-setup)
+ (unwind-protect
+ (progn
+ (wttrin--display-weather "Tokyo" test-wttrin--display-weather-sample-raw-data)
+
+ (with-current-buffer "*wttr.in*"
+ (goto-char (point-max))
+ (forward-line -2)
+ ;; Should contain help text
+ (should (search-forward "Press:" nil t))
+ (should (search-forward "[g] to query another location" nil t))
+ (should (search-forward "[r] to refresh" nil t))
+ (should (search-forward "[q] to quit" nil t))))
+ (test-wttrin--display-weather-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-wttrin--display-weather-boundary-empty-location-name-creates-buffer ()
+ "Test that empty location name still creates buffer."
+ (test-wttrin--display-weather-setup)
+ (unwind-protect
+ (progn
+ (wttrin--display-weather "" test-wttrin--display-weather-sample-raw-data)
+
+ ;; Buffer should still be created
+ (should (get-buffer "*wttr.in*"))
+
+ (with-current-buffer "*wttr.in*"
+ ;; Location should be set to empty string
+ (should (equal wttrin--current-location ""))))
+ (test-wttrin--display-weather-teardown)))
+
+(ert-deftest test-wttrin--display-weather-boundary-location-with-special-chars-creates-buffer ()
+ "Test that location with special characters creates buffer."
+ (test-wttrin--display-weather-setup)
+ (unwind-protect
+ (progn
+ (wttrin--display-weather "São Paulo, BR 🌆" test-wttrin--display-weather-sample-raw-data)
+
+ (should (get-buffer "*wttr.in*"))
+
+ (with-current-buffer "*wttr.in*"
+ ;; Location with Unicode should be preserved
+ (should (equal wttrin--current-location "São Paulo, BR 🌆"))))
+ (test-wttrin--display-weather-teardown)))
+
+(ert-deftest test-wttrin--display-weather-boundary-empty-string-creates-buffer ()
+ "Test that empty weather string creates buffer without error.
+Empty string does not match ERROR pattern, so it's processed as data."
+ (test-wttrin--display-weather-setup)
+ (unwind-protect
+ (progn
+ (wttrin--display-weather "Paris" "")
+
+ ;; Empty string is not treated as error, buffer is created
+ (should (get-buffer "*wttr.in*"))
+
+ (with-current-buffer "*wttr.in*"
+ ;; Buffer exists but will have minimal/broken content
+ ;; Just verify it was created and made read-only
+ (should buffer-read-only)))
+ (test-wttrin--display-weather-teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-wttrin--display-weather-error-nil-raw-string-shows-message ()
+ "Test that nil raw-string displays error message."
+ (test-wttrin--display-weather-setup)
+ (unwind-protect
+ (progn
+ ;; Capture message output
+ (let ((message-log-max t)
+ (message-displayed nil))
+ (cl-letf (((symbol-function 'message)
+ (lambda (format-string &rest args)
+ (setq message-displayed (apply #'format format-string args)))))
+ (wttrin--display-weather "InvalidCity" nil)
+
+ ;; Should display error message
+ (should message-displayed)
+ (should (string-match-p "Cannot retrieve" message-displayed)))))
+ (test-wttrin--display-weather-teardown)))
+
+(ert-deftest test-wttrin--display-weather-error-string-with-error-shows-message ()
+ "Test that weather string containing ERROR shows error message."
+ (test-wttrin--display-weather-setup)
+ (unwind-protect
+ (progn
+ (let ((message-log-max t)
+ (message-displayed nil))
+ (cl-letf (((symbol-function 'message)
+ (lambda (format-string &rest args)
+ (setq message-displayed (apply #'format format-string args)))))
+ (wttrin--display-weather "BadLocation" testutil-wttrin-sample-error-response)
+
+ ;; Should display error message
+ (should message-displayed)
+ (should (string-match-p "Cannot retrieve" message-displayed)))))
+ (test-wttrin--display-weather-teardown)))
+
+(ert-deftest test-wttrin--display-weather-error-nil-raw-string-no-buffer-created ()
+ "Test that nil raw-string does not create weather buffer."
+ (test-wttrin--display-weather-setup)
+ (unwind-protect
+ (progn
+ ;; Suppress message output
+ (cl-letf (((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin--display-weather "InvalidCity" nil)
+
+ ;; Buffer should not be created for error case
+ ;; (or if it exists from before, it shouldn't be switched to)
+ ;; This is testing the error path doesn't create/switch to buffer
+ (should-not (string-match-p "wttr.in"
+ (buffer-name (current-buffer))))))
+ (test-wttrin--display-weather-teardown)))
+
+(provide 'test-wttrin--display-weather)
+;;; test-wttrin--display-weather.el ends here
diff --git a/wttrin.el b/wttrin.el
index 7418c77..a4e6993 100644
--- a/wttrin.el
+++ b/wttrin.el
@@ -97,8 +97,16 @@ units (default)."
:group 'wttrin
:type 'integer)
+(defcustom wttrin-use-async t
+ "If non-nil, fetch weather data asynchronously to avoid blocking Emacs."
+ :group 'wttrin
+ :type 'boolean)
+
(defvar wttrin--cache (make-hash-table :test 'equal)
- "Cache for weather data: cache-key -> (timestamp . data)")
+ "Cache for weather data: cache-key -> (timestamp . data).")
+
+(defvar wttrin--force-refresh nil
+ "When non-nil, bypass cache on next fetch.")
(defun wttrin-additional-url-params ()
"Concatenates extra information into the URL."
@@ -135,6 +143,35 @@ Returns the weather data as a string, or signals an error on failure."
'utf-8))
(kill-buffer buf))))
+(defun wttrin-fetch-raw-string-async (query callback)
+ "Asynchronously fetch weather information for QUERY.
+CALLBACK is called with the weather data string when ready, or nil on error."
+ (let* ((url (wttrin--build-url query))
+ (url-request-extra-headers (list wttrin-default-languages))
+ (url-user-agent "curl"))
+ (url-retrieve
+ url
+ (lambda (status)
+ (let ((data nil))
+ (condition-case err
+ (if (plist-get status :error)
+ (progn
+ (message "wttrin: Network error - %s" (cdr (plist-get status :error)))
+ (setq data nil))
+ (unwind-protect
+ (progn
+ ;; Skip HTTP headers
+ (goto-char (point-min))
+ (re-search-forward "\r?\n\r?\n" nil t)
+ (setq data (decode-coding-string
+ (buffer-substring-no-properties (point) (point-max))
+ 'utf-8)))
+ (kill-buffer (current-buffer))))
+ (error
+ (message "wttrin: Error processing response - %s" (error-message-string err))
+ (setq data nil)))
+ (funcall callback data))))))
+
(defun wttrin-exit ()
"Exit the wttrin buffer."
(interactive)
@@ -149,57 +186,77 @@ Returns the weather data as a string, or signals an error on failure."
(car wttrin-default-locations)))))
(when (get-buffer "*wttr.in*")
(kill-buffer "*wttr.in*"))
- (wttrin-query new-location)))
+ (if wttrin-use-async
+ (wttrin-query-async new-location)
+ (wttrin-query new-location))))
+
+(defun wttrin--display-weather (location-name raw-string)
+ "Display weather data RAW-STRING for LOCATION-NAME in weather buffer."
+ (if (or (null raw-string) (string-match "ERROR" raw-string))
+ (message "Cannot retrieve weather data. Perhaps the location was misspelled?")
+ (let ((buffer (get-buffer-create (format "*wttr.in*")))
+ date-time-stamp location-info)
+ (switch-to-buffer buffer)
+ (setq-local wttrin--current-location location-name)
+ (setq buffer-read-only nil)
+ (erase-buffer)
+
+ ;; set the preferred font attributes for this buffer only
+ (setq buffer-face-mode-face `(:family ,wttrin-font-name :height
+ ,wttrin-font-height))
+
+ ;; display buffer text and insert wttr.in data
+ (buffer-face-mode t)
+ (insert (xterm-color-filter raw-string))
+
+ ;; rearrange header information
+ (goto-char (point-min))
+ (forward-line 4)
+ (setq date-time-stamp (buffer-substring-no-properties
+ (line-beginning-position) (line-end-position)))
+ (goto-char (point-min))
+ (forward-line 6)
+ (setq location-info (buffer-substring-no-properties
+ (line-beginning-position) (line-end-position)))
+ (goto-char (point-min))
+ (forward-line 8)
+ (delete-region (point-min) (line-beginning-position))
+
+ (insert "\n" location-info "\n" date-time-stamp "\n\n\n")
+
+ ;; provide user instructions
+ (goto-char (point-max))
+ (insert "\nPress: [g] to query another location [r] to refresh [q] to quit")
+
+ ;; align buffer to top
+ (goto-char (point-min))
+
+ ;; create choice keymap and disallow modifying buffer
+ (use-local-map (make-sparse-keymap))
+ (local-set-key "q" 'wttrin-exit)
+ (local-set-key "r" 'wttrin-requery-force)
+ (local-set-key "g" 'wttrin-requery)
+ (setq buffer-read-only t))))
(defun wttrin-query (location-name)
"Query weather of LOCATION-NAME via wttrin, display the result in new buffer."
(let ((raw-string (wttrin--get-cached-or-fetch location-name)))
- (if (string-match "ERROR" raw-string)
- (message "Cannot retrieve weather data. Perhaps the location was
-misspelled?")
- (let ((buffer (get-buffer-create (format "*wttr.in*")))
- date-time-stamp location-info)
- (switch-to-buffer buffer)
- (setq-local wttrin--current-location location-name)
- (setq buffer-read-only nil)
- (erase-buffer)
-
- ;; set the preferred font attributes for this buffer only
- (setq buffer-face-mode-face `(:family ,wttrin-font-name :height
- ,wttrin-font-height))
-
- ;; display buffer text and insert wttr.in data
- (buffer-face-mode t)
- (insert (xterm-color-filter raw-string))
-
- ;; rearrange header information
- (goto-char (point-min))
- (forward-line 4)
- (setq date-time-stamp (buffer-substring-no-properties
- (line-beginning-position) (line-end-position)))
- (goto-char (point-min))
- (forward-line 6)
- (setq location-info (buffer-substring-no-properties
- (line-beginning-position) (line-end-position)))
- (goto-char (point-min))
- (forward-line 8)
- (delete-region (point-min) (line-beginning-position))
-
- (insert "\n" location-info "\n" date-time-stamp "\n\n\n")
-
- ;; provide user instructions
- (goto-char (point-max))
- (insert "\nPress: [g] to query another location [r] to refresh [q] to quit"))
-
- ;; align buffer to top
- (goto-char (point-min))
-
- ;; create choice keymap and disallow modifying buffer
- (use-local-map (make-sparse-keymap))
- (local-set-key "q" 'wttrin-exit)
- (local-set-key "r" 'wttrin-requery-force)
- (local-set-key "g" 'wttrin-requery)
- (setq buffer-read-only t))))
+ (wttrin--display-weather location-name raw-string)))
+
+(defun wttrin-query-async (location-name)
+ "Asynchronously query weather of LOCATION-NAME, display result when ready."
+ (let ((buffer (get-buffer-create (format "*wttr.in*"))))
+ (switch-to-buffer buffer)
+ (setq buffer-read-only nil)
+ (erase-buffer)
+ (insert "Loading weather for " location-name "...")
+ (setq buffer-read-only t)
+ (wttrin--get-cached-or-fetch-async
+ location-name
+ (lambda (raw-string)
+ (when (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (wttrin--display-weather location-name raw-string)))))))
(defun wttrin--make-cache-key (location)
"Create cache key from LOCATION and current settings."
@@ -231,8 +288,34 @@ Returns the weather data string or nil on error."
data)
(signal (car err) (cdr err))))))))
-(defvar wttrin--force-refresh nil
- "When non-nil, bypass cache on next fetch.")
+(defun wttrin--get-cached-or-fetch-async (location callback)
+ "Asynchronously get cached weather for LOCATION or fetch if expired.
+CALLBACK is called with the weather data string when ready, or nil on error."
+ (let* ((cache-key (wttrin--make-cache-key location))
+ (cached (gethash cache-key wttrin--cache))
+ (timestamp (car cached))
+ (data (cdr cached))
+ (now (float-time)))
+ (if (and cached
+ (< (- now timestamp) wttrin-cache-ttl)
+ (not wttrin--force-refresh))
+ ;; Return cached data immediately
+ (funcall callback data)
+ ;; Fetch fresh data asynchronously
+ (wttrin-fetch-raw-string-async
+ location
+ (lambda (fresh-data)
+ (if fresh-data
+ (progn
+ (wttrin--cleanup-cache-if-needed)
+ (puthash cache-key (cons now fresh-data) wttrin--cache)
+ (funcall callback fresh-data))
+ ;; On error, return stale cache if available
+ (if cached
+ (progn
+ (message "Failed to fetch new data, using cached version")
+ (funcall callback data))
+ (funcall callback nil))))))))
(defun wttrin--cleanup-cache-if-needed ()
"Remove old entries if cache exceeds max size."
@@ -263,19 +346,24 @@ Returns the weather data string or nil on error."
(if wttrin--current-location
(let ((wttrin--force-refresh t))
(message "Refreshing weather data...")
- (wttrin-query wttrin--current-location)
- (message nil))
+ (if wttrin-use-async
+ (wttrin-query-async wttrin--current-location)
+ (wttrin-query wttrin--current-location)
+ (message nil)))
(message "No location to refresh")))
;;;###autoload
(defun wttrin (location)
- "Display weather information for LOCATION."
+ "Display weather information for LOCATION.
+Uses asynchronous fetching if `wttrin-use-async' is non-nil."
(interactive
(list
(completing-read "Location Name: " wttrin-default-locations nil nil
(when (= (length wttrin-default-locations) 1)
(car wttrin-default-locations)))))
- (wttrin-query location))
+ (if wttrin-use-async
+ (wttrin-query-async location)
+ (wttrin-query location)))
(provide 'wttrin)
;;; wttrin.el ends here