aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore6
-rw-r--r--README.org13
-rw-r--r--tests/test-wttrin--mode-line-forecast.el149
-rw-r--r--tests/test-wttrin--render-loading-placeholder.el64
-rw-r--r--tests/test-wttrin-favorite-override.el83
-rw-r--r--tests/test-wttrin-make-default.el48
-rw-r--r--tests/test-wttrin-saved-locations.el188
-rw-r--r--tests/test-wttrin-set-location-from-geolocation.el22
-rw-r--r--tests/test-wttrin-state-file.el258
-rw-r--r--tests/test-wttrin-use-current-location.el18
-rw-r--r--tests/testutil-wttrin.el19
-rw-r--r--wttrin-geolocation.el3
-rw-r--r--wttrin.el477
13 files changed, 1159 insertions, 189 deletions
diff --git a/.gitignore b/.gitignore
index 60f6dd2..75d5c96 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,9 @@ __pycache__/
CLAUDE.md
githooks/
*.eln
+
+# Claude Code: task archive (follows todo file privacy)
+/archive/task-archive.org
+
+# Claude Code per-project tooling (swept 2026-07-01)
+/AGENTS.md
diff --git a/README.org b/README.org
index cd419c1..56b0cb9 100644
--- a/README.org
+++ b/README.org
@@ -108,7 +108,7 @@ Simply use the keybinding you assigned, or run `M-x wttrin` to display the weath
Choose one, or for a quick one-time weather check, type a new location and ⏎ . After the weather is displayed, the footer shows two groups of keys. Keys that act on the view: `a` for another location, `g` to refresh, `q` to quit. Keys that act on your saved locations: `s` to save the shown location, `d` to make it your default, `r` to rename a saved location, and `x` to remove one.
-Pressing `d` sets =wttrin-favorite-location= to the location on screen and remembers it across restarts (via savehist), so the mode-line and future sessions follow it. Your default is also offered in the location list the next time you run =M-x wttrin=. Enable =savehist-mode= for the persistence to stick. (On a geolocation-detected buffer, `d` first prompts for a name and saves it — see Naming Locations.)
+Pressing `d` makes the location on screen your default and remembers it across restarts, so the mode-line and future sessions follow it. This works alongside a =wttrin-favorite-location= you set in your init: that option is your base default, and a location you promote with `d` overrides it and persists — so a `d` choice is never wiped out by the init value on the next restart. Your default is also offered in the location list the next time you run =M-x wttrin=. Persistence is automatic: wttrin keeps your default and saved locations in its own small state file (=wttrin-state-file=, in your Emacs directory by default) — nothing to enable. (On a geolocation-detected buffer, `d` first prompts for a name and saves it — see Naming Locations.)
If you're looking at cached data, a line below the weather art tells you how old it is (e.g., "Last updated: 2:30 PM (5 minutes ago)").
@@ -254,6 +254,8 @@ The mode-line shows a color emoji (☀️ 🌧️ ⛅ etc.) that updates hourly.
If a refresh fails, the emoji dims to gray and the tooltip tells you what went wrong and when it'll retry. Once the connection comes back, everything returns to normal on its own.
+The tooltip can also include a short forecast. Set =wttrin-mode-line-tooltip-forecast-days= to a positive number and the tooltip appends one line per day — Today, Tomorrow, then the weekday — with the day's low–high temperatures and midday conditions. wttr.in's feed carries at most three days, so larger values are capped at three. The default (0) keeps the tooltip current-conditions-only; when enabled, each refresh performs a second, small JSON fetch for the forecast data, and a failed forecast fetch never disturbs the current-conditions display.
+
**** Customization
#+begin_src emacs-lisp
@@ -275,6 +277,9 @@ If a refresh fails, the emoji dims to gray and the tooltip tells you what went w
;; How long to wait before the first fetch (1-10 seconds, default 3)
;; Useful if your network is slow to come up after Emacs starts
(setq wttrin-mode-line-startup-delay 5)
+
+ ;; Append a multi-day forecast to the tooltip (0 = off, max 3 days)
+ (setq wttrin-mode-line-tooltip-forecast-days 3)
#+end_src
*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.
@@ -284,7 +289,7 @@ If you don't want to type your city by hand, wttrin can detect it for you.
*From the picker (weather here, right now):* run =M-x wttrin= and pick the first entry, "Current location (detect)". wttrin looks up your city via IP geolocation and shows its weather. If the guess is wrong (VPN, mobile hotspot), the detected city is right there in the buffer header, so just open the picker again and type the correct city.
-*Make the detected city your default:* in that weather buffer, press =d=. The detected city becomes =wttrin-favorite-location= (what the mode-line tracks). With =savehist-mode= on, the favorite persists across sessions automatically, since wttrin registers it with savehist. No =customize-save-variable= step is needed.
+*Make the detected city your default:* in that weather buffer, press =d=. The detected city becomes your default (what the mode-line tracks), overriding any =wttrin-favorite-location= you set in your init. The choice persists across sessions automatically via wttrin's state file. No =customize-save-variable= step is needed.
*Always use my current location:* run =M-x wttrin-use-current-location=, or set the variable directly:
@@ -349,13 +354,13 @@ Or build the directory interactively:
- =M-x wttrin-rename-location= — rename an entry (refused if the new name is already taken).
- =M-x wttrin-remove-location= — remove an entry (asks to confirm).
-The directory persists across sessions with =savehist-mode= on (=wttrin= registers it), the same as your favorite and history.
+The directory persists across sessions automatically, alongside your default, in wttrin's state file. (Search history is separate — it rides =savehist-mode=; see Location History.)
You can point =wttrin-favorite-location= at a saved name (e.g. ="Craig's House"=): the mode-line resolves it to the query for fetching but shows the name in the tooltip.
When you pick "Current location (detect)" and press =d= to keep it, =wttrin= prompts for a name (prefilled with the detected address) and saves it as a named location, then makes it your default. Clear the field and press RET to keep the raw coordinates instead. Raw coordinates never clutter your history; only named places are remembered.
-*Privacy:* a saved query can be a home or work street address, kept in plaintext in your savehist file. With =wttrin-debug= on, the query and raw responses are also written to the debug log. =wttrin= does not encrypt or redact these, so save what you're comfortable storing in plain text.
+*Privacy:* a saved query can be a home or work street address, kept in plaintext in =wttrin-state-file= (and, for search history, your savehist file). With =wttrin-debug= on, the query and raw responses are also written to the debug log. =wttrin= does not encrypt or redact these, so save what you're comfortable storing in plain text.
*** Theming the Faces
The text wttrin draws itself uses named faces, so themes and =M-x customize-face= can restyle it. (The weather art itself is colored by the ANSI codes wttr.in returns, not by these faces.)
diff --git a/tests/test-wttrin--mode-line-forecast.el b/tests/test-wttrin--mode-line-forecast.el
new file mode 100644
index 0000000..c371f94
--- /dev/null
+++ b/tests/test-wttrin--mode-line-forecast.el
@@ -0,0 +1,149 @@
+;;; test-wttrin--mode-line-forecast.el --- Tests for the tooltip forecast -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;;; Commentary:
+
+;; Unit tests for the mode-line tooltip forecast: parsing wttr.in's j1 JSON
+;; into a day list, formatting up to `wttrin-mode-line-tooltip-forecast-days'
+;; lines, and appending the block to `wttrin--mode-line-tooltip' output.
+
+;;; Code:
+
+(require 'ert)
+(require 'wttrin)
+(require 'testutil-wttrin)
+
+;;; Fixture
+
+(defconst test-wttrin--forecast-j1
+ "{\"weather\": [
+ {\"date\": \"2026-07-01\", \"mintempC\": \"22\", \"maxtempC\": \"31\",
+ \"mintempF\": \"71\", \"maxtempF\": \"88\",
+ \"hourly\": [{\"time\": \"0\", \"weatherDesc\": [{\"value\": \"Clear\"}]},
+ {\"time\": \"1200\", \"weatherDesc\": [{\"value\": \"Partly cloudy\"}]},
+ {\"time\": \"2100\", \"weatherDesc\": [{\"value\": \"Clear\"}]}]},
+ {\"date\": \"2026-07-02\", \"mintempC\": \"20\", \"maxtempC\": \"26\",
+ \"mintempF\": \"68\", \"maxtempF\": \"79\",
+ \"hourly\": [{\"time\": \"1200\", \"weatherDesc\": [{\"value\": \"Light rain\"}]}]},
+ {\"date\": \"2026-07-03\", \"mintempC\": \"21\", \"maxtempC\": \"31\",
+ \"mintempF\": \"70\", \"maxtempF\": \"88\",
+ \"hourly\": [{\"time\": \"1200\", \"weatherDesc\": [{\"value\": \"Sunny\"}]}]}]}"
+ "Trimmed wttr.in ?format=j1 response: three days, the keys the code reads.")
+
+(defun test-wttrin--forecast-days ()
+ "Return the parsed day list from the fixture."
+ (wttrin--forecast-parse test-wttrin--forecast-j1))
+
+;;; --------------------------------------------------------------------------
+;;; wttrin--forecast-parse
+;;; --------------------------------------------------------------------------
+
+(ert-deftest test-wttrin--forecast-parse-normal-three-days ()
+ "Normal: the fixture parses into a list of three day alists."
+ (let ((days (test-wttrin--forecast-days)))
+ (should (= 3 (length days)))
+ (should (equal "2026-07-01" (alist-get 'date (car days))))))
+
+(ert-deftest test-wttrin--forecast-parse-error-malformed-json ()
+ "Error: malformed JSON returns nil instead of signaling."
+ (should-not (wttrin--forecast-parse "{not json")))
+
+(ert-deftest test-wttrin--forecast-parse-error-missing-weather-key ()
+ "Error: JSON without a weather array returns nil."
+ (should-not (wttrin--forecast-parse "{\"nearest_area\": []}")))
+
+(ert-deftest test-wttrin--forecast-parse-boundary-nil-and-empty ()
+ "Boundary: nil and empty-string input return nil."
+ (should-not (wttrin--forecast-parse nil))
+ (should-not (wttrin--forecast-parse "")))
+
+;;; --------------------------------------------------------------------------
+;;; wttrin--forecast-format
+;;; --------------------------------------------------------------------------
+
+(ert-deftest test-wttrin--forecast-format-normal-three-days-fahrenheit ()
+ "Normal: unit system \"u\" renders three labeled lines with °F ranges."
+ (let* ((wttrin-unit-system "u")
+ (text (wttrin--forecast-format (test-wttrin--forecast-days) 3))
+ (lines (split-string text "\n")))
+ (should (= 3 (length lines)))
+ (should (string-match-p "\\`Today 71-88°F Partly cloudy\\'" (nth 0 lines)))
+ (should (string-match-p "\\`Tomorrow 68-79°F Light rain\\'" (nth 1 lines)))
+ ;; 2026-07-03 is a Friday; the third day is labeled by weekday.
+ (should (string-match-p "\\`Fri 70-88°F Sunny\\'" (nth 2 lines)))))
+
+(ert-deftest test-wttrin--forecast-format-normal-metric ()
+ "Normal: a non-\"u\" unit system renders °C ranges."
+ (let* ((wttrin-unit-system "m")
+ (text (wttrin--forecast-format (test-wttrin--forecast-days) 2)))
+ (should (string-match-p "Today 22-31°C" text))
+ (should (string-match-p "Tomorrow 20-26°C" text))))
+
+(ert-deftest test-wttrin--forecast-format-boundary-day-counts ()
+ "Boundary: 0 days is nil; 1 day is one line; >3 caps at the data."
+ (let ((wttrin-unit-system "u")
+ (days (test-wttrin--forecast-days)))
+ (should-not (wttrin--forecast-format days 0))
+ (should (= 1 (length (split-string (wttrin--forecast-format days 1) "\n"))))
+ (should (= 3 (length (split-string (wttrin--forecast-format days 5) "\n"))))))
+
+(ert-deftest test-wttrin--forecast-format-boundary-missing-hourly-desc ()
+ "Boundary: a day with no hourly data renders the temps without a description."
+ (let* ((wttrin-unit-system "u")
+ (day '((date . "2026-07-01") (mintempF . "71") (maxtempF . "88")
+ (mintempC . "22") (maxtempC . "31") (hourly . ())))
+ (text (wttrin--forecast-format (list day) 1)))
+ (should (equal "Today 71-88°F" text))))
+
+(ert-deftest test-wttrin--forecast-format-error-nil-days ()
+ "Error: nil day list returns nil."
+ (should-not (wttrin--forecast-format nil 3)))
+
+;;; --------------------------------------------------------------------------
+;;; tooltip integration
+;;; --------------------------------------------------------------------------
+
+(defun test-wttrin--forecast-tooltip-fixture (option-days)
+ "Return the tooltip with fresh current + forecast caches and OPTION-DAYS."
+ (let ((wttrin-unit-system "u")
+ (wttrin-mode-line-tooltip-forecast-days option-days)
+ (wttrin--mode-line-cache (cons (float-time) "Paris: ☀️ +61°F Clear"))
+ (wttrin--mode-line-forecast-cache
+ (cons (float-time) (test-wttrin--forecast-days))))
+ (wttrin--mode-line-tooltip)))
+
+(ert-deftest test-wttrin--forecast-tooltip-normal-appends-block ()
+ "Normal: with the option at 3, the tooltip carries the forecast lines."
+ (testutil-wttrin-setup)
+ (unwind-protect
+ (let ((tooltip (test-wttrin--forecast-tooltip-fixture 3)))
+ (should (string-match-p "Paris" tooltip))
+ (should (string-match-p "Today 71-88°F Partly cloudy" tooltip))
+ (should (string-match-p "Fri 70-88°F Sunny" tooltip))
+ (should (string-match-p "Updated" tooltip)))
+ (testutil-wttrin-teardown)))
+
+(ert-deftest test-wttrin--forecast-tooltip-boundary-option-zero-unchanged ()
+ "Boundary: with the option at 0 (default), the tooltip has no forecast."
+ (testutil-wttrin-setup)
+ (unwind-protect
+ (let ((tooltip (test-wttrin--forecast-tooltip-fixture 0)))
+ (should (string-match-p "Paris" tooltip))
+ (should-not (string-match-p "Today" tooltip)))
+ (testutil-wttrin-teardown)))
+
+(ert-deftest test-wttrin--forecast-tooltip-boundary-empty-forecast-cache ()
+ "Boundary: option on but no forecast cache yet leaves the tooltip as before."
+ (testutil-wttrin-setup)
+ (unwind-protect
+ (let ((wttrin-mode-line-tooltip-forecast-days 3)
+ (wttrin--mode-line-cache (cons (float-time) "Paris: ☀️ +61°F Clear"))
+ (wttrin--mode-line-forecast-cache nil))
+ (let ((tooltip (wttrin--mode-line-tooltip)))
+ (should (string-match-p "Paris" tooltip))
+ (should-not (string-match-p "Today" tooltip))))
+ (testutil-wttrin-teardown)))
+
+(provide 'test-wttrin--mode-line-forecast)
+;;; test-wttrin--mode-line-forecast.el ends here
diff --git a/tests/test-wttrin--render-loading-placeholder.el b/tests/test-wttrin--render-loading-placeholder.el
new file mode 100644
index 0000000..5c27557
--- /dev/null
+++ b/tests/test-wttrin--render-loading-placeholder.el
@@ -0,0 +1,64 @@
+;;; test-wttrin--render-loading-placeholder.el --- Loading placeholder centering -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;;; Commentary:
+;; The loading placeholder must be centered every time it is shown, including on
+;; an `a' switch where the *wttr.in* buffer is already displayed. In that case
+;; `window-configuration-change-hook' does not fire, so `wttrin-query' relied on
+;; a stale window margin from the previous weather and the placeholder rendered
+;; off-center. `wttrin--render-loading-placeholder' centers it explicitly.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'wttrin)
+
+(ert-deftest test-wttrin--render-loading-placeholder-normal-shows-display-name ()
+ "Normal: the placeholder text names the display string, not the raw query."
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'wttrin--update-layout) #'ignore))
+ (wttrin--render-loading-placeholder "1500 Sugar Bowl Dr" "Superdome")
+ (should (string-match-p "Loading weather for Superdome\\.\\.\\."
+ (buffer-string)))
+ (should-not wttrin--weather-rendered))))
+
+(ert-deftest test-wttrin--render-loading-placeholder-boundary-nil-display-uses-query ()
+ "Boundary: with no display name, the placeholder falls back to the query."
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'wttrin--update-layout) #'ignore))
+ (wttrin--render-loading-placeholder "Reykjavik" nil)
+ (should (string-match-p "Loading weather for Reykjavik" (buffer-string))))))
+
+(ert-deftest test-wttrin--render-loading-placeholder-normal-centers-via-layout ()
+ "Normal: rendering the placeholder runs the layout pass, so it is centered by
+the same path weather uses rather than left to the hook (which does not fire on
+a reused, already-displayed buffer)."
+ (with-temp-buffer
+ (let ((layout-calls 0))
+ (cl-letf (((symbol-function 'wttrin--update-layout)
+ (lambda (&rest _) (setq layout-calls (1+ layout-calls)))))
+ (wttrin--render-loading-placeholder "Reykjavik" "Reykjavik")
+ (should (= 1 layout-calls))))))
+
+(ert-deftest test-wttrin--render-loading-placeholder-regression-replaces-stale-margin ()
+ "Regression: a stale window margin left by the previous weather block is
+recomputed for the placeholder, so it no longer renders at the old margin after
+an `a' switch."
+ (let ((buf (get-buffer-create "*wttrin-ph-test*")))
+ (unwind-protect
+ (save-window-excursion
+ (set-window-buffer (selected-window) buf)
+ ;; A wide margin as if left over from a previous (wide) weather block.
+ (set-window-margins (selected-window) 40)
+ (with-current-buffer buf
+ (let ((wttrin-auto-fit-font nil))
+ (wttrin--render-loading-placeholder "Reykjavik" "Reykjavik")
+ ;; The placeholder path must have recomputed the margin, not left
+ ;; the stale 40 in place.
+ (should-not (equal 40 (car (window-margins (selected-window))))))))
+ (kill-buffer buf))))
+
+(provide 'test-wttrin--render-loading-placeholder)
+;;; test-wttrin--render-loading-placeholder.el ends here
diff --git a/tests/test-wttrin-favorite-override.el b/tests/test-wttrin-favorite-override.el
new file mode 100644
index 0000000..95916c7
--- /dev/null
+++ b/tests/test-wttrin-favorite-override.el
@@ -0,0 +1,83 @@
+;;; test-wttrin-favorite-override.el --- Tests for the runtime favorite override -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;;; Commentary:
+;; Unit tests for the split between the configured favorite
+;; (`wttrin-favorite-location', a defcustom set in init) and the runtime
+;; favorite (`wttrin--favorite-override', set by `d'/the geolocation commands
+;; and persisted by savehist). The effective favorite resolves via
+;; `wttrin--favorite-location': the override wins over the config, so a favorite
+;; chosen at runtime is not clobbered by an init that also sets the option.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'wttrin)
+
+;;; wttrin--favorite-location (resolver)
+
+(ert-deftest test-wttrin-favorite-override-normal-config-when-no-override ()
+ "Normal: with no override, the effective favorite is the configured one."
+ (let ((wttrin-favorite-location "New Orleans, LA")
+ (wttrin--favorite-override nil))
+ (should (equal "New Orleans, LA" (wttrin--favorite-location)))))
+
+(ert-deftest test-wttrin-favorite-override-normal-override-wins ()
+ "Normal: a runtime override shadows the configured favorite."
+ (let ((wttrin-favorite-location "New Orleans, LA")
+ (wttrin--favorite-override "Hyatt Place Warwick, RI"))
+ (should (equal "Hyatt Place Warwick, RI" (wttrin--favorite-location)))))
+
+(ert-deftest test-wttrin-favorite-override-boundary-both-nil ()
+ "Boundary: no config and no override yields nil (favorite disabled)."
+ (let ((wttrin-favorite-location nil)
+ (wttrin--favorite-override nil))
+ (should (null (wttrin--favorite-location)))))
+
+(ert-deftest test-wttrin-favorite-override-boundary-auto-detect-override ()
+ "Boundary: the override carries the tri-state t (auto-detect)."
+ (let ((wttrin-favorite-location "New Orleans, LA")
+ (wttrin--favorite-override t))
+ (should (eq t (wttrin--favorite-location)))))
+
+(ert-deftest test-wttrin-favorite-override-regression-init-does-not-clobber ()
+ "Regression: setting the config after an override (as an init `setopt' would)
+does not change the effective favorite. This is the bug where a favorite set
+with `d' reverted to the init value on restart."
+ (let ((wttrin--favorite-override "Hyatt Place Warwick, RI")
+ (wttrin-favorite-location nil))
+ ;; Simulate the init running its (setopt wttrin-favorite-location ...).
+ (setq wttrin-favorite-location "New Orleans, LA")
+ (should (equal "Hyatt Place Warwick, RI" (wttrin--favorite-location)))))
+
+;;; wttrin--set-favorite-location writes the override, not the config
+
+(ert-deftest test-wttrin-favorite-override-normal-setter-writes-override ()
+ "Normal: the setter writes the runtime override and leaves the config intact."
+ (let ((wttrin-favorite-location "New Orleans, LA")
+ (wttrin--favorite-override nil)
+ (wttrin--location-history nil))
+ (wttrin--set-favorite-location "Paris, FR")
+ (should (equal "Paris, FR" wttrin--favorite-override))
+ (should (equal "New Orleans, LA" wttrin-favorite-location))
+ (should (equal "Paris, FR" (wttrin--favorite-location)))))
+
+;;; savehist registration covers only the scrub-tolerant search history;
+;;; the runtime vars persist in `wttrin-state-file' (see test-wttrin-state-file.el)
+
+(ert-deftest test-wttrin-favorite-override-normal-savehist-excludes-runtime-vars ()
+ "Normal: savehist persists neither the runtime vars nor the config defcustoms.
+The runtime favorite and directory live in `wttrin-state-file' — a savehist
+entry would be scrubbed by any session that saves savehist without wttrin."
+ (require 'savehist)
+ (let ((savehist-additional-variables '(kill-ring)))
+ (wttrin--savehist-register)
+ (should-not (memq 'wttrin--favorite-override savehist-additional-variables))
+ (should-not (memq 'wttrin--saved-locations-runtime savehist-additional-variables))
+ (should-not (memq 'wttrin-favorite-location savehist-additional-variables))
+ (should-not (memq 'wttrin-saved-locations savehist-additional-variables))))
+
+(provide 'test-wttrin-favorite-override)
+;;; test-wttrin-favorite-override.el ends here
diff --git a/tests/test-wttrin-make-default.el b/tests/test-wttrin-make-default.el
index e715e12..c7954c8 100644
--- a/tests/test-wttrin-make-default.el
+++ b/tests/test-wttrin-make-default.el
@@ -6,7 +6,9 @@
;; Unit tests for wttrin--set-favorite-location and wttrin-make-default,
;; the weather-buffer command (bound to "d") that promotes the displayed
-;; location to the persisted favorite.
+;; location to the persisted favorite. The favorite is written to the runtime
+;; override `wttrin--favorite-override' (not the `wttrin-favorite-location'
+;; defcustom), and read back through `wttrin--favorite-location'.
;;; Code:
@@ -21,26 +23,30 @@
;;; Normal Cases
(ert-deftest test-wttrin--set-favorite-location-normal-sets-variable ()
- "Normal: sets `wttrin-favorite-location' to the given location."
+ "Normal: sets the runtime favorite (leaving the config option untouched)."
(let ((wttrin-favorite-location nil)
+ (wttrin--favorite-override nil)
(savehist-additional-variables nil))
(wttrin--set-favorite-location "Paris, FR")
- (should (equal wttrin-favorite-location "Paris, FR"))))
+ (should (equal wttrin--favorite-override "Paris, FR"))
+ (should (equal "Paris, FR" (wttrin--favorite-location)))))
(ert-deftest test-wttrin--set-favorite-location-error-no-savehist-loaded ()
"Error: setting the favorite works even when savehist is not loaded.
The setter must not touch `savehist-additional-variables' directly (it may be
unbound); persistence is left to `wttrin--savehist-register'."
- (let ((wttrin-favorite-location nil))
+ (let ((wttrin-favorite-location nil)
+ (wttrin--favorite-override nil))
;; Simulate savehist absent: the variable is unbound.
(cl-letf (((symbol-function 'wttrin--savehist-register)
(lambda () (error "Should not be called from the setter"))))
(wttrin--set-favorite-location "Oslo, NO")
- (should (equal wttrin-favorite-location "Oslo, NO")))))
+ (should (equal wttrin--favorite-override "Oslo, NO")))))
(ert-deftest test-wttrin--set-favorite-location-normal-drops-from-history ()
"Normal: promoting a location removes it from the search history."
(let ((wttrin-favorite-location nil)
+ (wttrin--favorite-override nil)
(wttrin--location-history '("Reykjavik" "Oslo, NO")))
(wttrin--set-favorite-location "Reykjavik")
(should-not (member "Reykjavik" wttrin--location-history))
@@ -49,16 +55,20 @@ unbound); persistence is left to `wttrin--savehist-register'."
(ert-deftest test-wttrin--set-favorite-location-boundary-not-in-history-is-noop ()
"Boundary: promoting a location absent from history leaves history intact."
(let ((wttrin-favorite-location nil)
+ (wttrin--favorite-override nil)
(wttrin--location-history '("Oslo, NO")))
(wttrin--set-favorite-location "Berkeley, CA")
(should (equal wttrin--location-history '("Oslo, NO")))))
-(ert-deftest test-wttrin-favorite-savehist-register-includes-favorite ()
- "Normal: `wttrin--savehist-register' registers the favorite for persistence."
+(ert-deftest test-wttrin-favorite-savehist-register-excludes-favorite ()
+ "Normal: `wttrin--savehist-register' does not register the runtime override.
+The favorite persists in `wttrin-state-file' instead — a savehist entry would
+be scrubbed by any Emacs session that saves savehist without wttrin loaded."
(require 'savehist)
(let ((savehist-additional-variables '(kill-ring)))
(wttrin--savehist-register)
- (should (memq 'wttrin-favorite-location savehist-additional-variables))))
+ (should-not (memq 'wttrin--favorite-override savehist-additional-variables))
+ (should-not (memq 'wttrin-favorite-location savehist-additional-variables))))
;;; --------------------------------------------------------------------------
;;; mode-line refresh when the favorite changes
@@ -70,6 +80,7 @@ unbound); persistence is left to `wttrin--savehist-register'."
"Normal: changing the favorite while the mode-line is active clears the
stale cache and fetches fresh weather for the new location immediately."
(let ((wttrin-favorite-location "Oslo, NO")
+ (wttrin--favorite-override nil)
(wttrin-mode-line-mode t)
(wttrin--mode-line-cache (cons 0.0 "Oslo, NO: sun"))
(fetched nil))
@@ -86,6 +97,7 @@ stale cache and fetches fresh weather for the new location immediately."
(ert-deftest test-wttrin--set-favorite-location-boundary-mode-line-off-no-fetch ()
"Boundary: with the mode-line inactive, changing the favorite does not fetch."
(let ((wttrin-favorite-location "Oslo, NO")
+ (wttrin--favorite-override nil)
(wttrin-mode-line-mode nil)
(fetched nil))
(cl-letf (((symbol-function 'wttrin--mode-line-fetch-weather)
@@ -95,7 +107,8 @@ stale cache and fetches fresh weather for the new location immediately."
(ert-deftest test-wttrin--set-favorite-location-boundary-unchanged-no-fetch ()
"Boundary: re-promoting the current favorite does not refetch the mode-line."
- (let ((wttrin-favorite-location "Paris, FR")
+ (let ((wttrin-favorite-location nil)
+ (wttrin--favorite-override "Paris, FR")
(wttrin-mode-line-mode t)
(fetched nil))
(cl-letf (((symbol-function 'wttrin--mode-line-fetch-weather)
@@ -114,22 +127,26 @@ stale cache and fetches fresh weather for the new location immediately."
(ert-deftest test-wttrin-make-default-normal-sets-favorite-from-current ()
"Normal: promotes the buffer's current location to the favorite."
(let ((wttrin-favorite-location nil)
+ (wttrin--favorite-override nil)
+ (wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime nil)
(savehist-additional-variables nil))
(with-temp-buffer
(setq-local wttrin--current-location "Tokyo, JP")
(wttrin-make-default)
- (should (equal wttrin-favorite-location "Tokyo, JP")))))
+ (should (equal "Tokyo, JP" (wttrin--favorite-location))))))
;;; Boundary Cases
(ert-deftest test-wttrin-make-default-boundary-nil-current-leaves-favorite ()
"Boundary: no current location is a no-op that leaves the favorite intact."
(let ((wttrin-favorite-location "Berkeley, CA")
+ (wttrin--favorite-override nil)
(savehist-additional-variables nil))
(with-temp-buffer
(setq-local wttrin--current-location nil)
(wttrin-make-default)
- (should (equal wttrin-favorite-location "Berkeley, CA")))))
+ (should (equal "Berkeley, CA" (wttrin--favorite-location))))))
;;; --------------------------------------------------------------------------
;;; favorite in completion candidates
@@ -141,7 +158,8 @@ stale cache and fetches fresh weather for the new location immediately."
"Normal: a typed-in favorite is offered in the picker, at the front."
(let ((wttrin-default-locations '("Honolulu, HI" "Berkeley, CA"))
(wttrin--location-history nil)
- (wttrin-favorite-location "Reykjavik"))
+ (wttrin-favorite-location "Reykjavik")
+ (wttrin--favorite-override nil))
(should (equal (wttrin--completion-candidates)
(list wttrin--geolocation-sentinel
"Reykjavik" "Honolulu, HI" "Berkeley, CA")))))
@@ -153,14 +171,16 @@ stale cache and fetches fresh weather for the new location immediately."
(require 'cl-lib)
(let ((wttrin-default-locations '("Honolulu, HI" "Berkeley, CA"))
(wttrin--location-history nil)
- (wttrin-favorite-location "Berkeley, CA"))
+ (wttrin-favorite-location "Berkeley, CA")
+ (wttrin--favorite-override nil))
(should (= 1 (cl-count "Berkeley, CA" (wttrin--completion-candidates) :test #'equal)))))
(ert-deftest test-wttrin-make-default-boundary-nil-favorite-candidates-unchanged ()
"Boundary: nil favorite leaves the candidate list as defaults plus history."
(let ((wttrin-default-locations '("Honolulu, HI"))
(wttrin--location-history '("Oslo, NO"))
- (wttrin-favorite-location nil))
+ (wttrin-favorite-location nil)
+ (wttrin--favorite-override nil))
(should (equal (wttrin--completion-candidates)
(list wttrin--geolocation-sentinel "Honolulu, HI" "Oslo, NO")))))
diff --git a/tests/test-wttrin-saved-locations.el b/tests/test-wttrin-saved-locations.el
index de97df0..b41d7ee 100644
--- a/tests/test-wttrin-saved-locations.el
+++ b/tests/test-wttrin-saved-locations.el
@@ -3,10 +3,20 @@
;; Copyright (C) 2024-2026 Craig Jennings
;;; Commentary:
-;; Unit tests for the named-locations directory (Phase 1): the normalizer
+;; Unit tests for the named-locations directory: the normalizer/union
;; `wttrin--saved-locations', the resolver `wttrin--resolve-location-query',
;; candidate de-duplication/precedence, favorite-as-name resolution, alias cache
;; identity, history suppression of saved names, and savehist registration.
+;;
+;; The directory has two layers: the configured `wttrin-saved-locations'
+;; defcustom (a seed the user sets in init) and the runtime
+;; `wttrin--saved-locations-runtime' (savehist-persisted, mutated by `d',
+;; `wttrin-save-location', rename, and remove). `wttrin--saved-locations'
+;; returns the union, runtime winning on a name collision. Read-only tests set
+;; up the directory via the config layer; mutation tests use the runtime layer,
+;; since the runtime commands cannot delete a config-seeded entry. The favorite
+;; is likewise split: the `wttrin-favorite-location' defcustom plus the runtime
+;; `wttrin--favorite-override', read back through `wttrin--favorite-location'.
;;; Code:
@@ -15,42 +25,61 @@
(require 'wttrin)
(require 'testutil-wttrin)
-;;; wttrin--saved-locations (normalizer)
+;;; wttrin--saved-locations (normalizer/union)
(ert-deftest test-wttrin-saved-locations-normal-pairs-returned ()
"Normal: well-formed pairs are returned as (NAME . QUERY)."
- (let ((wttrin-saved-locations '(("Home" . "1500 Sugar Bowl Dr, New Orleans"))))
+ (let ((wttrin-saved-locations '(("Home" . "1500 Sugar Bowl Dr, New Orleans")))
+ (wttrin--saved-locations-runtime nil))
(should (equal '(("Home" . "1500 Sugar Bowl Dr, New Orleans"))
(wttrin--saved-locations)))))
(ert-deftest test-wttrin-saved-locations-boundary-bare-string-shorthand ()
"Boundary: a bare string S becomes (S . S)."
- (let ((wttrin-saved-locations '("Berkeley, CA")))
+ (let ((wttrin-saved-locations '("Berkeley, CA"))
+ (wttrin--saved-locations-runtime nil))
(should (equal '(("Berkeley, CA" . "Berkeley, CA"))
(wttrin--saved-locations)))))
(ert-deftest test-wttrin-saved-locations-boundary-whitespace-trimmed ()
"Boundary: surrounding whitespace on name and query is trimmed."
- (let ((wttrin-saved-locations '((" Home " . " Paris, FR "))))
+ (let ((wttrin-saved-locations '((" Home " . " Paris, FR ")))
+ (wttrin--saved-locations-runtime nil))
(should (equal '(("Home" . "Paris, FR")) (wttrin--saved-locations)))))
(ert-deftest test-wttrin-saved-locations-error-malformed-skipped ()
"Error: non-cons, non-string, and empty entries are skipped, not fatal."
(let ((wttrin-saved-locations
- (list '("Good" . "Tokyo") 42 '("" . "x") '("y" . "") " " '(a . b))))
+ (list '("Good" . "Tokyo") 42 '("" . "x") '("y" . "") " " '(a . b)))
+ (wttrin--saved-locations-runtime nil))
(should (equal '(("Good" . "Tokyo")) (wttrin--saved-locations)))))
+(ert-deftest test-wttrin-saved-locations-normal-runtime-overlays-config ()
+ "Normal: a runtime entry wins over a config entry with the same name."
+ (let ((wttrin-saved-locations '(("Home" . "Paris, FR")))
+ (wttrin--saved-locations-runtime '(("Home" . "Tokyo, JP"))))
+ (should (equal '(("Home" . "Tokyo, JP")) (wttrin--saved-locations)))))
+
+(ert-deftest test-wttrin-saved-locations-normal-union-runtime-then-config ()
+ "Normal: distinct config and runtime entries both appear, runtime first."
+ (let ((wttrin-saved-locations '(("Work" . "Tokyo, JP")))
+ (wttrin--saved-locations-runtime '(("Home" . "Paris, FR"))))
+ (should (equal '(("Home" . "Paris, FR") ("Work" . "Tokyo, JP"))
+ (wttrin--saved-locations)))))
+
;;; wttrin--resolve-location-query
(ert-deftest test-wttrin-saved-locations-normal-resolve-name-to-query ()
"Normal: a saved name resolves to its query."
- (let ((wttrin-saved-locations '(("Craig's House" . "1500 Sugar Bowl Dr, New Orleans"))))
+ (let ((wttrin-saved-locations '(("Craig's House" . "1500 Sugar Bowl Dr, New Orleans")))
+ (wttrin--saved-locations-runtime nil))
(should (equal "1500 Sugar Bowl Dr, New Orleans"
(wttrin--resolve-location-query "Craig's House")))))
(ert-deftest test-wttrin-saved-locations-boundary-resolve-passthrough ()
"Boundary: a non-saved selection passes through unchanged."
- (let ((wttrin-saved-locations '(("Home" . "Paris, FR"))))
+ (let ((wttrin-saved-locations '(("Home" . "Paris, FR")))
+ (wttrin--saved-locations-runtime nil))
(should (equal "Tokyo, JP" (wttrin--resolve-location-query "Tokyo, JP")))))
;;; Candidate de-duplication and precedence
@@ -61,7 +90,9 @@
(unwind-protect
(let ((wttrin-geolocation-enabled nil)
(wttrin-saved-locations '(("Home" . "Paris, FR")))
+ (wttrin--saved-locations-runtime nil)
(wttrin-favorite-location "Reykjavik")
+ (wttrin--favorite-override nil)
(wttrin-default-locations '("Honolulu, HI"))
(wttrin--location-history '("Tokyo")))
(should (equal '("Home" "Reykjavik" "Honolulu, HI" "Tokyo")
@@ -74,7 +105,9 @@
(unwind-protect
(let ((wttrin-geolocation-enabled nil)
(wttrin-saved-locations '(("Honolulu, HI" . "Honolulu, HI")))
+ (wttrin--saved-locations-runtime nil)
(wttrin-favorite-location nil)
+ (wttrin--favorite-override nil)
(wttrin-default-locations '("Honolulu, HI" "Berkeley, CA"))
(wttrin--location-history nil))
(should (equal '("Honolulu, HI" "Berkeley, CA")
@@ -86,14 +119,18 @@
(ert-deftest test-wttrin-saved-locations-normal-favorite-name-resolves-to-query ()
"Normal: a favorite that is a saved name resolves to its query for fetching."
(let ((wttrin-saved-locations '(("Home" . "1500 Sugar Bowl Dr, New Orleans")))
- (wttrin-favorite-location "Home"))
+ (wttrin--saved-locations-runtime nil)
+ (wttrin-favorite-location "Home")
+ (wttrin--favorite-override nil))
(should (equal "1500 Sugar Bowl Dr, New Orleans"
(wttrin--resolve-favorite-location)))))
(ert-deftest test-wttrin-saved-locations-normal-favorite-display-shows-name ()
"Normal: the favorite display name is the saved name, not its query."
(let ((wttrin-saved-locations '(("Home" . "1500 Sugar Bowl Dr, New Orleans")))
- (wttrin-favorite-location "Home"))
+ (wttrin--saved-locations-runtime nil)
+ (wttrin-favorite-location "Home")
+ (wttrin--favorite-override nil))
(should (equal "Home" (wttrin--favorite-location-display-name)))))
;;; Alias cache identity
@@ -101,7 +138,8 @@
(ert-deftest test-wttrin-saved-locations-normal-cache-keyed-on-query ()
"Normal: cache identity follows the query, not the display name.
Two names with the same query share a key; the name never leaks into the key."
- (let ((wttrin-saved-locations '(("A" . "Paris, FR") ("B" . "Paris, FR"))))
+ (let ((wttrin-saved-locations '(("A" . "Paris, FR") ("B" . "Paris, FR")))
+ (wttrin--saved-locations-runtime nil))
(should (equal (wttrin--make-cache-key (wttrin--resolve-location-query "A"))
(wttrin--make-cache-key (wttrin--resolve-location-query "B"))))))
@@ -112,6 +150,7 @@ Two names with the same query share a key; the name never leaks into the key."
(testutil-wttrin-setup)
(unwind-protect
(let ((wttrin-saved-locations '(("Home" . "Paris, FR")))
+ (wttrin--saved-locations-runtime nil)
(wttrin-default-locations '())
(wttrin--location-history nil))
(wttrin--add-to-location-history "Home")
@@ -120,12 +159,15 @@ Two names with the same query share a key; the name never leaks into the key."
;;; savehist
-(ert-deftest test-wttrin-saved-locations-integration-savehist-registers ()
- "Integration: wttrin-saved-locations is registered for savehist persistence."
+(ert-deftest test-wttrin-saved-locations-integration-savehist-excludes-runtime ()
+ "Integration: the runtime directory is not registered with savehist.
+It persists in `wttrin-state-file' instead — a savehist entry would be
+scrubbed by any Emacs session that saves savehist without wttrin loaded."
(require 'savehist)
(let ((savehist-additional-variables '(kill-ring)))
(wttrin--savehist-register)
- (should (memq 'wttrin-saved-locations savehist-additional-variables))))
+ (should-not (memq 'wttrin--saved-locations-runtime savehist-additional-variables))
+ (should-not (memq 'wttrin-saved-locations savehist-additional-variables))))
;;; wttrin--coordinates-p
@@ -141,7 +183,8 @@ Two names with the same query share a key; the name never leaks into the key."
(ert-deftest test-wttrin-saved-locations-normal-put-adds-and-updates ()
"Normal: put adds a new entry and updates an existing name without duplicating."
- (let ((wttrin-saved-locations nil))
+ (let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime nil))
(wttrin--put-saved-location "Home" "Paris, FR")
(should (equal "Paris, FR" (wttrin--resolve-location-query "Home")))
(wttrin--put-saved-location "Home" "Tokyo, JP")
@@ -150,7 +193,8 @@ Two names with the same query share a key; the name never leaks into the key."
(ert-deftest test-wttrin-saved-locations-error-put-rejects-empty-and-sentinel ()
"Error: put refuses an empty name, empty query, or the sentinel name."
- (let ((wttrin-saved-locations nil))
+ (let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime nil))
(should-error (wttrin--put-saved-location "" "Paris") :type 'user-error)
(should-error (wttrin--put-saved-location "Home" "") :type 'user-error)
(should-error (wttrin--put-saved-location wttrin--geolocation-sentinel "x")
@@ -160,25 +204,30 @@ Two names with the same query share a key; the name never leaks into the key."
(ert-deftest test-wttrin-saved-locations-normal-rename ()
"Normal: rename moves the entry and updates the favorite reference."
- (let ((wttrin-saved-locations '(("Home" . "Paris, FR")))
- (wttrin-favorite-location "Home"))
+ (let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime '(("Home" . "Paris, FR")))
+ (wttrin-favorite-location nil)
+ (wttrin--favorite-override "Home"))
(cl-letf (((symbol-function 'message) (lambda (&rest _) nil)))
(wttrin-rename-location "Home" "Casa"))
(should (equal "Paris, FR" (wttrin--resolve-location-query "Casa")))
(should-not (assoc "Home" (wttrin--saved-locations)))
- (should (equal "Casa" wttrin-favorite-location))))
+ (should (equal "Casa" (wttrin--favorite-location)))))
(ert-deftest test-wttrin-saved-locations-error-rename-collision-refused ()
"Error: renaming onto an existing name is refused and changes nothing."
- (let ((wttrin-saved-locations '(("Home" . "Paris, FR") ("Work" . "Tokyo, JP"))))
+ (let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime '(("Home" . "Paris, FR") ("Work" . "Tokyo, JP"))))
(should-error (wttrin-rename-location "Home" "Work") :type 'user-error)
(should (equal "Paris, FR" (wttrin--resolve-location-query "Home")))))
(ert-deftest test-wttrin-saved-locations-normal-rename-favorite-refreshes-mode-line ()
"Normal: renaming the favorite refreshes the mode-line so the icon and tooltip
follow the new name immediately instead of at the next scheduled fetch."
- (let ((wttrin-saved-locations '(("Home" . "Paris, FR")))
- (wttrin-favorite-location "Home")
+ (let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime '(("Home" . "Paris, FR")))
+ (wttrin-favorite-location nil)
+ (wttrin--favorite-override "Home")
(wttrin--location-history nil)
(wttrin-mode-line-mode t)
(fetched nil))
@@ -188,13 +237,15 @@ follow the new name immediately instead of at the next scheduled fetch."
(lambda () nil))
((symbol-function 'message) (lambda (&rest _) nil)))
(wttrin-rename-location "Home" "Casa"))
- (should (equal "Casa" wttrin-favorite-location))
+ (should (equal "Casa" (wttrin--favorite-location)))
(should fetched)))
(ert-deftest test-wttrin-saved-locations-boundary-rename-non-favorite-no-refresh ()
"Boundary: renaming a location that is not the favorite does not refresh."
- (let ((wttrin-saved-locations '(("Home" . "Paris, FR") ("Work" . "Tokyo, JP")))
- (wttrin-favorite-location "Work")
+ (let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime '(("Home" . "Paris, FR") ("Work" . "Tokyo, JP")))
+ (wttrin-favorite-location nil)
+ (wttrin--favorite-override "Work")
(wttrin--location-history nil)
(wttrin-mode-line-mode t)
(fetched nil))
@@ -210,7 +261,8 @@ follow the new name immediately instead of at the next scheduled fetch."
(ert-deftest test-wttrin-saved-locations-normal-remove-confirmed ()
"Normal: confirming removes the entry."
- (let ((wttrin-saved-locations '(("Home" . "Paris, FR"))))
+ (let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime '(("Home" . "Paris, FR"))))
(cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t))
((symbol-function 'message) (lambda (&rest _) nil)))
(wttrin-remove-location "Home"))
@@ -218,17 +270,30 @@ follow the new name immediately instead of at the next scheduled fetch."
(ert-deftest test-wttrin-saved-locations-boundary-remove-declined-keeps ()
"Boundary: declining the confirmation keeps the entry."
- (let ((wttrin-saved-locations '(("Home" . "Paris, FR"))))
+ (let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime '(("Home" . "Paris, FR"))))
(cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) nil))
((symbol-function 'message) (lambda (&rest _) nil)))
(wttrin-remove-location "Home"))
(should (assoc "Home" (wttrin--saved-locations)))))
+(ert-deftest test-wttrin-saved-locations-boundary-remove-config-entry-remains ()
+ "Boundary: removing a config-seeded entry (not in the runtime layer) leaves it
+in the directory, since the runtime removal cannot delete an init-defined entry."
+ (let ((wttrin-saved-locations '(("Home" . "Paris, FR")))
+ (wttrin--saved-locations-runtime nil))
+ (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-remove-location "Home"))
+ (should (assoc "Home" (wttrin--saved-locations)))))
+
(ert-deftest test-wttrin-saved-locations-normal-remove-favorite-refreshes-mode-line ()
"Normal: removing the favorite refreshes the mode-line so it stops showing the
now-deleted alias's resolved weather and re-fetches against the bare query."
- (let ((wttrin-saved-locations '(("Home" . "Paris, FR")))
- (wttrin-favorite-location "Home")
+ (let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime '(("Home" . "Paris, FR")))
+ (wttrin-favorite-location nil)
+ (wttrin--favorite-override "Home")
(wttrin-mode-line-mode t)
(fetched nil))
(cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t))
@@ -242,8 +307,10 @@ now-deleted alias's resolved weather and re-fetches against the bare query."
(ert-deftest test-wttrin-saved-locations-boundary-remove-non-favorite-no-refresh ()
"Boundary: removing a location that is not the favorite does not refresh."
- (let ((wttrin-saved-locations '(("Home" . "Paris, FR") ("Work" . "Tokyo, JP")))
- (wttrin-favorite-location "Work")
+ (let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime '(("Home" . "Paris, FR") ("Work" . "Tokyo, JP")))
+ (wttrin-favorite-location nil)
+ (wttrin--favorite-override "Work")
(wttrin-mode-line-mode t)
(fetched nil))
(cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t))
@@ -260,7 +327,9 @@ now-deleted alias's resolved weather and re-fetches against the bare query."
(ert-deftest test-wttrin-saved-locations-normal-d-names-and-promotes ()
"Normal: d on a coordinate buffer names it, saves it, and promotes the name."
(let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime nil)
(wttrin-favorite-location nil)
+ (wttrin--favorite-override nil)
(wttrin-mode-line-mode nil))
(with-temp-buffer
(setq-local wttrin--current-location "41.37,-71.83")
@@ -270,12 +339,14 @@ now-deleted alias's resolved weather and re-fetches against the bare query."
((symbol-function 'message) (lambda (&rest _) nil)))
(wttrin-make-default)))
(should (equal "41.37,-71.83" (wttrin--resolve-location-query "Home")))
- (should (equal "Home" wttrin-favorite-location))))
+ (should (equal "Home" (wttrin--favorite-location)))))
(ert-deftest test-wttrin-saved-locations-boundary-d-empty-keeps-coordinates ()
"Boundary: an empty name at the d prompt keeps the coordinates, saves no entry."
(let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime nil)
(wttrin-favorite-location nil)
+ (wttrin--favorite-override nil)
(wttrin-mode-line-mode nil))
(with-temp-buffer
(setq-local wttrin--current-location "41.37,-71.83")
@@ -284,12 +355,14 @@ now-deleted alias's resolved weather and re-fetches against the bare query."
((symbol-function 'message) (lambda (&rest _) nil)))
(wttrin-make-default)))
(should (null (wttrin--saved-locations)))
- (should (equal "41.37,-71.83" wttrin-favorite-location))))
+ (should (equal "41.37,-71.83" (wttrin--favorite-location)))))
(ert-deftest test-wttrin-saved-locations-boundary-d-named-buffer-no-prompt ()
"Boundary: d on a named buffer promotes the display name without prompting."
(let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime nil)
(wttrin-favorite-location nil)
+ (wttrin--favorite-override nil)
(wttrin-mode-line-mode nil)
(prompted nil))
(with-temp-buffer
@@ -300,7 +373,7 @@ now-deleted alias's resolved weather and re-fetches against the bare query."
((symbol-function 'message) (lambda (&rest _) nil)))
(wttrin-make-default)))
(should-not prompted)
- (should (equal "Craig's House" wttrin-favorite-location))
+ (should (equal "Craig's House" (wttrin--favorite-location)))
(should (equal "1500 Sugar Bowl Dr"
(wttrin--resolve-location-query "Craig's House")))))
@@ -308,14 +381,16 @@ now-deleted alias's resolved weather and re-fetches against the bare query."
"Normal: making a typed location the default also saves it to the directory,
so it persists as a named entry rather than only as the favorite string."
(let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime nil)
(wttrin-favorite-location nil)
+ (wttrin--favorite-override nil)
(wttrin-mode-line-mode nil))
(with-temp-buffer
(setq-local wttrin--current-location "Reykjavik")
(setq-local wttrin--current-display "Reykjavik")
(cl-letf (((symbol-function 'message) (lambda (&rest _) nil)))
(wttrin-make-default)))
- (should (equal "Reykjavik" wttrin-favorite-location))
+ (should (equal "Reykjavik" (wttrin--favorite-location)))
(should (assoc "Reykjavik" (wttrin--saved-locations)))
(should (equal "Reykjavik" (wttrin--resolve-location-query "Reykjavik")))))
@@ -323,7 +398,8 @@ so it persists as a named entry rather than only as the favorite string."
(ert-deftest test-wttrin-saved-locations-normal-save-location-interactive ()
"Normal: the interactive save command reads the buffer query and a name."
- (let ((wttrin-saved-locations nil))
+ (let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime nil))
(with-temp-buffer
(setq-local wttrin--current-location "Paris, FR")
(setq-local wttrin--current-display "Paris, FR")
@@ -334,7 +410,8 @@ so it persists as a named entry rather than only as the favorite string."
(ert-deftest test-wttrin-saved-locations-normal-rename-interactive ()
"Normal: the interactive rename command prompts for the entry and new name."
- (let ((wttrin-saved-locations '(("Home" . "Paris, FR"))))
+ (let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime '(("Home" . "Paris, FR"))))
(cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "Home"))
((symbol-function 'read-string) (lambda (&rest _) "Casa"))
((symbol-function 'message) (lambda (&rest _) nil)))
@@ -343,7 +420,8 @@ so it persists as a named entry rather than only as the favorite string."
(ert-deftest test-wttrin-saved-locations-normal-remove-interactive ()
"Normal: the interactive remove command prompts and confirms."
- (let ((wttrin-saved-locations '(("Home" . "Paris, FR"))))
+ (let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime '(("Home" . "Paris, FR"))))
(cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "Home"))
((symbol-function 'yes-or-no-p) (lambda (&rest _) t))
((symbol-function 'message) (lambda (&rest _) nil)))
@@ -352,7 +430,8 @@ so it persists as a named entry rather than only as the favorite string."
(ert-deftest test-wttrin-saved-locations-boundary-save-empty-name-cancels ()
"Boundary: an empty name at the save prompt cancels without saving."
- (let ((wttrin-saved-locations nil))
+ (let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime nil))
(with-temp-buffer
(setq-local wttrin--current-location "Paris, FR")
(cl-letf (((symbol-function 'read-string) (lambda (&rest _) " "))
@@ -368,6 +447,7 @@ so it persists as a named entry rather than only as the favorite string."
(unwind-protect
(let ((wttrin-default-locations '())
(wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime nil)
(wttrin--location-history nil))
(wttrin--add-to-location-history "41.37,-71.83")
(should (null wttrin--location-history)))
@@ -379,40 +459,12 @@ so it persists as a named entry rather than only as the favorite string."
"Normal: saving a location drops its query from history, so the place lives in
the directory only and does not also appear as a separate history candidate."
(let ((wttrin-saved-locations nil)
+ (wttrin--saved-locations-runtime nil)
(wttrin--location-history '("New Orleans" "Paris")))
(cl-letf (((symbol-function 'message) (lambda (&rest _) nil)))
(wttrin-save-location "Home" "New Orleans"))
(should-not (member "New Orleans" wttrin--location-history))
(should (member "Paris" wttrin--location-history))))
-(ert-deftest test-wttrin-saved-locations-normal-d-alias-drops-query-from-history ()
- "Normal: making an aliased location the default drops its underlying query from
-history, so the place does not also linger as a separate history candidate."
- (let ((wttrin-saved-locations nil)
- (wttrin-favorite-location nil)
- (wttrin-mode-line-mode nil)
- (wttrin--location-history '("New Orleans" "Paris")))
- (with-temp-buffer
- (setq-local wttrin--current-location "New Orleans")
- (setq-local wttrin--current-display "Home")
- (cl-letf (((symbol-function 'message) (lambda (&rest _) nil)))
- (wttrin-make-default)))
- (should (equal "Home" wttrin-favorite-location))
- (should (assoc "Home" (wttrin--saved-locations)))
- (should-not (member "New Orleans" wttrin--location-history))
- (should (member "Paris" wttrin--location-history))))
-
-(ert-deftest test-wttrin-saved-locations-normal-remove-forgets-history ()
- "Normal: removing a saved location drops both its name and its query from
-history, so a removed place does not resurface as a history candidate."
- (let ((wttrin-saved-locations '(("Home" . "New Orleans")))
- (wttrin--location-history '("Home" "New Orleans" "Paris")))
- (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t))
- ((symbol-function 'message) (lambda (&rest _) nil)))
- (wttrin-remove-location "Home"))
- (should-not (member "Home" wttrin--location-history))
- (should-not (member "New Orleans" wttrin--location-history))
- (should (member "Paris" wttrin--location-history))))
-
(provide 'test-wttrin-saved-locations)
;;; test-wttrin-saved-locations.el ends here
diff --git a/tests/test-wttrin-set-location-from-geolocation.el b/tests/test-wttrin-set-location-from-geolocation.el
index f16b2ae..10e5cc0 100644
--- a/tests/test-wttrin-set-location-from-geolocation.el
+++ b/tests/test-wttrin-set-location-from-geolocation.el
@@ -20,16 +20,24 @@
(defvar test-wttrin-set-location-from-geolocation--saved-favorite nil
"Snapshot of `wttrin-favorite-location' restored in teardown.")
+(defvar test-wttrin-set-location-from-geolocation--saved-override nil
+ "Snapshot of `wttrin--favorite-override' restored in teardown.")
+
(defun test-wttrin-set-location-from-geolocation-setup ()
- "Snapshot `wttrin-favorite-location' and clear it for the test."
+ "Snapshot the configured favorite and runtime override, clearing both."
(setq test-wttrin-set-location-from-geolocation--saved-favorite
wttrin-favorite-location)
- (setq wttrin-favorite-location nil))
+ (setq test-wttrin-set-location-from-geolocation--saved-override
+ wttrin--favorite-override)
+ (setq wttrin-favorite-location nil)
+ (setq wttrin--favorite-override nil))
(defun test-wttrin-set-location-from-geolocation-teardown ()
- "Restore `wttrin-favorite-location' to its pre-test value."
+ "Restore the configured favorite and runtime override to pre-test values."
(setq wttrin-favorite-location
- test-wttrin-set-location-from-geolocation--saved-favorite))
+ test-wttrin-set-location-from-geolocation--saved-favorite)
+ (setq wttrin--favorite-override
+ test-wttrin-set-location-from-geolocation--saved-override))
;;; Helpers
@@ -51,7 +59,7 @@
(progn
(test-wttrin-set-location--with-detected "Berkeley, California" t
(wttrin-set-location-from-geolocation))
- (should (string= "Berkeley, California" wttrin-favorite-location)))
+ (should (string= "Berkeley, California" (wttrin--favorite-location))))
(test-wttrin-set-location-from-geolocation-teardown)))
(ert-deftest test-wttrin-set-location-from-geolocation-normal-decline-leaves-variable-unchanged ()
@@ -84,7 +92,7 @@ favorite immediately instead of at the next scheduled fetch."
((symbol-function 'wttrin--mode-line-set-placeholder)
(lambda () nil)))
(wttrin-set-location-from-geolocation))
- (should (string= "Berkeley, California" wttrin-favorite-location))
+ (should (string= "Berkeley, California" (wttrin--favorite-location)))
(should fetched))
(test-wttrin-set-location-from-geolocation-teardown)))
@@ -97,7 +105,7 @@ favorite immediately instead of at the next scheduled fetch."
(progn
(test-wttrin-set-location--with-detected "München, Bayern" t
(wttrin-set-location-from-geolocation))
- (should (string= "München, Bayern" wttrin-favorite-location)))
+ (should (string= "München, Bayern" (wttrin--favorite-location))))
(test-wttrin-set-location-from-geolocation-teardown)))
;;; Error Cases
diff --git a/tests/test-wttrin-state-file.el b/tests/test-wttrin-state-file.el
new file mode 100644
index 0000000..5648a13
--- /dev/null
+++ b/tests/test-wttrin-state-file.el
@@ -0,0 +1,258 @@
+;;; test-wttrin-state-file.el --- Tests for state-file persistence -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;;; Commentary:
+
+;; Unit tests for the wttrin state file: `wttrin--state-save',
+;; `wttrin--state-read', and `wttrin--state-load'. The state file persists
+;; the runtime favorite (`wttrin--favorite-override') and runtime directory
+;; (`wttrin--saved-locations-runtime') in a file only wttrin writes, so a
+;; foreign Emacs process saving savehist without wttrin loaded can no longer
+;; scrub them. Covers round-trips, authority over in-memory values, the
+;; savehist-legacy adoption path, setter integration, and failure isolation.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'wttrin)
+
+(defmacro test-wttrin-state-file--with-sandbox (&rest body)
+ "Run BODY with `wttrin-state-file' bound to a fresh temp path.
+The runtime vars start nil. The file is removed afterward."
+ (declare (indent 0))
+ `(let ((wttrin-state-file (expand-file-name
+ (format "wttrin-test-state-%s.el" (random 1000000))
+ temporary-file-directory))
+ (wttrin--favorite-override nil)
+ (wttrin--saved-locations-runtime nil))
+ (unwind-protect
+ (progn ,@body)
+ (when (file-exists-p wttrin-state-file)
+ (delete-file wttrin-state-file)))))
+
+;;; --------------------------------------------------------------------------
+;;; Normal Cases
+;;; --------------------------------------------------------------------------
+
+(ert-deftest test-wttrin-state-file-normal-save-load-round-trip ()
+ "Normal: save writes both vars; load restores them after they are cleared."
+ (test-wttrin-state-file--with-sandbox
+ (setq wttrin--favorite-override "Hyatt Place Warwick, RI")
+ (setq wttrin--saved-locations-runtime
+ '(("Hyatt Place Warwick, RI" . "41.7266678,-71.443097")))
+ (wttrin--state-save)
+ (setq wttrin--favorite-override nil)
+ (setq wttrin--saved-locations-runtime nil)
+ (wttrin--state-load)
+ (should (equal wttrin--favorite-override "Hyatt Place Warwick, RI"))
+ (should (equal wttrin--saved-locations-runtime
+ '(("Hyatt Place Warwick, RI" . "41.7266678,-71.443097"))))))
+
+(ert-deftest test-wttrin-state-file-normal-file-wins-over-memory ()
+ "Normal: an existing state file is authoritative over in-memory values.
+This is the guard against a later savehist restore of stale legacy lines."
+ (test-wttrin-state-file--with-sandbox
+ (setq wttrin--favorite-override "Newer, ME")
+ (wttrin--state-save)
+ (setq wttrin--favorite-override "Stale Legacy, LA")
+ (setq wttrin--saved-locations-runtime '(("Stale" . "stale")))
+ (wttrin--state-load)
+ (should (equal wttrin--favorite-override "Newer, ME"))
+ (should (null wttrin--saved-locations-runtime))))
+
+(ert-deftest test-wttrin-state-file-normal-set-favorite-writes-file ()
+ "Normal: `wttrin--set-favorite-location' persists to the state file."
+ (test-wttrin-state-file--with-sandbox
+ (let ((wttrin-favorite-location nil)
+ (wttrin--location-history nil))
+ (wttrin--set-favorite-location "Paris, FR")
+ (should (file-exists-p wttrin-state-file))
+ (should (equal (plist-get (wttrin--state-read) :favorite-override)
+ "Paris, FR")))))
+
+(ert-deftest test-wttrin-state-file-normal-put-saved-location-writes-file ()
+ "Normal: `wttrin--put-saved-location' persists to the state file."
+ (test-wttrin-state-file--with-sandbox
+ (wttrin--put-saved-location "Home" "New Orleans, LA")
+ (should (equal (plist-get (wttrin--state-read) :saved-locations)
+ '(("Home" . "New Orleans, LA"))))))
+
+(ert-deftest test-wttrin-state-file-normal-remove-saved-location-writes-file ()
+ "Normal: `wttrin--remove-saved-location' persists the removal."
+ (test-wttrin-state-file--with-sandbox
+ (wttrin--put-saved-location "Home" "New Orleans, LA")
+ (wttrin--remove-saved-location "Home")
+ (should (null (plist-get (wttrin--state-read) :saved-locations)))))
+
+(ert-deftest test-wttrin-state-file-normal-rename-location-writes-file ()
+ "Normal: `wttrin-rename-location' persists the rename and favorite update."
+ (test-wttrin-state-file--with-sandbox
+ (let ((wttrin-saved-locations nil)
+ (wttrin-favorite-location nil)
+ (wttrin--location-history nil))
+ (wttrin--put-saved-location "Hotel" "41.72,-71.44")
+ (wttrin--set-favorite-location "Hotel")
+ (wttrin-rename-location "Hotel" "Hyatt")
+ (let ((data (wttrin--state-read)))
+ (should (equal (plist-get data :saved-locations)
+ '(("Hyatt" . "41.72,-71.44"))))
+ (should (equal (plist-get data :favorite-override) "Hyatt"))))))
+
+(ert-deftest test-wttrin-state-file-normal-adopts-savehist-legacy-values ()
+ "Normal: with no state file, non-nil vars (savehist legacy) are adopted."
+ (test-wttrin-state-file--with-sandbox
+ (setq wttrin--favorite-override "Legacy, TX")
+ (wttrin--state-load)
+ (should (file-exists-p wttrin-state-file))
+ (should (equal (plist-get (wttrin--state-read) :favorite-override)
+ "Legacy, TX"))))
+
+(ert-deftest test-wttrin-state-file-normal-savehist-mode-hook-wired ()
+ "Normal: `wttrin--state-load' is on `savehist-mode-hook' so a savehist
+restore after wttrin loads cannot clobber state-file values."
+ (should (memq #'wttrin--state-load savehist-mode-hook)))
+
+;;; --------------------------------------------------------------------------
+;;; Boundary Cases
+;;; --------------------------------------------------------------------------
+
+(ert-deftest test-wttrin-state-file-boundary-tri-state-t-round-trips ()
+ "Boundary: the favorite's auto-detect value t survives a round-trip."
+ (test-wttrin-state-file--with-sandbox
+ (setq wttrin--favorite-override t)
+ (wttrin--state-save)
+ (setq wttrin--favorite-override nil)
+ (wttrin--state-load)
+ (should (eq wttrin--favorite-override t))))
+
+(ert-deftest test-wttrin-state-file-boundary-nils-round-trip ()
+ "Boundary: an explicitly saved all-nil state loads as nils."
+ (test-wttrin-state-file--with-sandbox
+ (wttrin--state-save)
+ (setq wttrin--favorite-override "Ghost, AZ")
+ (setq wttrin--saved-locations-runtime '(("Ghost" . "ghost")))
+ (wttrin--state-load)
+ (should (null wttrin--favorite-override))
+ (should (null wttrin--saved-locations-runtime))))
+
+(ert-deftest test-wttrin-state-file-boundary-long-directory-round-trips ()
+ "Boundary: a long saved-locations alist survives intact.
+Guards the `print-length' / `print-level' bindings in the writer."
+ (test-wttrin-state-file--with-sandbox
+ (let ((entries (cl-loop for i from 1 to 60
+ collect (cons (format "Place %02d" i)
+ (format "%d.0,-%d.0" i i)))))
+ (setq wttrin--saved-locations-runtime entries)
+ (wttrin--state-save)
+ (setq wttrin--saved-locations-runtime nil)
+ (wttrin--state-load)
+ (should (equal wttrin--saved-locations-runtime entries))
+ (should (= (length wttrin--saved-locations-runtime) 60)))))
+
+(ert-deftest test-wttrin-state-file-boundary-unicode-round-trips ()
+ "Boundary: unicode names and queries survive a round-trip."
+ (test-wttrin-state-file--with-sandbox
+ (setq wttrin--favorite-override "Zürich 🌦️")
+ (setq wttrin--saved-locations-runtime '(("北京" . "Beijing, CN")))
+ (wttrin--state-save)
+ (setq wttrin--favorite-override nil)
+ (setq wttrin--saved-locations-runtime nil)
+ (wttrin--state-load)
+ (should (equal wttrin--favorite-override "Zürich 🌦️"))
+ (should (equal wttrin--saved-locations-runtime '(("北京" . "Beijing, CN"))))))
+
+(ert-deftest test-wttrin-state-file-boundary-read-absent-file-returns-nil ()
+ "Boundary: reading a nonexistent state file returns nil."
+ (test-wttrin-state-file--with-sandbox
+ (should-not (wttrin--state-read))))
+
+(ert-deftest test-wttrin-state-file-boundary-absent-file-nil-vars-noop ()
+ "Boundary: no file and nil vars is a silent no-op — no file created."
+ (test-wttrin-state-file--with-sandbox
+ (wttrin--state-load)
+ (should-not (file-exists-p wttrin-state-file))
+ (should (null wttrin--favorite-override))
+ (should (null wttrin--saved-locations-runtime))))
+
+;;; --------------------------------------------------------------------------
+;;; Error Cases
+;;; --------------------------------------------------------------------------
+
+(ert-deftest test-wttrin-state-file-error-corrupt-file-does-not-signal ()
+ "Error: unreadable lisp in the state file is ignored, vars untouched."
+ (test-wttrin-state-file--with-sandbox
+ (with-temp-file wttrin-state-file (insert "(((( not lisp"))
+ (setq wttrin--favorite-override "Kept, OK")
+ (wttrin--state-load)
+ (should (equal wttrin--favorite-override "Kept, OK"))))
+
+(ert-deftest test-wttrin-state-file-error-empty-file-does-not-signal ()
+ "Error: an empty state file is ignored, vars untouched."
+ (test-wttrin-state-file--with-sandbox
+ (with-temp-file wttrin-state-file)
+ (setq wttrin--favorite-override "Kept, OK")
+ (wttrin--state-load)
+ (should (equal wttrin--favorite-override "Kept, OK"))))
+
+(ert-deftest test-wttrin-state-file-error-wrong-shape-data-ignored ()
+ "Error: readable lisp that is not a versioned plist is treated as corrupt."
+ (test-wttrin-state-file--with-sandbox
+ (with-temp-file wttrin-state-file (insert "[1 2 3]"))
+ (setq wttrin--favorite-override "Kept, OK")
+ (wttrin--state-load)
+ (should (equal wttrin--favorite-override "Kept, OK"))))
+
+(ert-deftest test-wttrin-state-file-error-failed-write-preserves-file ()
+ "Error: a failed write leaves the previous state file intact.
+The writer goes through a temp file + rename, so an error before the
+rename cannot truncate or clobber the existing file."
+ (test-wttrin-state-file--with-sandbox
+ (setq wttrin--favorite-override "Good, OK")
+ (wttrin--state-save)
+ (setq wttrin--favorite-override "Never Written, NV")
+ (cl-letf (((symbol-function 'write-region)
+ (lambda (&rest _) (error "Disk full"))))
+ (wttrin--state-save))
+ (should (equal (plist-get (wttrin--state-read) :favorite-override)
+ "Good, OK"))))
+
+(ert-deftest test-wttrin-state-file-error-corrupt-file-survives-adoption ()
+ "Error: adoption never writes over an existing corrupt state file.
+The file is left byte-for-byte in place for inspection; non-nil vars do
+not trigger the missing-file adoption write because the file exists."
+ (test-wttrin-state-file--with-sandbox
+ (with-temp-file wttrin-state-file (insert "(((( not lisp"))
+ (setq wttrin--favorite-override "Legacy, TX")
+ (wttrin--state-load)
+ (should (equal (with-temp-buffer
+ (insert-file-contents wttrin-state-file)
+ (buffer-string))
+ "(((( not lisp"))))
+
+(ert-deftest test-wttrin-state-file-error-unwritable-path-does-not-signal ()
+ "Error: a save to an unwritable path messages instead of signaling."
+ (let ((wttrin-state-file "/nonexistent-root-dir/wttrin/state.el")
+ (wttrin--favorite-override "Anywhere, US")
+ (wttrin--saved-locations-runtime nil))
+ (should-not
+ (condition-case nil (progn (wttrin--state-save) nil) (error t)))))
+
+;;; --------------------------------------------------------------------------
+;;; savehist registration (post state-file)
+;;; --------------------------------------------------------------------------
+
+(ert-deftest test-wttrin-state-file-normal-savehist-excludes-state-vars ()
+ "Normal: `wttrin--savehist-register' no longer registers the state-file
+vars; only the scrub-tolerant search history stays with savehist."
+ (require 'savehist)
+ (let ((savehist-additional-variables '(kill-ring)))
+ (wttrin--savehist-register)
+ (should (memq 'wttrin--location-history savehist-additional-variables))
+ (should-not (memq 'wttrin--favorite-override savehist-additional-variables))
+ (should-not (memq 'wttrin--saved-locations-runtime
+ savehist-additional-variables))))
+
+(provide 'test-wttrin-state-file)
+;;; test-wttrin-state-file.el ends here
diff --git a/tests/test-wttrin-use-current-location.el b/tests/test-wttrin-use-current-location.el
index 4b61657..d7d0ea6 100644
--- a/tests/test-wttrin-use-current-location.el
+++ b/tests/test-wttrin-use-current-location.el
@@ -18,14 +18,20 @@
(defvar test-wttrin-use-current-location--saved nil
"Snapshot of `wttrin-favorite-location' restored in teardown.")
+(defvar test-wttrin-use-current-location--saved-override nil
+ "Snapshot of `wttrin--favorite-override' restored in teardown.")
+
(defun test-wttrin-use-current-location-setup ()
- "Snapshot `wttrin-favorite-location' and clear it."
+ "Snapshot the configured favorite and the runtime override, clearing both."
(setq test-wttrin-use-current-location--saved wttrin-favorite-location)
- (setq wttrin-favorite-location nil))
+ (setq test-wttrin-use-current-location--saved-override wttrin--favorite-override)
+ (setq wttrin-favorite-location nil)
+ (setq wttrin--favorite-override nil))
(defun test-wttrin-use-current-location-teardown ()
- "Restore `wttrin-favorite-location'."
- (setq wttrin-favorite-location test-wttrin-use-current-location--saved))
+ "Restore the configured favorite and the runtime override."
+ (setq wttrin-favorite-location test-wttrin-use-current-location--saved)
+ (setq wttrin--favorite-override test-wttrin-use-current-location--saved-override))
;;; Normal Cases
@@ -37,7 +43,7 @@
(cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t))
((symbol-function 'message) (lambda (&rest _) nil)))
(wttrin-use-current-location))
- (should (eq t wttrin-favorite-location)))
+ (should (eq t (wttrin--favorite-location))))
(test-wttrin-use-current-location-teardown)))
(ert-deftest test-wttrin-use-current-location-normal-decline-leaves-unchanged ()
@@ -69,7 +75,7 @@ location immediately rather than at the next scheduled fetch."
((symbol-function 'wttrin--mode-line-set-placeholder)
(lambda () nil)))
(wttrin-use-current-location))
- (should (eq t wttrin-favorite-location))
+ (should (eq t (wttrin--favorite-location)))
(should fetched))
(test-wttrin-use-current-location-teardown)))
diff --git a/tests/testutil-wttrin.el b/tests/testutil-wttrin.el
index e4e2e4e..1373813 100644
--- a/tests/testutil-wttrin.el
+++ b/tests/testutil-wttrin.el
@@ -136,17 +136,32 @@ Other mocks BODY needs can be set in a nested `cl-letf'."
;;; Test Setup and Teardown
+;; Redirect state-file writes for the whole batch: any test that exercises a
+;; location setter triggers `wttrin--state-save', which must never touch the
+;; developer's real `wttrin-state-file'. Process-wide, set once at load.
+(setq wttrin-state-file
+ (expand-file-name (format "wttrin-test-state-%d.el" (emacs-pid))
+ temporary-file-directory))
+
(defun testutil-wttrin-setup ()
"Common setup for wttrin tests.
Call this at the beginning of each test."
(testutil-wttrin-clear-cache)
- (setq wttrin--force-refresh nil))
+ (setq wttrin--force-refresh nil)
+ (setq wttrin--favorite-override nil)
+ (setq wttrin--saved-locations-runtime nil)
+ (when (file-exists-p wttrin-state-file)
+ (delete-file wttrin-state-file)))
(defun testutil-wttrin-teardown ()
"Common teardown for wttrin tests.
Call this at the end of each test."
(testutil-wttrin-clear-cache)
- (setq wttrin--force-refresh nil))
+ (setq wttrin--force-refresh nil)
+ (setq wttrin--favorite-override nil)
+ (setq wttrin--saved-locations-runtime nil)
+ (when (file-exists-p wttrin-state-file)
+ (delete-file wttrin-state-file)))
(provide 'testutil-wttrin)
;;; testutil-wttrin.el ends here
diff --git a/wttrin-geolocation.el b/wttrin-geolocation.el
index eba7989..664485d 100644
--- a/wttrin-geolocation.el
+++ b/wttrin-geolocation.el
@@ -81,7 +81,8 @@ This is the opt-in accuracy path. The command may do whatever the system
supports (a WiFi scan, a GPS read) to beat IP geolocation, and it runs
asynchronously so a multi-second lookup does not block Emacs. The package
ships no command and assumes nothing about the OS or network stack, so it is
-inert until set.
+inert until set. Ready-to-adapt example commands ship under the package's
+examples/geolocation/ directory.
On any failure (the command is unset, exits non-zero, or prints no parseable
lat/lng), wttrin falls back to the IP provider named by
diff --git a/wttrin.el b/wttrin.el
index ef83b45..ca9d0fd 100644
--- a/wttrin.el
+++ b/wttrin.el
@@ -35,6 +35,7 @@
(require 'face-remap)
(require 'subr-x) ; string-trim
+(require 'json) ; forecast j1 parsing
(require 'url)
;; Declare xterm-color functions (loaded on-demand)
@@ -237,13 +238,28 @@ coordinates. For example:
(\"Home\" . \"41.37,-71.83\"))
A bare string S used anywhere a location is expected is shorthand for
-\(S . S) — name and query the same. Persisted across sessions via
-`savehist-mode'; add entries interactively with \\[wttrin-save-location] or the
-`d' key in a weather buffer, or set this in your init."
+\(S . S) — name and query the same. Entries added interactively with
+\\[wttrin-save-location] or the `d' key in a weather buffer are persisted in
+`wttrin-state-file'; entries set here in your init are re-read each start."
:group 'wttrin
:type '(alist :key-type (string :tag "Name")
:value-type (string :tag "Query")))
+(defvar wttrin--favorite-override nil
+ "Runtime favorite set by `d'/`wttrin-make-default' and geolocation commands.
+Persisted across sessions in `wttrin-state-file'. When non-nil it shadows
+the configured `wttrin-favorite-location', so a favorite chosen at runtime
+survives a restart even when the user also sets `wttrin-favorite-location'
+in their init. Carries the same tri-state shape as that option: nil (no
+override), a string, or t (auto-detect).")
+
+(defvar wttrin--saved-locations-runtime nil
+ "Saved-location entries added at runtime via `d' or `wttrin-save-location'.
+Persisted across sessions in `wttrin-state-file'. Overlaid on the configured
+`wttrin-saved-locations' by `wttrin--saved-locations', runtime winning on a name
+collision, so directory edits survive a restart without being clobbered by an
+init that also sets `wttrin-saved-locations'. An alist of (NAME . QUERY).")
+
(defvar wttrin--resolved-favorite-location nil
"Cached geolocation result for `wttrin-favorite-location' = t.
Holds the resolved \"City, Region\" string so subsequent reads
@@ -254,9 +270,19 @@ do not re-fetch. Reset implicitly when the Emacs session ends.")
Prevents duplicate concurrent lookups when several consumers ask
during the resolution window.")
+(defun wttrin--favorite-location ()
+ "Return the effective favorite: the runtime override, else the configured one.
+`wttrin--favorite-override' (set at runtime by `d' and the geolocation
+commands, persisted in `wttrin-state-file') wins over the
+`wttrin-favorite-location' defcustom, so a runtime choice is not clobbered
+by an init that also sets the option. Carries the option's tri-state
+shape: nil, a string, or t."
+ (or wttrin--favorite-override wttrin-favorite-location))
+
(defun wttrin--resolve-favorite-location ()
"Return the favorite location's query string, or nil if unavailable.
-Resolves `wttrin-favorite-location' across the three modes:
+Resolves the effective favorite (`wttrin--favorite-location') across the three
+modes:
- nil -> nil (disabled)
- a string -> its saved-locations query when the string is a saved name,
otherwise the string as-is (the query for a plain location)
@@ -264,15 +290,16 @@ Resolves `wttrin-favorite-location' across the three modes:
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--resolve-location-query wttrin-favorite-location))
- ((eq wttrin-favorite-location t)
- (or wttrin--resolved-favorite-location
- (progn
- (wttrin--start-favorite-location-detect)
- nil)))))
+ (let ((fav (wttrin--favorite-location)))
+ (cond
+ ((null fav) nil)
+ ((stringp fav)
+ (wttrin--resolve-location-query fav))
+ ((eq fav 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.
@@ -296,12 +323,14 @@ call retries."
"Return a human-readable name for the favorite location.
For a string favorite this is the string itself (a saved-location name shows as
its name, not its resolved query). For t it is the resolved geolocation place,
-or \"current location\" while a lookup is pending. Nil when disabled."
- (cond
- ((stringp wttrin-favorite-location) wttrin-favorite-location)
- ((eq wttrin-favorite-location t)
- (or wttrin--resolved-favorite-location "current location"))
- (t nil)))
+or \"current location\" while a lookup is pending. Nil when disabled.
+Reads the effective favorite (`wttrin--favorite-location')."
+ (let ((fav (wttrin--favorite-location)))
+ (cond
+ ((stringp fav) fav)
+ ((eq fav t)
+ (or wttrin--resolved-favorite-location "current location"))
+ (t nil))))
(defcustom wttrin-mode-line-refresh-interval 3600
"Interval in seconds to refresh mode-line weather data.
@@ -311,6 +340,17 @@ wastes their bandwidth. Be kind to the free service."
:group 'wttrin
:type 'integer)
+(defcustom wttrin-mode-line-tooltip-forecast-days 0
+ "Days of forecast to append to the mode-line weather tooltip.
+0 (the default) keeps the tooltip current-conditions-only. A positive
+value appends one line per day: the remainder of today, tomorrow, and
+the day after, labeled Today / Tomorrow / weekday. wttr.in's JSON feed
+carries at most three days, so larger values are capped at what the
+feed returns. When non-zero, the mode-line refresh performs a second
+fetch (?format=j1) for the forecast data."
+ :group 'wttrin
+ :type 'integer)
+
(defcustom wttrin-mode-line-startup-delay 3
"Seconds to delay initial mode-line weather fetch after Emacs starts.
This allows network stack and daemon initialization to complete before
@@ -405,6 +445,14 @@ Set this to t BEFORE loading wttrin, typically in your init file:
When non-nil, car is the `float-time' when data was fetched,
and cdr is the weather string from the API.")
+(defvar wttrin--mode-line-forecast-cache nil
+ "Cached forecast data as a (timestamp . DAY-LIST) cons cell, or nil.
+When non-nil, car is the `float-time' of the fetch and cdr is the parsed
+day list from `wttrin--forecast-parse'. The raw day list is cached (not
+rendered text) so `wttrin--mode-line-tooltip' formats it at hover time
+against the current `wttrin-mode-line-tooltip-forecast-days' and
+`wttrin-unit-system' values.")
+
(defvar wttrin--mode-line-rendered-stale nil
"Whether the mode-line emoji is currently rendered as stale (dimmed).")
@@ -599,21 +647,122 @@ Persisted across sessions via `savehist-mode'.")
(defvar savehist-additional-variables)
(defun wttrin--savehist-register ()
- "Ensure wttrin's persisted variables are saved by savehist.
-Registers `wttrin--location-history', `wttrin-favorite-location', and
-`wttrin-saved-locations' so they survive across restarts without the Emacs
-custom-variable mechanism.
+ "Register the search history with savehist.
+Only `wttrin--location-history' rides savehist: it is scrub-tolerant
+throwaway data, which is what savehist is for. The runtime favorite and
+runtime directory are deliberately NOT registered — savehist rewrites its
+whole file on every save keeping only currently-registered variables, so any
+Emacs process that saves savehist without wttrin loaded (a deferred-loading
+session where wttrin was never opened, a batch tool) would silently delete
+them. Those live in `wttrin-state-file' instead, which only wttrin writes.
Run both at load and on `savehist-save-hook', so the registration survives a
user `setq' of `savehist-additional-variables' (a common config pattern) that
-would otherwise drop the entries before they could be saved."
- (add-to-list 'savehist-additional-variables 'wttrin--location-history)
- (add-to-list 'savehist-additional-variables 'wttrin-favorite-location)
- (add-to-list 'savehist-additional-variables 'wttrin-saved-locations))
+would otherwise drop the entry before it could be saved."
+ (add-to-list 'savehist-additional-variables 'wttrin--location-history))
+
+;;;###autoload (with-eval-after-load 'savehist (add-to-list 'savehist-additional-variables 'wttrin--location-history))
(with-eval-after-load 'savehist
(wttrin--savehist-register)
(add-hook 'savehist-save-hook #'wttrin--savehist-register))
+(defcustom wttrin-state-file (locate-user-emacs-file "wttrin-state.el")
+ "File persisting wttrin's runtime state across sessions.
+Holds the runtime favorite (`wttrin--favorite-override') and the runtime
+saved-locations directory (`wttrin--saved-locations-runtime') — the values
+written by `d'/`wttrin-make-default', `wttrin-save-location', and the
+geolocation commands. A dedicated file rather than savehist because savehist
+rewrites its whole file keeping only the variables registered in the current
+process: any Emacs session that saved savehist without wttrin loaded would
+silently delete the persisted favorite. Only wttrin writes this file.
+Customize this before wttrin loads (e.g. via use-package `:custom'); the
+state restores at load time, so a later `setq' takes effect only from the
+next restore (`savehist-mode-hook' or restart)."
+ :group 'wttrin
+ :type 'file)
+
+(defun wttrin--state-save ()
+ "Write the runtime favorite and directory to `wttrin-state-file'.
+Writes atomically (temp file + rename) so a crash mid-write cannot leave a
+truncated file, always as UTF-8 so the file round-trips regardless of the
+session's default coding system (the same reason savehist pins
+`savehist-coding-system'). Never signals: persistence failure must not
+break the location command that triggered it — it messages and moves on."
+ (condition-case err
+ (let* ((file (expand-file-name wttrin-state-file))
+ (dir (file-name-directory file))
+ (tmp (make-temp-name (concat file ".tmp")))
+ (coding-system-for-write 'utf-8-emacs)
+ (print-length nil)
+ (print-level nil))
+ (unless (file-directory-p dir) (make-directory dir t))
+ (unwind-protect
+ (progn
+ (with-temp-buffer
+ (insert ";; wttrin runtime state -- managed by wttrin;"
+ " do not edit by hand.\n")
+ (prin1 (list :version 1
+ :favorite-override wttrin--favorite-override
+ :saved-locations wttrin--saved-locations-runtime)
+ (current-buffer))
+ (insert "\n")
+ (write-region (point-min) (point-max) tmp nil 'silent))
+ (rename-file tmp file t)
+ (setq tmp nil))
+ ;; Any failure above leaves the temp file behind; remove it. On
+ ;; success TMP is nil and there is nothing to clean.
+ (when (and tmp (file-exists-p tmp))
+ (delete-file tmp))))
+ (error (message "wttrin: could not save state to %s: %s"
+ wttrin-state-file (error-message-string err)))))
+
+(defun wttrin--state-read ()
+ "Read `wttrin-state-file' and return its state plist, or nil.
+Returns nil when the file is absent, unreadable, or does not contain a
+versioned plist. Reads with `read', never `eval', so the state file cannot
+execute code, and as UTF-8, matching how `wttrin--state-save' writes it.
+Never signals — a broken state file must not break load."
+ (when (file-readable-p wttrin-state-file)
+ (condition-case err
+ (with-temp-buffer
+ (let ((coding-system-for-read 'utf-8-emacs))
+ (insert-file-contents wttrin-state-file))
+ (let ((data (read (current-buffer))))
+ (if (and (listp data) (integerp (plist-get data :version)))
+ data
+ (message "wttrin: ignoring malformed state file %s"
+ wttrin-state-file)
+ nil)))
+ (error (message "wttrin: could not read state file %s: %s"
+ wttrin-state-file (error-message-string err))
+ nil))))
+
+(defun wttrin--state-load ()
+ "Restore the runtime favorite and directory from `wttrin-state-file'.
+An existing state file is authoritative: its values replace whatever is in
+memory, which is what defeats a later savehist restore of stale legacy
+entries (this function also runs on `savehist-mode-hook'). When the file
+does not exist but either variable is non-nil — savehist restored legacy
+values from a pre-state-file wttrin earlier in init — those values are
+adopted and written, migrating existing users with zero steps. A corrupt
+file is left in place and ignored: vars keep their current values and no
+adoption write happens over it."
+ (let ((data (wttrin--state-read)))
+ (cond
+ (data
+ (setq wttrin--favorite-override (plist-get data :favorite-override))
+ (setq wttrin--saved-locations-runtime (plist-get data :saved-locations)))
+ ((and (not (file-exists-p wttrin-state-file))
+ (or wttrin--favorite-override wttrin--saved-locations-runtime))
+ (wttrin--state-save)))))
+
+;; Restore persisted state at load — but not in batch (CI, test runners,
+;; scripts), which stays hermetic by design. The savehist-mode-hook entry
+;; re-asserts the state file after savehist restores, in case a savehist file
+;; still carrying legacy pre-state-file entries loads later in init.
+(unless noninteractive (wttrin--state-load))
+(add-hook 'savehist-mode-hook #'wttrin--state-load)
+
(defconst wttrin--geolocation-sentinel "Current location (detect)"
"Picker candidate that triggers geolocation detection.
Selecting it routes through `wttrin--query-selection' to a
@@ -650,8 +799,8 @@ other, never both."
(setq wttrin--location-history
(delete location wttrin--location-history)))))
-(defun wttrin--saved-locations ()
- "Return `wttrin-saved-locations' as a clean list of (NAME . QUERY) pairs.
+(defun wttrin--normalize-location-entries (entries)
+ "Return ENTRIES as a clean list of (NAME . QUERY) pairs.
Skips malformed entries — non-cons, a non-string name or query, or an empty
name or query — and trims surrounding whitespace, so stale or hand-edited
config never errors. A bare string S is read as (S . S)."
@@ -667,7 +816,24 @@ config never errors. A bare string S is read as (S . S)."
(let ((s (string-trim entry)))
(and (> (length s) 0) (cons s s))))
(t nil)))
- wttrin-saved-locations)))
+ entries)))
+
+(defun wttrin--saved-locations ()
+ "Return the effective saved-locations directory as clean (NAME . QUERY) pairs.
+The runtime layer `wttrin--saved-locations-runtime' (set by `d' and
+`wttrin-save-location', persisted by savehist) is overlaid on the configured
+`wttrin-saved-locations', runtime winning on a name collision. Both layers are
+normalized (malformed entries skipped, whitespace trimmed, a bare string S read
+as (S . S)); runtime entries are listed first, then config entries whose names
+the runtime does not already define."
+ (let* ((runtime (wttrin--normalize-location-entries wttrin--saved-locations-runtime))
+ (runtime-names (mapcar #'car runtime))
+ (config (wttrin--normalize-location-entries wttrin-saved-locations)))
+ (append runtime
+ (delq nil
+ (mapcar (lambda (entry)
+ (unless (member (car entry) runtime-names) entry))
+ config)))))
(defun wttrin--resolve-location-query (selection)
"Return the query string for a picker SELECTION.
@@ -683,31 +849,41 @@ Used to keep a raw geolocation fix out of history and to decide when the
(and (stringp string)
(string-match-p "\\`[ ]*-?[0-9.]+[ ]*,[ ]*-?[0-9.]+[ ]*\\'" string)))
-(defun wttrin--saved-locations-without (name)
- "Return `wttrin-saved-locations' with any entry named NAME removed."
+(defun wttrin--saved-locations-runtime-without (name)
+ "Return `wttrin--saved-locations-runtime' with any entry named NAME removed."
(delq nil
(mapcar (lambda (entry)
(unless (and (consp entry) (equal (car entry) name)) entry))
- wttrin-saved-locations)))
+ wttrin--saved-locations-runtime)))
(defun wttrin--put-saved-location (name query)
- "Add or update NAME -> QUERY in `wttrin-saved-locations'; return the saved name.
-Trims NAME and QUERY. Signals a `user-error' for an empty name or query, or a
-name equal to the geolocation sentinel. An existing name has its query updated."
+ "Add or update NAME -> QUERY in the runtime directory; return the saved name.
+Writes `wttrin--saved-locations-runtime' (persisted in `wttrin-state-file'),
+never the `wttrin-saved-locations' defcustom, so a runtime save is not
+clobbered by an init that also sets the option. Trims NAME and QUERY.
+Signals a `user-error'
+for an empty name or query, or a name equal to the geolocation sentinel. An
+existing runtime name has its query updated."
(let ((name (string-trim (or name "")))
(query (string-trim (or query ""))))
(when (string= name "") (user-error "Location name cannot be empty"))
(when (string= query "") (user-error "Location query cannot be empty"))
(when (string= name wttrin--geolocation-sentinel)
(user-error "That name is reserved for the geolocation entry"))
- (setq wttrin-saved-locations
- (append (wttrin--saved-locations-without name)
+ (setq wttrin--saved-locations-runtime
+ (append (wttrin--saved-locations-runtime-without name)
(list (cons name query))))
+ (wttrin--state-save)
name))
(defun wttrin--remove-saved-location (name)
- "Remove the saved location named NAME from `wttrin-saved-locations'."
- (setq wttrin-saved-locations (wttrin--saved-locations-without name)))
+ "Remove the saved location named NAME from the runtime directory.
+Only the runtime layer `wttrin--saved-locations-runtime' is mutated. An entry
+from the `wttrin-saved-locations' defcustom cannot be deleted here (it is
+re-read from the user's init) and remains in the effective directory."
+ (setq wttrin--saved-locations-runtime
+ (wttrin--saved-locations-runtime-without name))
+ (wttrin--state-save))
(defvar-local wttrin--current-location nil
"Query for the weather shown in this buffer (the fetch/cache identity).")
@@ -745,8 +921,8 @@ history (the explicit alias wins over a same-named default or history string),
so each place appears exactly once. The geolocation sentinel is prepended when
geolocation is enabled."
(let* ((saved (mapcar #'car (wttrin--saved-locations)))
- (favorite (and (stringp wttrin-favorite-location)
- (list wttrin-favorite-location)))
+ (favorite (let ((fav (wttrin--favorite-location)))
+ (and (stringp fav) (list fav))))
(deduped (delete-dups
(append saved favorite
(copy-sequence wttrin-default-locations)
@@ -865,7 +1041,7 @@ the favorite is updated to NEW."
(let ((query (cdr entry)))
(wttrin--remove-saved-location old)
(wttrin--put-saved-location new query)
- (when (equal wttrin-favorite-location old)
+ (when (equal (wttrin--favorite-location) old)
(wttrin--set-favorite-location new))
(message "Renamed %s to %s" old new))))))
@@ -880,16 +1056,22 @@ When NAME is the favorite, it is left as a literal query with a warning."
((not (assoc name (wttrin--saved-locations)))
(user-error "No saved location named %s" name))
((yes-or-no-p (format "Remove saved location \"%s\"? " name))
- (let ((query (cdr (assoc name (wttrin--saved-locations)))))
+ (let ((query (cdr (assoc name (wttrin--saved-locations))))
+ (was-favorite (equal (wttrin--favorite-location) name)))
(wttrin--remove-saved-location name)
- (wttrin--drop-from-location-history name query))
- (if (equal wttrin-favorite-location name)
- (progn
- (when (bound-and-true-p wttrin-mode-line-mode)
- (wttrin--mode-line-refresh-now))
- (message "Removed %s; it was your favorite and is now a literal query until you set a new one"
- name))
- (message "Removed %s" name)))
+ (wttrin--drop-from-location-history name query)
+ (when (and was-favorite (bound-and-true-p wttrin-mode-line-mode))
+ (wttrin--mode-line-refresh-now))
+ (cond
+ ;; Still resolvable means the name comes from the user's init, which the
+ ;; runtime removal can't touch, so the entry stays in the directory.
+ ((assoc name (wttrin--saved-locations))
+ (message "Removed the runtime entry %s; it is still defined in your init (wttrin-saved-locations) and remains in the directory"
+ name))
+ (was-favorite
+ (message "Removed %s; it was your favorite and is now a literal query until you set a new one"
+ name))
+ (t (message "Removed %s" name)))))
(t (message "Cancelled"))))
(defun wttrin--requery-location (new-location)
@@ -1249,6 +1431,25 @@ coordinates but can name the place)."
(defvar-local wttrin--current-request-id nil
"Request id of the most recent query for this weather buffer.")
+(defun wttrin--render-loading-placeholder (query display)
+ "Show the loading placeholder for QUERY in the current buffer.
+DISPLAY is the name to show (a saved-location name); when nil it falls back to
+QUERY. Erases the buffer, inserts the one-line placeholder, resets the font to
+the base height (dropping any auto-fit remap from the previous weather), and
+centers it via `wttrin--update-layout'. The explicit centering matters on an
+`a' switch: the *wttr.in* buffer is already displayed, so
+`window-configuration-change-hook' does not fire and the placeholder would
+otherwise keep the previous weather block's window margin."
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert "Loading weather for " (or display query) "..."))
+ (setq buffer-read-only t)
+ ;; The placeholder is one line; keep auto-fit off it (weather not yet rendered)
+ ;; and show it at the base font rather than the previous weather's size.
+ (setq-local wttrin--weather-rendered nil)
+ (wttrin--reset-font-height)
+ (wttrin--update-layout))
+
(defun wttrin-query (query &optional display address)
"Asynchronously query weather for QUERY, display the result when ready.
QUERY is what weather is fetched by (and the cache key). Optional DISPLAY is
@@ -1258,15 +1459,8 @@ coordinates from a geolocation command."
(let ((buffer (get-buffer-create (format "*wttr.in*")))
(request-id (setq wttrin--request-counter (1+ wttrin--request-counter))))
(switch-to-buffer buffer)
- (setq buffer-read-only nil)
- (erase-buffer)
- (insert "Loading weather for " (or display query) "...")
- (setq buffer-read-only t)
(setq-local wttrin--current-request-id request-id)
- ;; The placeholder is one line; keep auto-fit off it and show it at the base
- ;; font rather than the previous weather's auto-fitted (possibly capped) size.
- (setq-local wttrin--weather-rendered nil)
- (wttrin--reset-font-height)
+ (wttrin--render-loading-placeholder query display)
(wttrin--get-cached-or-fetch
query
(lambda (raw-string &optional error-msg)
@@ -1346,12 +1540,14 @@ This creates headroom to avoid frequent cleanups."
(defun wttrin-set-location-from-geolocation ()
"Detect your location via IP geolocation and set it as the favorite.
Uses the provider named by `wttrin-geolocation-provider' to fetch
-\"City, Region\", asks for confirmation, and on yes assigns the
-result to `wttrin-favorite-location'.
+\"City, Region\", asks for confirmation, and on yes makes the result
+the runtime favorite (`wttrin--favorite-override', which shadows the
+`wttrin-favorite-location' option).
With `savehist-mode' on, the favorite persists across sessions
-automatically (wttrin registers it with savehist); no
-`customize-save-variable' step is needed.
+automatically (wttrin registers the override with savehist); no
+`customize-save-variable' step is needed, and it is not overwritten by
+an init that also sets `wttrin-favorite-location'.
IP-based geolocation can be wrong behind a VPN or a mobile hotspot.
The confirmation prompt shows the detected location so you can
@@ -1373,7 +1569,7 @@ the detected city as your default."
((yes-or-no-p (format "Detected location: %s. Set as favorite? "
location))
(wttrin--set-favorite-location location)
- (message "Set wttrin-favorite-location to: %s%s"
+ (message "Set favorite location to: %s%s"
location
(if (bound-and-true-p savehist-mode)
" (persisted via savehist)."
@@ -1389,13 +1585,14 @@ the detected city as your default."
;;;###autoload
(defun wttrin-use-current-location ()
"Make your current location the persistent favorite (always auto-detect).
-Sets `wttrin-favorite-location' to t after confirmation, so the mode-line
-and buffer track wherever you are via geolocation rather than a fixed city.
-This is the labeled way to choose auto-detect without typing the bare symbol
-t into your init.
-
-With `savehist-mode' on, the choice persists across sessions automatically.
-Does nothing when `wttrin-geolocation-enabled' is nil."
+After confirmation, sets the runtime favorite (`wttrin--favorite-override') to
+t, so the mode-line and buffer track wherever you are via geolocation rather
+than a fixed city. This is the labeled way to choose auto-detect without typing
+the bare symbol t into your init.
+
+With `savehist-mode' on, the choice persists across sessions automatically, and
+is not overwritten by an init that also sets `wttrin-favorite-location'. Does
+nothing when `wttrin-geolocation-enabled' is nil."
(interactive)
(cond
((not wttrin-geolocation-enabled)
@@ -1421,16 +1618,18 @@ Does nothing when `wttrin-geolocation-enabled' is nil."
(message "No location to refresh")))
(defun wttrin--set-favorite-location (location)
- "Set `wttrin-favorite-location' to LOCATION and drop it from search history.
-LOCATION becomes a permanent default, so it no longer needs a history entry,
-mirroring how `wttrin-default-locations' entries are kept out of history.
-Persistence is handled by `wttrin--savehist-register', which registers the
-variable when savehist loads and again on `savehist-save-hook', so the value
-survives restarts without the Emacs custom-variable mechanism, and setting it
-here works whether or not savehist is loaded."
- (let ((changed (not (equal location wttrin-favorite-location))))
- (setq wttrin-favorite-location location)
+ "Set the runtime favorite to LOCATION and drop it from search history.
+Writes `wttrin--favorite-override', not the `wttrin-favorite-location'
+defcustom, so the choice survives a restart even when the user also sets the
+option in their init (the override shadows the config; see
+`wttrin--favorite-location'). LOCATION becomes a permanent default, so it no
+longer needs a history entry, mirroring how `wttrin-default-locations' entries
+are kept out of history. Persisted immediately to `wttrin-state-file', so the
+choice survives restarts regardless of savehist."
+ (let ((changed (not (equal location (wttrin--favorite-location)))))
+ (setq wttrin--favorite-override location)
(setq wttrin--location-history (delete location wttrin--location-history))
+ (wttrin--state-save)
(when (and changed (bound-and-true-p wttrin-mode-line-mode))
(wttrin--mode-line-refresh-now))))
@@ -1528,10 +1727,11 @@ 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)
+ (wttrin--debug-log "mode-line-fetch: Starting fetch for %s" (wttrin--favorite-location))
(let ((location (wttrin--resolve-favorite-location)))
(if (not location)
(wttrin--debug-log "mode-line-fetch: No favorite location available, skipping")
+ (wttrin--mode-line-fetch-forecast location)
(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")
@@ -1581,25 +1781,128 @@ without a separate guard."
(let ((age (- (float-time) (car cache-entry))))
(> age (* 2 wttrin-mode-line-refresh-interval)))))
+(defun wttrin--forecast-parse (json-string)
+ "Parse a wttr.in ?format=j1 JSON-STRING into a list of day alists.
+Returns the top-level weather array as a list (one alist per day), or
+nil when JSON-STRING is nil, empty, malformed, or missing the weather
+key. Never signals -- a broken forecast response must not take the
+mode-line fetch down with it."
+ (when (and (stringp json-string) (> (length json-string) 0))
+ (condition-case err
+ (let ((json-object-type 'alist)
+ (json-array-type 'list)
+ (json-key-type 'symbol))
+ (alist-get 'weather (json-read-from-string json-string)))
+ (error
+ (wttrin--debug-log "forecast-parse: %s" (error-message-string err))
+ nil))))
+
+(defun wttrin--forecast-day-label (day index)
+ "Return the tooltip label for DAY (a day alist) at INDEX in the forecast.
+Index 0 is \"Today\" and 1 is \"Tomorrow\"; later days use the abbreviated
+weekday name from DAY's date field (\"Fri\"). Falls back to the raw date
+string when it doesn't parse, and to the index when there's no date."
+ (let ((date (alist-get 'date day)))
+ (cond
+ ((= index 0) "Today")
+ ((= index 1) "Tomorrow")
+ ((stringp date)
+ (condition-case nil
+ (let ((parsed (parse-time-string date)))
+ (format-time-string
+ "%a" (encode-time 0 0 12 (nth 3 parsed) (nth 4 parsed)
+ (nth 5 parsed))))
+ (error date)))
+ (t (format "Day %d" (1+ index))))))
+
+(defun wttrin--forecast-midday-desc (day)
+ "Return DAY's midday weather description, or nil when unavailable.
+Reads the hourly entry whose time is \"1200\" (falling back to the middle
+entry) and returns the first weatherDesc value."
+ (let* ((hourly (alist-get 'hourly day))
+ (midday (or (seq-find (lambda (h) (equal (alist-get 'time h) "1200"))
+ hourly)
+ (nth (/ (length hourly) 2) hourly))))
+ (when midday
+ (let ((desc (alist-get 'value (car (alist-get 'weatherDesc midday)))))
+ ;; wttr.in pads some description values with trailing whitespace.
+ (when (stringp desc) (string-trim desc))))))
+
+(defun wttrin--forecast-format (days count)
+ "Format up to COUNT entries of DAYS (parsed day alists) for the tooltip.
+One line per day: \"<label> <min>-<max>°F <midday description>\" -- °F when
+`wttrin-unit-system' is \"u\", otherwise °C (both are present in the j1
+data; wttr.in's own location-based default can't be known here). The
+description is omitted when the day has no hourly data. Returns nil when
+DAYS is nil or COUNT is not positive."
+ (when (and days (> count 0))
+ (let* ((fahrenheit (equal wttrin-unit-system "u"))
+ (min-key (if fahrenheit 'mintempF 'mintempC))
+ (max-key (if fahrenheit 'maxtempF 'maxtempC))
+ (unit (if fahrenheit "°F" "°C"))
+ (index -1))
+ (mapconcat
+ (lambda (day)
+ (setq index (1+ index))
+ (let ((desc (wttrin--forecast-midday-desc day)))
+ (concat (wttrin--forecast-day-label day index)
+ (format " %s-%s%s"
+ (alist-get min-key day)
+ (alist-get max-key day)
+ unit)
+ (when desc (concat " " desc)))))
+ (seq-take days count)
+ "\n"))))
+
+(defun wttrin--mode-line-fetch-forecast (location)
+ "Fetch the j1 forecast for LOCATION into `wttrin--mode-line-forecast-cache'.
+Fires only when `wttrin-mode-line-tooltip-forecast-days' is positive.
+On a failed fetch or unparseable response the previous cache is kept --
+the tooltip just shows whatever forecast it last had."
+ (when (> wttrin-mode-line-tooltip-forecast-days 0)
+ (let ((url (concat "https://wttr.in/"
+ (url-hexify-string location)
+ "?format=j1")))
+ (wttrin--debug-log "forecast-fetch: URL = %s" url)
+ (wttrin--fetch-url
+ url
+ (lambda (data &optional _error-msg)
+ (let ((days (wttrin--forecast-parse data)))
+ (if days
+ (setq wttrin--mode-line-forecast-cache
+ (cons (float-time) days))
+ (wttrin--debug-log
+ "forecast-fetch: no usable forecast, keeping previous"))))))))
+
(defun wttrin--mode-line-tooltip (&optional _window _object _pos)
"Compute tooltip text from `wttrin--mode-line-cache'.
Calculates age at call time so the tooltip is always current.
If staleness has changed since the last render, triggers a re-render
so the emoji dimming matches.
+When `wttrin-mode-line-tooltip-forecast-days' is positive and a forecast
+has been fetched, the forecast lines sit between the conditions and the
+age line.
Optional arguments are ignored (required by `help-echo' function protocol)."
(when wttrin--mode-line-cache
(let* ((timestamp (car wttrin--mode-line-cache))
(weather-string (cdr wttrin--mode-line-cache))
(age (- (float-time) timestamp))
(stale-p (wttrin--mode-line-stale-p wttrin--mode-line-cache))
- (age-str (wttrin--format-age age)))
+ (age-str (wttrin--format-age age))
+ (forecast (when (> wttrin-mode-line-tooltip-forecast-days 0)
+ (wttrin--forecast-format
+ (cdr wttrin--mode-line-forecast-cache)
+ wttrin-mode-line-tooltip-forecast-days)))
+ (head (if forecast
+ (concat weather-string "\n" forecast)
+ weather-string)))
;; Re-render emoji if staleness state has changed
(unless (eq stale-p wttrin--mode-line-rendered-stale)
(wttrin--mode-line-update-display))
(if stale-p
(format "%s\nStale: updated %s — fetch failed, will retry"
- weather-string age-str)
- (format "%s\nUpdated %s" weather-string age-str)))))
+ head age-str)
+ (format "%s\nUpdated %s" head age-str)))))
(defun wttrin--mode-line-update-display ()
"Update mode-line display from `wttrin--mode-line-cache'.
@@ -1691,9 +1994,9 @@ mode-line state."
(defun wttrin--mode-line-start ()
"Start mode-line weather display and refresh timer."
(wttrin--debug-log "wttrin mode-line: Starting mode-line display (location=%s, interval=%s)"
- wttrin-favorite-location
+ (wttrin--favorite-location)
wttrin-mode-line-refresh-interval)
- (when wttrin-favorite-location
+ (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.