diff options
| -rw-r--r-- | Makefile | 12 | ||||
| -rw-r--r-- | tests/test-wttrin--display-weather.el | 213 | ||||
| -rw-r--r-- | wttrin.el | 196 |
3 files changed, 365 insertions, 56 deletions
@@ -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 @@ -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 |
