diff options
| -rw-r--r-- | README.org | 12 | ||||
| -rw-r--r-- | tests/test-wttrin--resolve-favorite-location.el | 154 | ||||
| -rw-r--r-- | wttrin.el | 137 |
3 files changed, 270 insertions, 33 deletions
@@ -247,7 +247,9 @@ If a refresh fails, the emoji dims to gray and the tooltip tells you what went w *Note:* If the weather emoji appears as a monochrome symbol instead of a color icon, try setting `wttrin-mode-line-emoji-font` to match a color emoji font installed on your system. Use `M-x fc-list` or check your system fonts to see what's available. *** Setting the Favorite Location from IP Geolocation -If you don't want to type your city by hand, wttrin can detect it for you: +If you don't want to type your city by hand, wttrin can detect it for you. Two ways: + +**Manual detection with confirmation:** #+begin_src emacs-lisp M-x wttrin-set-location-from-geolocation @@ -255,6 +257,14 @@ If you don't want to type your city by hand, wttrin can detect it for you: This looks up your city via IP geolocation, shows the detected location, and sets =wttrin-favorite-location= after you confirm. To make the setting persist across Emacs sessions, run =M-x customize-save-variable RET wttrin-favorite-location RET=, or add =(setq wttrin-favorite-location "Your City, State")= to your init file. +**Automatic detection on first use:** + +#+begin_src emacs-lisp + (setq wttrin-favorite-location t) +#+end_src + +When set to =t=, wttrin runs the geolocation lookup once on first use (when the mode-line first fetches, when the buffer cache first refreshes, etc.) and caches the result for the rest of the session. The lookup happens in the background, so Emacs startup isn't blocked. The first display tick shows a placeholder until the lookup returns; everything proceeds normally after that. + The default lookup provider is =ipapi.co=. Two alternatives ship with the package, both free and key-less: #+begin_src emacs-lisp diff --git a/tests/test-wttrin--resolve-favorite-location.el b/tests/test-wttrin--resolve-favorite-location.el new file mode 100644 index 0000000..506c226 --- /dev/null +++ b/tests/test-wttrin--resolve-favorite-location.el @@ -0,0 +1,154 @@ +;;; test-wttrin--resolve-favorite-location.el --- Tests for wttrin--resolve-favorite-location -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;;; Commentary: + +;; Unit tests for `wttrin--resolve-favorite-location' and its helpers. +;; The resolver maps the three modes of `wttrin-favorite-location' +;; (nil / string / t) onto a returned location string, kicking off an +;; async geolocation lookup the first time t is seen. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'wttrin) +(require 'testutil-wttrin) + +;; Load wttrin-geolocation up front so cl-letf mocks of +;; `wttrin-geolocation-detect' aren't undone by the resolver's lazy +;; (require 'wttrin-geolocation) — which would otherwise re-evaluate +;; the defun and overwrite the mocked symbol-function. +(require 'wttrin-geolocation + (expand-file-name "wttrin-geolocation.el" + (file-name-directory (locate-library "wttrin")))) + +;;; Helpers + +(defmacro test-wttrin--resolve-with-geolocation-mock (returned-location &rest body) + "Run BODY with `wttrin-geolocation-detect' mocked to call back with +RETURNED-LOCATION synchronously. Resets resolver caches before BODY." + (declare (indent 1)) + `(let ((wttrin--resolved-favorite-location nil) + (wttrin--favorite-location-pending nil)) + (cl-letf (((symbol-function 'wttrin-geolocation-detect) + (lambda (callback) (funcall callback ,returned-location)))) + ,@body))) + +;;; Normal Cases + +(ert-deftest test-wttrin--resolve-favorite-location-normal-nil-returns-nil () + "Disabled mode (nil) resolves to nil." + (let ((wttrin-favorite-location nil) + (wttrin--resolved-favorite-location nil) + (wttrin--favorite-location-pending nil)) + (should-not (wttrin--resolve-favorite-location)))) + +(ert-deftest test-wttrin--resolve-favorite-location-normal-string-returns-itself () + "Explicit string resolves to itself." + (let ((wttrin-favorite-location "Berkeley, CA") + (wttrin--resolved-favorite-location nil) + (wttrin--favorite-location-pending nil)) + (should (string= "Berkeley, CA" (wttrin--resolve-favorite-location))))) + +(ert-deftest test-wttrin--resolve-favorite-location-normal-t-with-cache-returns-cached () + "When t and cache populated, resolver returns cached value without re-fetching." + (let ((wttrin-favorite-location t) + (wttrin--resolved-favorite-location "Cached, Place") + (wttrin--favorite-location-pending nil) + (detect-calls 0)) + (cl-letf (((symbol-function 'wttrin-geolocation-detect) + (lambda (_callback) (cl-incf detect-calls)))) + (should (string= "Cached, Place" (wttrin--resolve-favorite-location))) + (should (= 0 detect-calls))))) + +(ert-deftest test-wttrin--resolve-favorite-location-normal-t-detection-success-caches-result () + "t-mode detect callback populates the resolver cache." + (let ((wttrin-favorite-location t)) + (test-wttrin--resolve-with-geolocation-mock "Detected, City" + (wttrin--resolve-favorite-location) + (should (string= "Detected, City" wttrin--resolved-favorite-location)) + (should-not wttrin--favorite-location-pending)))) + +(ert-deftest test-wttrin--resolve-favorite-location-normal-second-call-returns-cached-from-first () + "After detection completes, the second call returns the cached string." + (let ((wttrin-favorite-location t)) + (test-wttrin--resolve-with-geolocation-mock "Detected, City" + (wttrin--resolve-favorite-location) + (should (string= "Detected, City" (wttrin--resolve-favorite-location)))))) + +;;; Boundary Cases + +(ert-deftest test-wttrin--resolve-favorite-location-boundary-t-without-cache-returns-nil () + "First call with t and empty cache returns nil while detection runs." + (let ((wttrin-favorite-location t) + (wttrin--resolved-favorite-location nil) + (wttrin--favorite-location-pending nil)) + (cl-letf (((symbol-function 'wttrin-geolocation-detect) + (lambda (_callback) + ;; Simulate an async lookup that has NOT called back yet. + (setq wttrin--favorite-location-pending t)))) + (should-not (wttrin--resolve-favorite-location))))) + +(ert-deftest test-wttrin--resolve-favorite-location-boundary-pending-suppresses-duplicate-detect () + "When a lookup is already pending, the resolver does not start another." + (let ((wttrin-favorite-location t) + (wttrin--resolved-favorite-location nil) + (wttrin--favorite-location-pending t) + (detect-calls 0)) + (cl-letf (((symbol-function 'wttrin-geolocation-detect) + (lambda (_callback) (cl-incf detect-calls)))) + (wttrin--resolve-favorite-location) + (should (= 0 detect-calls))))) + +;;; Error Cases + +(ert-deftest test-wttrin--resolve-favorite-location-error-detection-failure-leaves-cache-empty () + "When the detect callback returns nil, the cache stays empty and pending clears." + (let ((wttrin-favorite-location t)) + (test-wttrin--resolve-with-geolocation-mock nil + (wttrin--resolve-favorite-location) + (should-not wttrin--resolved-favorite-location) + (should-not wttrin--favorite-location-pending)))) + +(ert-deftest test-wttrin--resolve-favorite-location-error-detection-failure-allows-retry () + "After a failed detection, a subsequent resolve call kicks off a new lookup." + (let ((wttrin-favorite-location t) + (wttrin--resolved-favorite-location nil) + (wttrin--favorite-location-pending nil) + (detect-calls 0)) + (cl-letf (((symbol-function 'wttrin-geolocation-detect) + (lambda (callback) + (cl-incf detect-calls) + (funcall callback nil)))) + (wttrin--resolve-favorite-location) + (wttrin--resolve-favorite-location) + (should (= 2 detect-calls))))) + +;;; Display Name Helper + +(ert-deftest test-wttrin--favorite-location-display-name-resolved-string-returns-string () + "Display name returns the resolved string when available." + (let ((wttrin-favorite-location "Tokyo, JP") + (wttrin--resolved-favorite-location nil) + (wttrin--favorite-location-pending nil)) + (should (string= "Tokyo, JP" (wttrin--favorite-location-display-name))))) + +(ert-deftest test-wttrin--favorite-location-display-name-t-pending-returns-current-location-label () + "Display name returns \"current location\" when t-mode lookup is pending." + (let ((wttrin-favorite-location t) + (wttrin--resolved-favorite-location nil) + (wttrin--favorite-location-pending t)) + (should (string= "current location" + (wttrin--favorite-location-display-name))))) + +(ert-deftest test-wttrin--favorite-location-display-name-nil-returns-nil () + "Display name returns nil when feature is disabled." + (let ((wttrin-favorite-location nil) + (wttrin--resolved-favorite-location nil) + (wttrin--favorite-location-pending nil)) + (should-not (wttrin--favorite-location-display-name)))) + +(provide 'test-wttrin--resolve-favorite-location) +;;; test-wttrin--resolve-favorite-location.el ends here @@ -144,14 +144,76 @@ of entries, providing a reasonable buffer before the next cleanup.") (defcustom wttrin-favorite-location nil "Favorite location to display weather for. -When nil, favorite location features are disabled. -Set to a location string (e.g., \"New Orleans, LA\") to enable mode-line -weather display and other location-based features. -The weather icon and tooltip will update automatically in the background." + +Three modes: +- nil Favorite-location features are disabled (default). +- a string Use the string as the location, e.g. \"Berkeley, CA\". +- t Auto-detect via IP geolocation. wttrin runs the lookup + once on first use and caches the result for the session. + To pick a specific provider, customize + `wttrin-geolocation-provider'. + +When set, the weather icon and tooltip update automatically in the +background. IP-based auto-detection can be inaccurate behind a VPN +or a mobile hotspot — use a string if you need accuracy." :group 'wttrin :type '(choice (const :tag "Disabled" nil) + (const :tag "Auto-detect via geolocation" t) (string :tag "Location"))) +(defvar wttrin--resolved-favorite-location nil + "Cached geolocation result for `wttrin-favorite-location' = t. +Holds the resolved \"City, Region\" string so subsequent reads +do not re-fetch. Reset implicitly when the Emacs session ends.") + +(defvar wttrin--favorite-location-pending nil + "Non-nil while a geolocation lookup for the favorite is in flight. +Prevents duplicate concurrent lookups when several consumers ask +during the resolution window.") + +(defun wttrin--resolve-favorite-location () + "Return the favorite location as a string, or nil if unavailable. +Resolves `wttrin-favorite-location' across the three modes: +- nil -> nil (disabled) +- a string -> the string as-is +- t -> the cached geolocation result. When the cache is empty + and no lookup is in flight, kicks off an async detect + and returns nil for this call. The next call after the + lookup completes returns the resolved string." + (cond + ((null wttrin-favorite-location) nil) + ((stringp wttrin-favorite-location) wttrin-favorite-location) + ((eq wttrin-favorite-location t) + (or wttrin--resolved-favorite-location + (progn + (wttrin--start-favorite-location-detect) + nil))))) + +(defun wttrin--start-favorite-location-detect () + "Kick off an async geolocation lookup if one is not already pending. +On success the resolved string is stored in +`wttrin--resolved-favorite-location'. Failures (network error, parse +error) leave the cache empty and clear the pending flag, so the next +call retries." + (unless wttrin--favorite-location-pending + (setq wttrin--favorite-location-pending t) + (require 'wttrin-geolocation) + (wttrin-geolocation-detect + (lambda (location) + (setq wttrin--favorite-location-pending nil) + (when location + (setq wttrin--resolved-favorite-location location) + (wttrin--debug-log + "Resolved favorite-location via geolocation: %s" location)))))) + +(defun wttrin--favorite-location-display-name () + "Return a human-readable name for the favorite location. +Returns the resolved string when available; otherwise returns +\"current location\" if auto-detect is configured but pending, +or nil if the favorite is disabled." + (or (wttrin--resolve-favorite-location) + (when (eq wttrin-favorite-location t) "current location"))) + (defcustom wttrin-mode-line-refresh-interval 3600 "Interval in seconds to refresh mode-line weather data. Default is 3600 seconds (1 hour). The wttr.in service updates its @@ -675,29 +737,33 @@ e.g., \"Paris: ☀️ +61°F Clear\"." "Update placeholder to show fetch error state. Keeps the hourglass icon but updates tooltip to explain the failure and indicate when retry will occur." - (let ((retry-minutes (ceiling (/ wttrin-mode-line-refresh-interval 60.0)))) + (let ((retry-minutes (ceiling (/ wttrin-mode-line-refresh-interval 60.0))) + (label (or (wttrin--favorite-location-display-name) "favorite"))) (wttrin--set-mode-line-string (wttrin--make-emoji-icon "⏳") (format "Weather fetch failed for %s — will retry in %d minutes" - wttrin-favorite-location retry-minutes)))) + label retry-minutes)))) (defun wttrin--mode-line-fetch-weather () "Fetch weather for favorite location and update mode-line display. Uses wttr.in custom format for concise weather with emoji. On success, writes to `wttrin--mode-line-cache' and updates display. On failure with existing cache, shows stale data. -On failure with no cache, shows error placeholder." +On failure with no cache, shows error placeholder. +When `wttrin-favorite-location' is t and geolocation has not yet +resolved, this call is a no-op; the next tick after resolution +proceeds normally." (wttrin--debug-log "mode-line-fetch: Starting fetch for %s" wttrin-favorite-location) - (if (not wttrin-favorite-location) - (wttrin--debug-log "mode-line-fetch: No favorite location set, skipping") - (let* ((location wttrin-favorite-location) - ;; wttr.in format codes: %l=location %c=emoji %t=temp %C=conditions - (format-params (if wttrin-unit-system - (concat "?" wttrin-unit-system "&format=%l:+%c+%t+%C") - "?format=%l:+%c+%t+%C")) - (url (concat "https://wttr.in/" - (url-hexify-string location) - format-params))) + (let ((location (wttrin--resolve-favorite-location))) + (if (not location) + (wttrin--debug-log "mode-line-fetch: No favorite location available, skipping") + (let* (;; wttr.in format codes: %l=location %c=emoji %t=temp %C=conditions + (format-params (if wttrin-unit-system + (concat "?" wttrin-unit-system "&format=%l:+%c+%t+%C") + "?format=%l:+%c+%t+%C")) + (url (concat "https://wttr.in/" + (url-hexify-string location) + format-params))) (wttrin--debug-log "mode-line-fetch: URL = %s" url) (wttrin--fetch-url url @@ -718,7 +784,7 @@ On failure with no cache, shows error placeholder." ;; Have stale cache — update display to show staleness (wttrin--mode-line-update-display) ;; No cache at all — show error placeholder - (wttrin--mode-line-update-placeholder-error)))))))) + (wttrin--mode-line-update-placeholder-error))))))))) (defun wttrin--mode-line-extract-emoji (weather-string) "Extract the emoji character from WEATHER-STRING. @@ -783,14 +849,15 @@ shows staleness info in tooltip." "Handle left-click on mode-line weather widget. Check cache, refresh if needed, then open weather buffer." (interactive) - (when wttrin-favorite-location - (wttrin wttrin-favorite-location))) + (let ((location (wttrin--resolve-favorite-location))) + (when location + (wttrin location)))) (defun wttrin-mode-line-force-refresh () "Handle right-click on mode-line weather widget. Force-refresh cache and update tooltip without opening buffer." (interactive) - (when wttrin-favorite-location + (when (wttrin--resolve-favorite-location) (let ((wttrin--force-refresh t)) (wttrin--mode-line-fetch-weather)))) @@ -798,7 +865,8 @@ Force-refresh cache and update tooltip without opening buffer." "Set a placeholder icon in the mode-line while waiting for weather data." (wttrin--set-mode-line-string (wttrin--make-emoji-icon "⏳") - (format "Fetching weather for %s..." wttrin-favorite-location))) + (format "Fetching weather for %s..." + (or (wttrin--favorite-location-display-name) "favorite")))) (defvar wttrin--buffer-refresh-timer nil "Timer object for proactive buffer cache refresh.") @@ -807,16 +875,17 @@ Force-refresh cache and update tooltip without opening buffer." "Proactively refresh the buffer cache for `wttrin-favorite-location'. Fetches fresh weather data and updates the buffer cache entry without displaying anything. This keeps buffer data fresh for when the user -opens the weather buffer." - (when wttrin-favorite-location - (let* ((location wttrin-favorite-location) - (cache-key (wttrin--make-cache-key location))) - (wttrin-fetch-raw-string - location - (lambda (fresh-data &optional _error-msg) - (when fresh-data - (wttrin--cleanup-cache-if-needed) - (puthash cache-key (cons (float-time) fresh-data) wttrin--cache))))))) +opens the weather buffer. When the favorite is set to t and +geolocation has not yet resolved, this call is a no-op." + (let ((location (wttrin--resolve-favorite-location))) + (when location + (let ((cache-key (wttrin--make-cache-key location))) + (wttrin-fetch-raw-string + location + (lambda (fresh-data &optional _error-msg) + (when fresh-data + (wttrin--cleanup-cache-if-needed) + (puthash cache-key (cons (float-time) fresh-data) wttrin--cache)))))))) (defun wttrin--mode-line-start () "Start mode-line weather display and refresh timer." @@ -824,6 +893,10 @@ opens the weather buffer." wttrin-favorite-location wttrin-mode-line-refresh-interval) (when wttrin-favorite-location + ;; Trigger geolocation resolution in the background if needed; the + ;; placeholder + scheduled fetch will pick up the resolved string + ;; on the next tick. + (wttrin--resolve-favorite-location) (wttrin--mode-line-set-placeholder) ;; Delay first fetch — network/daemon may not be ready at startup (run-at-time wttrin-mode-line-startup-delay nil #'wttrin--mode-line-fetch-weather) |
