aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-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
9 files changed, 752 insertions, 97 deletions
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