aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test-wttrin--add-buffer-instructions.el50
-rw-r--r--tests/test-wttrin--display-weather.el29
-rw-r--r--tests/test-wttrin--format-location-line.el35
-rw-r--r--tests/test-wttrin--format-staleness-header.el12
-rw-r--r--tests/test-wttrin--handle-fetch-callback.el21
-rw-r--r--tests/test-wttrin--mode-line-helpers.el30
-rw-r--r--tests/test-wttrin--mode-line-update-display.el34
-rw-r--r--tests/test-wttrin-clear-cache.el11
-rw-r--r--tests/test-wttrin-error-types.el128
-rw-r--r--tests/test-wttrin-faces.el26
-rw-r--r--tests/test-wttrin-geolocation--internals.el6
-rw-r--r--tests/test-wttrin-geolocation-command.el161
-rw-r--r--tests/test-wttrin-geolocation-sentinel.el212
-rw-r--r--tests/test-wttrin-location-history.el242
-rw-r--r--tests/test-wttrin-make-default.el176
-rw-r--r--tests/test-wttrin-query.el25
-rw-r--r--tests/test-wttrin-requery-force.el21
-rw-r--r--tests/test-wttrin-requery.el23
-rw-r--r--tests/test-wttrin-saved-locations.el418
-rw-r--r--tests/test-wttrin-set-location-from-geolocation.el52
-rw-r--r--tests/test-wttrin-use-current-location.el94
-rw-r--r--tests/testutil-wttrin.el14
22 files changed, 1715 insertions, 105 deletions
diff --git a/tests/test-wttrin--add-buffer-instructions.el b/tests/test-wttrin--add-buffer-instructions.el
index 4949c45..fdd7c91 100644
--- a/tests/test-wttrin--add-buffer-instructions.el
+++ b/tests/test-wttrin--add-buffer-instructions.el
@@ -14,6 +14,17 @@
;;; Setup and Teardown
+(defun test-wttrin--expected-footer ()
+ "Return the footer string `wttrin--add-buffer-instructions' should produce.
+Width 23 is pinned here as a literal, independent of the production
+constant, so the test fails if the visible layout drifts."
+ (concat "\n\n"
+ (format "%-23s%s" "This view" "Saved locations") "\n"
+ (format "%-23s%s" "[a] another" "[s] save") "\n"
+ (format "%-23s%s" "[g] refresh" "[d] make default") "\n"
+ (format "%-23s%s" "[q] quit" "[r] rename") "\n"
+ (format "%-23s%s" "" "[x] remove")))
+
(defun test-wttrin--add-buffer-instructions-setup ()
"Setup for add-buffer-instructions tests."
(testutil-wttrin-setup))
@@ -28,7 +39,7 @@
"Test adding instructions to empty buffer."
(with-temp-buffer
(wttrin--add-buffer-instructions)
- (should (string= "\n\nPress: [a] for another location [g] to refresh [q] to quit"
+ (should (string= (test-wttrin--expected-footer)
(buffer-string)))))
(ert-deftest test-wttrin--add-buffer-instructions-normal-with-existing-content-appends-instructions ()
@@ -36,7 +47,8 @@
(with-temp-buffer
(insert "Weather: Sunny\nTemperature: 20°C")
(wttrin--add-buffer-instructions)
- (should (string= "Weather: Sunny\nTemperature: 20°C\n\nPress: [a] for another location [g] to refresh [q] to quit"
+ (should (string= (concat "Weather: Sunny\nTemperature: 20°C"
+ (test-wttrin--expected-footer))
(buffer-string)))))
(ert-deftest test-wttrin--add-buffer-instructions-normal-preserves-point-moves-to-end ()
@@ -56,12 +68,12 @@
(wttrin--add-buffer-instructions)
;; Should add instructions again, not check if they already exist
(should-not (string= first-result (buffer-string)))
- ;; Check for two occurrences of "Press:" by counting matches
+ ;; Check for two occurrences of the header by counting matches
(let ((count 0))
(with-temp-buffer
(insert first-result)
(goto-char (point-min))
- (while (search-forward "Press:" nil t)
+ (while (search-forward "This view" nil t)
(setq count (1+ count))))
;; After first call, should have 1 occurrence
(should (= 1 count)))
@@ -69,10 +81,28 @@
(let ((count 0))
(save-excursion
(goto-char (point-min))
- (while (search-forward "Press:" nil t)
+ (while (search-forward "This view" nil t)
(setq count (1+ count))))
(should (= 2 count))))))
+(ert-deftest test-wttrin--add-buffer-instructions-normal-key-chords-carry-key-face ()
+ "Bracketed key chords are styled with `wttrin-key'."
+ (with-temp-buffer
+ (wttrin--add-buffer-instructions)
+ (goto-char (point-min))
+ (search-forward "[a]")
+ ;; point is just after the closing bracket; the bracket char is part
+ ;; of the "[a]" segment
+ (should (eq (get-text-property (1- (point)) 'face) 'wttrin-key))))
+
+(ert-deftest test-wttrin--add-buffer-instructions-normal-prose-carries-instructions-face ()
+ "The footer prose is styled with `wttrin-instructions'."
+ (with-temp-buffer
+ (wttrin--add-buffer-instructions)
+ (goto-char (point-min))
+ (search-forward "another")
+ (should (eq (get-text-property (1- (point)) 'face) 'wttrin-instructions))))
+
;;; Boundary Cases
(ert-deftest test-wttrin--add-buffer-instructions-boundary-point-at-beginning-appends-at-end ()
@@ -81,7 +111,7 @@
(insert "Weather data here")
(goto-char (point-min))
(wttrin--add-buffer-instructions)
- (should (string-suffix-p "Press: [a] for another location [g] to refresh [q] to quit"
+ (should (string-suffix-p "[x] remove"
(buffer-string)))))
(ert-deftest test-wttrin--add-buffer-instructions-boundary-point-in-middle-appends-at-end ()
@@ -91,7 +121,7 @@
(goto-char (point-min))
(forward-line 1)
(wttrin--add-buffer-instructions)
- (should (string-suffix-p "Press: [a] for another location [g] to refresh [q] to quit"
+ (should (string-suffix-p "[x] remove"
(buffer-string)))))
(ert-deftest test-wttrin--add-buffer-instructions-boundary-trailing-newlines-preserves-newlines ()
@@ -99,7 +129,7 @@
(with-temp-buffer
(insert "Weather\n\n\n")
(wttrin--add-buffer-instructions)
- (should (string= "Weather\n\n\n\n\nPress: [a] for another location [g] to refresh [q] to quit"
+ (should (string= (concat "Weather\n\n\n" (test-wttrin--expected-footer))
(buffer-string)))))
(ert-deftest test-wttrin--add-buffer-instructions-boundary-very-large-buffer-appends-at-end ()
@@ -108,7 +138,7 @@
(insert (make-string 10000 ?x))
(wttrin--add-buffer-instructions)
(goto-char (point-max))
- (should (looking-back "Press: \\[a\\] for another location \\[g\\] to refresh \\[q\\] to quit" nil))))
+ (should (looking-back "\\[x\\] remove" nil))))
;;; Error Cases
@@ -134,7 +164,7 @@
(widen)
(goto-char start)
(forward-line 1)
- (should (looking-at-p "\n\nPress:")))))
+ (should (looking-at-p "\n\nThis view")))))
(provide 'test-wttrin--add-buffer-instructions)
;;; test-wttrin--add-buffer-instructions.el ends here
diff --git a/tests/test-wttrin--display-weather.el b/tests/test-wttrin--display-weather.el
index bca6a6c..99ea067 100644
--- a/tests/test-wttrin--display-weather.el
+++ b/tests/test-wttrin--display-weather.el
@@ -97,13 +97,14 @@ Weather report: Paris, France
(wttrin--display-weather "Tokyo" test-wttrin--display-weather-sample-raw-data)
(with-current-buffer "*wttr.in*"
- (goto-char (point-max))
- (forward-line -2)
- ;; Should contain help text
- (should (search-forward "Press:" nil t))
- (should (search-forward "[a] for another location" nil t))
- (should (search-forward "[g] to refresh" nil t))
- (should (search-forward "[q] to quit" nil t))))
+ ;; The two-column footer carries both groups; check a token from
+ ;; each column independently (document order mixes them).
+ (let ((text (buffer-string)))
+ (should (string-match-p "This view" text))
+ (should (string-match-p "Saved locations" text))
+ (should (string-match-p "\\[a\\] another" text))
+ (should (string-match-p "\\[s\\] save" text))
+ (should (string-match-p "\\[x\\] remove" text)))))
(test-wttrin--display-weather-teardown)))
;;; Boundary Cases
@@ -159,11 +160,8 @@ Empty string does not match ERROR pattern, so it's processed as data."
(test-wttrin--display-weather-setup)
(unwind-protect
(progn
- (let ((message-log-max t)
- (message-displayed nil))
- (cl-letf (((symbol-function 'message)
- (lambda (format-string &rest args)
- (setq message-displayed (apply #'format format-string args)))))
+ (let ((message-log-max t))
+ (testutil-wttrin-with-captured-message message-displayed
(wttrin--display-weather "InvalidCity" nil)
;; Should display error message
@@ -176,11 +174,8 @@ Empty string does not match ERROR pattern, so it's processed as data."
(test-wttrin--display-weather-setup)
(unwind-protect
(progn
- (let ((message-log-max t)
- (message-displayed nil))
- (cl-letf (((symbol-function 'message)
- (lambda (format-string &rest args)
- (setq message-displayed (apply #'format format-string args)))))
+ (let ((message-log-max t))
+ (testutil-wttrin-with-captured-message message-displayed
(wttrin--display-weather "BadLocation" testutil-wttrin-sample-error-response)
;; Should display error message
diff --git a/tests/test-wttrin--format-location-line.el b/tests/test-wttrin--format-location-line.el
new file mode 100644
index 0000000..28a9af8
--- /dev/null
+++ b/tests/test-wttrin--format-location-line.el
@@ -0,0 +1,35 @@
+;;; test-wttrin--format-location-line.el --- Tests for the buffer Location line -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024-2026 Craig Jennings
+
+;;; Commentary:
+;; Unit tests for `wttrin--format-location-line', the "Location: ADDRESS" line
+;; shown in the weather buffer when a geolocation command supplies an address.
+
+;;; Code:
+
+(require 'ert)
+(require 'wttrin)
+
+(ert-deftest test-wttrin--format-location-line-normal-builds-line ()
+ "Normal: a non-empty address becomes a \"Location: ...\" line."
+ (let ((line (wttrin--format-location-line "Westerly, Rhode Island, USA")))
+ (should (stringp line))
+ (should (string-prefix-p "Location: Westerly, Rhode Island, USA" line))))
+
+(ert-deftest test-wttrin--format-location-line-normal-uses-face ()
+ "Normal: the line carries the staleness-header face."
+ (let ((line (wttrin--format-location-line "Westerly, RI")))
+ (should (eq 'wttrin-staleness-header
+ (get-text-property 0 'face line)))))
+
+(ert-deftest test-wttrin--format-location-line-boundary-nil-returns-nil ()
+ "Boundary: a nil address yields no line."
+ (should (null (wttrin--format-location-line nil))))
+
+(ert-deftest test-wttrin--format-location-line-boundary-empty-returns-nil ()
+ "Boundary: an empty address yields no line."
+ (should (null (wttrin--format-location-line ""))))
+
+(provide 'test-wttrin--format-location-line)
+;;; test-wttrin--format-location-line.el ends here
diff --git a/tests/test-wttrin--format-staleness-header.el b/tests/test-wttrin--format-staleness-header.el
index 5658be0..7a39b9a 100644
--- a/tests/test-wttrin--format-staleness-header.el
+++ b/tests/test-wttrin--format-staleness-header.el
@@ -50,6 +50,18 @@
(should (string-match-p "just now" header)))))
(test-wttrin--format-staleness-header-teardown)))
+(ert-deftest test-wttrin--format-staleness-header-normal-carries-face ()
+ "The returned header string is styled with `wttrin-staleness-header'."
+ (test-wttrin--format-staleness-header-setup)
+ (unwind-protect
+ (let ((now 1000000.0))
+ (cl-letf (((symbol-function 'float-time) (lambda () now)))
+ (testutil-wttrin-add-to-cache "Paris" "weather data" 300)
+ (let ((header (wttrin--format-staleness-header "Paris")))
+ (should (eq (get-text-property 0 'face header)
+ 'wttrin-staleness-header)))))
+ (test-wttrin--format-staleness-header-teardown)))
+
;;; Boundary Cases
(ert-deftest test-wttrin--format-staleness-header-boundary-no-cache-returns-nil ()
diff --git a/tests/test-wttrin--handle-fetch-callback.el b/tests/test-wttrin--handle-fetch-callback.el
index d4158ac..e50c61d 100644
--- a/tests/test-wttrin--handle-fetch-callback.el
+++ b/tests/test-wttrin--handle-fetch-callback.el
@@ -226,12 +226,9 @@
(ert-deftest test-wttrin--handle-fetch-callback-error-network-shows-message ()
"Network errors should show a specific message in the echo area,
not leave the user guessing."
- (let ((displayed-message nil))
+ (testutil-wttrin-with-captured-message displayed-message
(cl-letf (((symbol-function 'wttrin--extract-response-body)
- (lambda () nil))
- ((symbol-function 'message)
- (lambda (fmt &rest args)
- (setq displayed-message (apply #'format fmt args)))))
+ (lambda () nil)))
(wttrin--handle-fetch-callback
'(:error (error "Network unreachable"))
#'ignore)
@@ -240,14 +237,11 @@ not leave the user guessing."
(ert-deftest test-wttrin--handle-fetch-callback-error-http-404-shows-message ()
"HTTP 404 should tell the user the location wasn't found."
- (let ((displayed-message nil))
+ (testutil-wttrin-with-captured-message displayed-message
(cl-letf (((symbol-function 'wttrin--extract-response-body)
(lambda () nil))
((symbol-function 'wttrin--extract-http-status)
- (lambda () 404))
- ((symbol-function 'message)
- (lambda (fmt &rest args)
- (setq displayed-message (apply #'format fmt args)))))
+ (lambda () 404)))
;; No :error in status — url-retrieve succeeded but server returned 404
(wttrin--handle-fetch-callback nil #'ignore)
(should displayed-message)
@@ -255,14 +249,11 @@ not leave the user guessing."
(ert-deftest test-wttrin--handle-fetch-callback-error-http-500-shows-message ()
"HTTP 500 should tell the user the weather service had an error."
- (let ((displayed-message nil))
+ (testutil-wttrin-with-captured-message displayed-message
(cl-letf (((symbol-function 'wttrin--extract-response-body)
(lambda () nil))
((symbol-function 'wttrin--extract-http-status)
- (lambda () 500))
- ((symbol-function 'message)
- (lambda (fmt &rest args)
- (setq displayed-message (apply #'format fmt args)))))
+ (lambda () 500)))
(wttrin--handle-fetch-callback nil #'ignore)
(should displayed-message)
(should (string-match-p "service\\|server\\|500" (downcase displayed-message))))))
diff --git a/tests/test-wttrin--mode-line-helpers.el b/tests/test-wttrin--mode-line-helpers.el
index 408711b..21ac167 100644
--- a/tests/test-wttrin--mode-line-helpers.el
+++ b/tests/test-wttrin--mode-line-helpers.el
@@ -46,40 +46,40 @@
(should (equal (plist-get face :family) "Noto Color Emoji"))
(should (equal (plist-get face :height) 1.0))))))
-(ert-deftest test-wttrin--make-emoji-icon-normal-with-foreground ()
- "Foreground color should be applied when specified."
+(ert-deftest test-wttrin--make-emoji-icon-normal-with-face ()
+ "A face should be applied via :inherit when specified."
(let ((wttrin-mode-line-emoji-font nil))
- (let ((result (wttrin--make-emoji-icon "☀" "gray60")))
+ (let ((result (wttrin--make-emoji-icon "☀" 'wttrin-mode-line-stale)))
(let ((face (get-text-property 0 'face result)))
- (should (equal (plist-get face :foreground) "gray60"))))))
+ (should (eq (plist-get face :inherit) 'wttrin-mode-line-stale))))))
-(ert-deftest test-wttrin--make-emoji-icon-normal-with-font-and-foreground ()
- "Both font and foreground should be applied together."
+(ert-deftest test-wttrin--make-emoji-icon-normal-with-font-and-face ()
+ "Both font and face should be applied together."
(let ((wttrin-mode-line-emoji-font "Noto Color Emoji"))
- (let ((result (wttrin--make-emoji-icon "⏳" "gray60")))
+ (let ((result (wttrin--make-emoji-icon "⏳" 'wttrin-mode-line-stale)))
(let ((face (get-text-property 0 'face result)))
(should (equal (plist-get face :family) "Noto Color Emoji"))
- (should (equal (plist-get face :foreground) "gray60"))))))
+ (should (eq (plist-get face :inherit) 'wttrin-mode-line-stale))))))
;;; Boundary Cases
-(ert-deftest test-wttrin--make-emoji-icon-boundary-nil-foreground-no-color ()
- "Nil foreground should not add any :foreground property when no font."
+(ert-deftest test-wttrin--make-emoji-icon-boundary-nil-face-no-font ()
+ "Nil face with no font should return a plain string."
(let ((wttrin-mode-line-emoji-font nil))
(let ((result (wttrin--make-emoji-icon "☀" nil)))
- ;; Without font or foreground, should be plain string
+ ;; Without font or face, should be plain string
(should (equal result "☀")))))
-(ert-deftest test-wttrin--make-emoji-icon-boundary-nil-foreground-with-font ()
- "With font set and nil foreground, the face plist must omit :foreground entirely.
-A literal `:foreground nil' entry triggers \"Invalid face attribute\" warnings on
+(ert-deftest test-wttrin--make-emoji-icon-boundary-nil-face-with-font ()
+ "With font set and nil face, the face plist must omit :inherit entirely.
+A literal `:inherit nil' entry triggers \"Invalid face attribute\" warnings on
every redisplay. `plist-member' (not `plist-get') is required: `plist-get' can't
distinguish a missing key from a present key bound to nil."
(let ((wttrin-mode-line-emoji-font "Noto Color Emoji"))
(let* ((result (wttrin--make-emoji-icon "☀" nil))
(face (get-text-property 0 'face result)))
(should (equal (plist-get face :family) "Noto Color Emoji"))
- (should-not (plist-member face :foreground)))))
+ (should-not (plist-member face :inherit)))))
;;; --------------------------------------------------------------------------
;;; wttrin--set-mode-line-string
diff --git a/tests/test-wttrin--mode-line-update-display.el b/tests/test-wttrin--mode-line-update-display.el
index 0635b5f..be066e7 100644
--- a/tests/test-wttrin--mode-line-update-display.el
+++ b/tests/test-wttrin--mode-line-update-display.el
@@ -170,7 +170,7 @@
(test-wttrin--mode-line-update-display-teardown)))
(ert-deftest test-wttrin--mode-line-update-display-stale-emoji-dimmed ()
- "Stale data dims the emoji with gray foreground."
+ "Stale data dims the emoji via the `wttrin-mode-line-stale' face."
(test-wttrin--mode-line-update-display-setup)
(unwind-protect
(let ((wttrin-mode-line-refresh-interval 900)
@@ -178,13 +178,13 @@
(cl-letf (((symbol-function 'float-time) (lambda () 3000.0)))
(setq wttrin--mode-line-cache (cons 1000.0 "Paris: X +61°F Clear"))
(wttrin--mode-line-update-display)
- ;; The emoji character should have a gray face
+ ;; The emoji character should inherit the stale face
(let* ((str wttrin-mode-line-string)
;; Find the emoji position (after the space)
(emoji-pos 1)
(face (get-text-property emoji-pos 'face str)))
(should face)
- (should (equal (plist-get face :foreground) "gray60")))))
+ (should (eq (plist-get face :inherit) 'wttrin-mode-line-stale)))))
(test-wttrin--mode-line-update-display-teardown)))
;;; Boundary Cases
@@ -223,7 +223,7 @@ trigger an emoji re-render so dimming matches the tooltip's staleness state."
(wttrin--mode-line-update-display)
;; Emoji should NOT be dimmed
(let ((face (get-text-property 1 'face wttrin-mode-line-string)))
- (should-not (and face (equal (plist-get face :foreground) "gray60")))))
+ (should-not (and face (eq (plist-get face :inherit) 'wttrin-mode-line-stale)))))
;; Time passes: data is now stale (age=2001, threshold=1800)
;; Invoke the tooltip (simulating a hover) — this should trigger a re-render
@@ -233,7 +233,7 @@ trigger an emoji re-render so dimming matches the tooltip's staleness state."
;; After hover detected staleness transition, emoji should now be dimmed
(let ((face (get-text-property 1 'face wttrin-mode-line-string)))
(should face)
- (should (equal (plist-get face :foreground) "gray60")))))
+ (should (eq (plist-get face :inherit) 'wttrin-mode-line-stale)))))
(test-wttrin--mode-line-update-display-teardown)))
;;; --------------------------------------------------------------------------
@@ -252,6 +252,30 @@ trigger an emoji re-render so dimming matches the tooltip's staleness state."
(should wttrin-mode-line-string)))
(test-wttrin--mode-line-update-display-teardown)))
+(ert-deftest test-wttrin--mode-line-fetch-weather-normal-saved-name-shown-in-cache ()
+ "Normal: a saved-name favorite caches its display name, not the resolved query.
+The query (coordinates or an address) drives the fetch, but the hover tooltip
+should read the friendly name the user gave the place."
+ (test-wttrin--mode-line-update-display-setup)
+ (unwind-protect
+ (let ((wttrin-saved-locations '(("Mom's House" . "40.71,-74.01")))
+ (wttrin-favorite-location "Mom's House"))
+ (testutil-wttrin-mock-http-response "40.71,-74.01: ☀️ +70°F Cloudy"
+ (wttrin--mode-line-fetch-weather)
+ (should (string-prefix-p "Mom's House:" (cdr wttrin--mode-line-cache)))))
+ (test-wttrin--mode-line-update-display-teardown)))
+
+(ert-deftest test-wttrin--mode-line-fetch-weather-boundary-plain-location-unchanged ()
+ "Boundary: a plain (non-saved) favorite still caches its own name as the prefix."
+ (test-wttrin--mode-line-update-display-setup)
+ (unwind-protect
+ (let ((wttrin-saved-locations nil)
+ (wttrin-favorite-location "Paris"))
+ (testutil-wttrin-mock-http-response "Paris: ☀️ +61°F Clear"
+ (wttrin--mode-line-fetch-weather)
+ (should (string-prefix-p "Paris:" (cdr wttrin--mode-line-cache)))))
+ (test-wttrin--mode-line-update-display-teardown)))
+
(ert-deftest test-wttrin--mode-line-fetch-weather-error-empty-response-keeps-previous ()
"Empty API response does not overwrite previous valid cache."
(test-wttrin--mode-line-update-display-setup)
diff --git a/tests/test-wttrin-clear-cache.el b/tests/test-wttrin-clear-cache.el
index 8185c5f..784be4c 100644
--- a/tests/test-wttrin-clear-cache.el
+++ b/tests/test-wttrin-clear-cache.el
@@ -43,13 +43,10 @@
"User should be told the cache was cleared."
(test-wttrin-clear-cache-setup)
(unwind-protect
- (let ((displayed-message nil))
- (cl-letf (((symbol-function 'message)
- (lambda (fmt &rest args)
- (setq displayed-message (apply #'format fmt args)))))
- (wttrin-clear-cache)
- (should displayed-message)
- (should (string-match-p "cache cleared" displayed-message))))
+ (testutil-wttrin-with-captured-message displayed-message
+ (wttrin-clear-cache)
+ (should displayed-message)
+ (should (string-match-p "cache cleared" displayed-message)))
(test-wttrin-clear-cache-teardown)))
;;; Boundary Cases
diff --git a/tests/test-wttrin-error-types.el b/tests/test-wttrin-error-types.el
new file mode 100644
index 0000000..dd3df2d
--- /dev/null
+++ b/tests/test-wttrin-error-types.el
@@ -0,0 +1,128 @@
+;;; test-wttrin-error-types.el --- Tests for wttrin's typed error hierarchy -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024-2026 Craig Jennings
+
+;;; Commentary:
+;; Unit tests for the define-error hierarchy (wttrin-error and children),
+;; the wttrin--error-message constructor, the wttrin-error-type accessor,
+;; and the error-class tagging of the async fetch callback's error-msg.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'wttrin)
+(require 'testutil-wttrin)
+
+;;; Setup and Teardown
+
+(defun test-wttrin-error-types-setup ()
+ "Setup for error-type tests."
+ (testutil-wttrin-setup))
+
+(defun test-wttrin-error-types-teardown ()
+ "Teardown for error-type tests."
+ (testutil-wttrin-teardown))
+
+;;; Hierarchy
+
+(ert-deftest test-wttrin-error-types-normal-parent-inherits-error ()
+ "wttrin-error is a child of the built-in error condition."
+ (should (memq 'error (get 'wttrin-error 'error-conditions)))
+ (should (memq 'wttrin-error (get 'wttrin-error 'error-conditions))))
+
+(ert-deftest test-wttrin-error-types-normal-children-inherit-parent-and-error ()
+ "Each child condition inherits both wttrin-error and error."
+ (dolist (child '(wttrin-invalid-input
+ wttrin-network-error
+ wttrin-not-found-error
+ wttrin-service-error
+ wttrin-parse-error))
+ (let ((conds (get child 'error-conditions)))
+ (should (memq child conds))
+ (should (memq 'wttrin-error conds))
+ (should (memq 'error conds)))))
+
+;;; wttrin--error-message constructor
+
+(ert-deftest test-wttrin-error-types-normal-error-message-carries-type-property ()
+ "wttrin--error-message returns the formatted string tagged with its type."
+ (let ((msg (wttrin--error-message 'wttrin-network-error "Network error")))
+ (should (string= "Network error" msg))
+ (should (eq 'wttrin-network-error (get-text-property 0 'wttrin-error-type msg)))))
+
+(ert-deftest test-wttrin-error-types-normal-error-message-formats-arguments ()
+ "wttrin--error-message applies format arguments like `format'."
+ (let ((msg (wttrin--error-message 'wttrin-not-found-error
+ "Location not found (HTTP %d)" 404)))
+ (should (string= "Location not found (HTTP 404)" msg))
+ (should (eq 'wttrin-not-found-error (wttrin-error-message-type msg)))))
+
+;;; wttrin-error-type accessor
+
+(ert-deftest test-wttrin-error-types-normal-error-type-reads-tag ()
+ "wttrin-error-type returns the class symbol from a tagged message."
+ (let ((msg (wttrin--error-message 'wttrin-service-error "boom")))
+ (should (eq 'wttrin-service-error (wttrin-error-message-type msg)))))
+
+(ert-deftest test-wttrin-error-types-boundary-error-type-plain-string-is-nil ()
+ "An untagged string has no error type."
+ (should (null (wttrin-error-message-type "just a string"))))
+
+(ert-deftest test-wttrin-error-types-boundary-error-type-nil-is-nil ()
+ "nil has no error type."
+ (should (null (wttrin-error-message-type nil))))
+
+(ert-deftest test-wttrin-error-types-boundary-error-type-empty-string-is-nil ()
+ "An empty string has no error type."
+ (should (null (wttrin-error-message-type ""))))
+
+;;; Synchronous signal site
+
+(ert-deftest test-wttrin-error-types-error-build-url-nil-signals-invalid-input ()
+ "A nil query signals the specific wttrin-invalid-input condition."
+ (should-error (wttrin--build-url nil) :type 'wttrin-invalid-input)
+ (should-error (wttrin--build-url nil) :type 'wttrin-error))
+
+;;; Async classification — error-msg handed to the callback carries the class
+
+(defun test-wttrin-error-types--capture-error-msg (status &optional status-code)
+ "Run the fetch callback with STATUS, return the error-msg it receives.
+STATUS-CODE, when non-nil, is the HTTP status the buffer reports."
+ (let ((captured 'unset))
+ (cl-letf (((symbol-function 'wttrin--extract-response-body) (lambda () nil))
+ ((symbol-function 'wttrin--extract-http-status) (lambda () status-code))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin--handle-fetch-callback
+ status
+ (lambda (_data &optional error-msg) (setq captured error-msg))))
+ captured))
+
+(ert-deftest test-wttrin-error-types-error-network-failure-tagged-network ()
+ "A network-level failure tags the error-msg as wttrin-network-error."
+ (let ((msg (test-wttrin-error-types--capture-error-msg
+ '(:error (error "Network unreachable")))))
+ (should (eq 'wttrin-network-error (wttrin-error-message-type msg)))))
+
+(ert-deftest test-wttrin-error-types-error-http-404-tagged-not-found ()
+ "An HTTP 4xx tags the error-msg as wttrin-not-found-error."
+ (let ((msg (test-wttrin-error-types--capture-error-msg nil 404)))
+ (should (eq 'wttrin-not-found-error (wttrin-error-message-type msg)))))
+
+(ert-deftest test-wttrin-error-types-error-http-500-tagged-service ()
+ "An HTTP 5xx tags the error-msg as wttrin-service-error."
+ (let ((msg (test-wttrin-error-types--capture-error-msg nil 500)))
+ (should (eq 'wttrin-service-error (wttrin-error-message-type msg)))))
+
+(ert-deftest test-wttrin-error-types-error-2xx-empty-body-tagged-parse ()
+ "A 2xx response with no usable body tags the error-msg as wttrin-parse-error."
+ (let ((msg (test-wttrin-error-types--capture-error-msg nil 200)))
+ (should (eq 'wttrin-parse-error (wttrin-error-message-type msg)))))
+
+(ert-deftest test-wttrin-error-types-error-missing-status-tagged-parse ()
+ "An unreadable response (no status, no body) tags the error-msg as wttrin-parse-error."
+ (let ((msg (test-wttrin-error-types--capture-error-msg nil nil)))
+ (should (eq 'wttrin-parse-error (wttrin-error-message-type msg)))))
+
+(provide 'test-wttrin-error-types)
+;;; test-wttrin-error-types.el ends here
diff --git a/tests/test-wttrin-faces.el b/tests/test-wttrin-faces.el
new file mode 100644
index 0000000..db30002
--- /dev/null
+++ b/tests/test-wttrin-faces.el
@@ -0,0 +1,26 @@
+;;; test-wttrin-faces.el --- Tests for wttrin themeable faces -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;;; Commentary:
+
+;; Unit tests verifying the package's customizable faces are defined so
+;; themes and `customize-face' can target them.
+
+;;; Code:
+
+(require 'ert)
+(require 'wttrin)
+
+;;; Normal Cases
+
+(ert-deftest test-wttrin-faces-normal-all-defined ()
+ "Normal: every package face is defined after loading wttrin."
+ (dolist (face '(wttrin-mode-line-stale
+ wttrin-staleness-header
+ wttrin-instructions
+ wttrin-key))
+ (should (facep face))))
+
+(provide 'test-wttrin-faces)
+;;; test-wttrin-faces.el ends here
diff --git a/tests/test-wttrin-geolocation--internals.el b/tests/test-wttrin-geolocation--internals.el
index 6ad8384..dfc483d 100644
--- a/tests/test-wttrin-geolocation--internals.el
+++ b/tests/test-wttrin-geolocation--internals.el
@@ -128,9 +128,11 @@
;;; Error Cases
(ert-deftest test-wttrin-geolocation--lookup-provider-error-unknown-symbol ()
- "Unknown provider symbol signals error."
+ "Unknown provider symbol signals the typed wttrin-invalid-input condition."
(should-error (wttrin-geolocation--lookup-provider 'definitely-not-registered)
- :type 'error))
+ :type 'wttrin-invalid-input)
+ (should-error (wttrin-geolocation--lookup-provider 'definitely-not-registered)
+ :type 'wttrin-error))
;;; --------------------------------------------------------------------------
;;; wttrin-geolocation--extract-body
diff --git a/tests/test-wttrin-geolocation-command.el b/tests/test-wttrin-geolocation-command.el
new file mode 100644
index 0000000..40ea278
--- /dev/null
+++ b/tests/test-wttrin-geolocation-command.el
@@ -0,0 +1,161 @@
+;;; test-wttrin-geolocation-command.el --- Tests for the external-command geolocation provider -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024-2026 Craig Jennings
+
+;;; Commentary:
+;; Unit tests for the generic external-command geolocation provider:
+;; `wttrin-geolocation--parse-coordinates' (pure JSON -> "LAT,LNG"), the
+;; routing/fallback in `wttrin-geolocation-detect' when
+;; `wttrin-geolocation-command' is set, and an end-to-end run of
+;; `wttrin-geolocation--detect-via-command' against a real shell command.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'wttrin)
+(require 'wttrin-geolocation)
+
+;;; wttrin-geolocation--parse-coordinates
+
+(ert-deftest test-wttrin-geolocation-command-normal-parse-coordinates ()
+ "Normal: numeric lat/lng become a \"LAT,LNG\" string."
+ (should (equal "41.32,-71.81"
+ (wttrin-geolocation--parse-coordinates
+ "{\"lat\": 41.32, \"lng\": -71.81}"))))
+
+(ert-deftest test-wttrin-geolocation-command-boundary-parse-ignores-extra-keys ()
+ "Boundary: extra keys (accuracy, address) are ignored."
+ (should (equal "1.5,2.5"
+ (wttrin-geolocation--parse-coordinates
+ "{\"lat\":1.5,\"lng\":2.5,\"accuracy_m\":11.5,\"address\":\"x\"}"))))
+
+(ert-deftest test-wttrin-geolocation-command-error-parse-malformed-json ()
+ "Error: malformed JSON returns nil."
+ (should (null (wttrin-geolocation--parse-coordinates "not json")))
+ (should (null (wttrin-geolocation--parse-coordinates "")))
+ (should (null (wttrin-geolocation--parse-coordinates nil))))
+
+(ert-deftest test-wttrin-geolocation-command-error-parse-missing-or-nonnumeric ()
+ "Error: missing or non-numeric coordinates return nil."
+ (should (null (wttrin-geolocation--parse-coordinates "{\"lat\":1.0}")))
+ (should (null (wttrin-geolocation--parse-coordinates
+ "{\"lat\":\"x\",\"lng\":\"y\"}"))))
+
+;;; wttrin-geolocation--parse-address
+
+(ert-deftest test-wttrin-geolocation-command-normal-parse-address ()
+ "Normal: the address key is returned verbatim."
+ (should (equal "Westerly, Rhode Island, USA"
+ (wttrin-geolocation--parse-address
+ "{\"lat\":1.0,\"lng\":2.0,\"address\":\"Westerly, Rhode Island, USA\"}"))))
+
+(ert-deftest test-wttrin-geolocation-command-boundary-parse-address-label-synonym ()
+ "Boundary: a `label' key is accepted when `address' is absent."
+ (should (equal "Westerly, RI"
+ (wttrin-geolocation--parse-address
+ "{\"lat\":1.0,\"lng\":2.0,\"label\":\"Westerly, RI\"}"))))
+
+(ert-deftest test-wttrin-geolocation-command-error-parse-address-absent ()
+ "Error: no address or label, malformed JSON, or empty string returns nil."
+ (should (null (wttrin-geolocation--parse-address "{\"lat\":1.0,\"lng\":2.0}")))
+ (should (null (wttrin-geolocation--parse-address "not json")))
+ (should (null (wttrin-geolocation--parse-address
+ "{\"lat\":1.0,\"lng\":2.0,\"address\":\"\"}"))))
+
+;;; wttrin-geolocation-detect — routing and fallback
+
+(ert-deftest test-wttrin-geolocation-command-normal-detect-uses-command ()
+ "Normal: with a command set that succeeds, its coordinates are used."
+ (let ((got 'none)
+ (ip-called nil)
+ (wttrin-geolocation-command "ignored-in-mock"))
+ (cl-letf (((symbol-function 'wttrin-geolocation--detect-via-command)
+ (lambda (cb) (funcall cb "1.0,2.0")))
+ ((symbol-function 'wttrin-geolocation--detect-via-ip)
+ (lambda (cb) (setq ip-called t) (funcall cb "City, ST"))))
+ (wttrin-geolocation-detect (lambda (r &optional _a) (setq got r))))
+ (should (equal "1.0,2.0" got))
+ (should-not ip-called)))
+
+(ert-deftest test-wttrin-geolocation-command-boundary-detect-falls-back-to-ip ()
+ "Boundary: with a command that fails (nil), detection falls back to IP."
+ (let ((got 'none)
+ (wttrin-geolocation-command "ignored-in-mock"))
+ (cl-letf (((symbol-function 'wttrin-geolocation--detect-via-command)
+ (lambda (cb) (funcall cb nil)))
+ ((symbol-function 'wttrin-geolocation--detect-via-ip)
+ (lambda (cb) (funcall cb "City, ST"))))
+ (wttrin-geolocation-detect (lambda (r &optional _a) (setq got r))))
+ (should (equal "City, ST" got))))
+
+(ert-deftest test-wttrin-geolocation-command-boundary-detect-no-command-uses-ip ()
+ "Boundary: with no command set, the IP path runs directly."
+ (let ((got 'none)
+ (cmd-called nil)
+ (wttrin-geolocation-command nil))
+ (cl-letf (((symbol-function 'wttrin-geolocation--detect-via-command)
+ (lambda (cb) (setq cmd-called t) (funcall cb "9.0,9.0")))
+ ((symbol-function 'wttrin-geolocation--detect-via-ip)
+ (lambda (cb) (funcall cb "City, ST"))))
+ (wttrin-geolocation-detect (lambda (r &optional _a) (setq got r))))
+ (should (equal "City, ST" got))
+ (should-not cmd-called)))
+
+;;; wttrin-geolocation--detect-via-command — real process
+
+(defun test-wttrin-geolocation-command--run-sync (command)
+ "Run `wttrin-geolocation--detect-via-command' with COMMAND, wait, return coords.
+The address (second callback argument) is ignored here; a separate test covers it."
+ (let ((result 'pending)
+ (wttrin-geolocation-command command))
+ (wttrin-geolocation--detect-via-command
+ (lambda (r &optional _a) (setq result r)))
+ (with-timeout (5 (error "detect-via-command timed out"))
+ (while (eq result 'pending)
+ (accept-process-output nil 0.05)))
+ result))
+
+(ert-deftest test-wttrin-geolocation-command-integration-real-command-success ()
+ "Integration: a command printing lat/lng JSON resolves to \"LAT,LNG\".
+Components: wttrin-geolocation--detect-via-command (real make-process),
+the shell (real), wttrin-geolocation--parse-coordinates (real)."
+ (should (equal "1.5,2.5"
+ (test-wttrin-geolocation-command--run-sync
+ "echo '{\"lat\":1.5,\"lng\":2.5}'"))))
+
+(ert-deftest test-wttrin-geolocation-command-integration-real-command-passes-address ()
+ "Integration: a command printing lat/lng plus an address passes both to the callback.
+Components: detect-via-command (real make-process), the shell, the coord and
+address parsers (real)."
+ (let ((coords 'pending)
+ (address 'pending)
+ (wttrin-geolocation-command
+ "echo '{\"lat\":1.5,\"lng\":2.5,\"address\":\"Westerly, RI\"}'"))
+ (wttrin-geolocation--detect-via-command
+ (lambda (c &optional a) (setq coords c address a)))
+ (with-timeout (5 (error "detect-via-command timed out"))
+ (while (eq coords 'pending)
+ (accept-process-output nil 0.05)))
+ (should (equal "1.5,2.5" coords))
+ (should (equal "Westerly, RI" address))))
+
+(ert-deftest test-wttrin-geolocation-command-integration-real-command-nonzero-exit ()
+ "Integration: a command exiting non-zero yields nil (caller falls back to IP)."
+ (should (null (test-wttrin-geolocation-command--run-sync "exit 1"))))
+
+(ert-deftest test-wttrin-geolocation-command-integration-real-command-bad-output ()
+ "Integration: a zero-exit command printing non-JSON yields nil."
+ (should (null (test-wttrin-geolocation-command--run-sync "echo not-json"))))
+
+(ert-deftest test-wttrin-geolocation-command-error-spawn-failure-yields-nil ()
+ "Error: when the process cannot spawn, the callback receives nil."
+ (let ((result 'pending)
+ (wttrin-geolocation-command "whatever"))
+ (cl-letf (((symbol-function 'make-process)
+ (lambda (&rest _) (error "spawn failed"))))
+ (wttrin-geolocation--detect-via-command (lambda (r) (setq result r))))
+ (should (null result))))
+
+(provide 'test-wttrin-geolocation-command)
+;;; test-wttrin-geolocation-command.el ends here
diff --git a/tests/test-wttrin-geolocation-sentinel.el b/tests/test-wttrin-geolocation-sentinel.el
new file mode 100644
index 0000000..169761b
--- /dev/null
+++ b/tests/test-wttrin-geolocation-sentinel.el
@@ -0,0 +1,212 @@
+;;; test-wttrin-geolocation-sentinel.el --- Tests for the picker geolocation sentinel -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024-2026 Craig Jennings
+
+;;; Commentary:
+;; Unit tests for the "Current location (detect)" picker sentinel: its presence
+;; and position in `wttrin--completion-candidates', the selection routing in
+;; `wttrin--query-selection' (literal vs detect-then-query), and the guard that
+;; keeps the sentinel out of location history.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'wttrin)
+(require 'wttrin-geolocation)
+(require 'testutil-wttrin)
+
+;;; wttrin--completion-candidates — sentinel presence and position
+
+(ert-deftest test-wttrin-geolocation-sentinel-normal-first-candidate ()
+ "Normal: the sentinel is the first completion candidate."
+ (testutil-wttrin-setup)
+ (unwind-protect
+ (let ((wttrin-favorite-location nil)
+ (wttrin-default-locations '("Honolulu, HI"))
+ (wttrin--location-history '("Tokyo")))
+ (should (equal wttrin--geolocation-sentinel
+ (car (wttrin--completion-candidates)))))
+ (testutil-wttrin-teardown)))
+
+(ert-deftest test-wttrin-geolocation-sentinel-normal-first-even-with-favorite ()
+ "Normal: the sentinel precedes a string favorite in the candidate list."
+ (testutil-wttrin-setup)
+ (unwind-protect
+ (let ((wttrin-favorite-location "New Orleans, LA")
+ (wttrin-default-locations '("Honolulu, HI"))
+ (wttrin--location-history nil))
+ (should (equal (list wttrin--geolocation-sentinel
+ "New Orleans, LA" "Honolulu, HI")
+ (wttrin--completion-candidates))))
+ (testutil-wttrin-teardown)))
+
+;;; wttrin--sort-completions — pin the sentinel first
+
+(ert-deftest test-wttrin-geolocation-sentinel-normal-sort-pins-first ()
+ "Normal: the sentinel is moved to the front, the rest keep their order.
+Sorting completion UIs (vertico, icomplete) call the metadata
+display-sort-function, so this is what keeps the sentinel pinned."
+ (should (equal (list wttrin--geolocation-sentinel "Honolulu, HI" "Tokyo")
+ (wttrin--sort-completions
+ (list "Honolulu, HI" wttrin--geolocation-sentinel "Tokyo")))))
+
+(ert-deftest test-wttrin-geolocation-sentinel-boundary-sort-no-sentinel-unchanged ()
+ "Boundary: a list without the sentinel is returned in its original order."
+ (should (equal '("Honolulu, HI" "Tokyo")
+ (wttrin--sort-completions '("Honolulu, HI" "Tokyo")))))
+
+(ert-deftest test-wttrin-geolocation-sentinel-boundary-sort-empty ()
+ "Boundary: an empty candidate list sorts to empty."
+ (should (null (wttrin--sort-completions nil))))
+
+;;; wttrin--completion-table — metadata + completion
+
+(ert-deftest test-wttrin-geolocation-sentinel-normal-table-metadata-sort-fn ()
+ "Normal: the table advertises the pin-first display-sort-function."
+ (let* ((table (wttrin--completion-table
+ (list wttrin--geolocation-sentinel "Tokyo")))
+ (meta (funcall table "" nil 'metadata)))
+ (should (eq #'wttrin--sort-completions
+ (cdr (assq 'display-sort-function (cdr meta)))))))
+
+(ert-deftest test-wttrin-geolocation-sentinel-normal-table-completes-candidates ()
+ "Normal: the table completes over the candidates it was given."
+ (let ((table (wttrin--completion-table
+ (list wttrin--geolocation-sentinel "Tokyo" "Paris"))))
+ (should (equal (sort (list wttrin--geolocation-sentinel "Tokyo" "Paris")
+ #'string-lessp)
+ (sort (all-completions "" table) #'string-lessp)))))
+
+;;; wttrin interactive entry — delegates to routing
+
+(ert-deftest test-wttrin-geolocation-sentinel-normal-entry-delegates-to-query-selection ()
+ "Normal: the interactive `wttrin' command routes its picker selection
+through `wttrin--query-selection' (smoke test of the entry wrapper)."
+ (testutil-wttrin-setup)
+ (unwind-protect
+ (let ((routed nil))
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (&rest _) "London, GB"))
+ ((symbol-function 'wttrin--query-selection)
+ (lambda (loc) (setq routed loc))))
+ (call-interactively 'wttrin))
+ (should (equal "London, GB" routed)))
+ (testutil-wttrin-teardown)))
+
+;;; wttrin--query-selection — routing
+
+(ert-deftest test-wttrin-geolocation-sentinel-normal-typed-location-queries-literally ()
+ "Normal: a typed location is passed straight to `wttrin-query'."
+ (testutil-wttrin-setup)
+ (unwind-protect
+ (let ((captured nil))
+ (cl-letf (((symbol-function 'wttrin-query)
+ (lambda (loc &rest _) (setq captured loc))))
+ (wttrin--query-selection "Paris"))
+ (should (equal "Paris" captured)))
+ (testutil-wttrin-teardown)))
+
+(ert-deftest test-wttrin-geolocation-sentinel-normal-routes-to-detect-then-query ()
+ "Normal: selecting the sentinel detects, then queries the resolved city."
+ (testutil-wttrin-setup)
+ (unwind-protect
+ (let ((captured nil))
+ (cl-letf (((symbol-function 'wttrin-geolocation-detect)
+ (lambda (callback) (funcall callback "Austin, TX")))
+ ((symbol-function 'wttrin-query)
+ (lambda (loc &rest _) (setq captured loc)))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin--query-selection wttrin--geolocation-sentinel))
+ (should (equal "Austin, TX" captured)))
+ (testutil-wttrin-teardown)))
+
+(ert-deftest test-wttrin-geolocation-sentinel-error-detect-failure-no-query ()
+ "Error: a failed detection does not query and does not mutate the favorite."
+ (testutil-wttrin-setup)
+ (unwind-protect
+ (let ((queried nil)
+ (wttrin-favorite-location "New Orleans, LA"))
+ (cl-letf (((symbol-function 'wttrin-geolocation-detect)
+ (lambda (callback) (funcall callback nil)))
+ ((symbol-function 'wttrin-query)
+ (lambda (_loc) (setq queried t)))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin--query-selection wttrin--geolocation-sentinel))
+ (should-not queried)
+ (should (equal "New Orleans, LA" wttrin-favorite-location)))
+ (testutil-wttrin-teardown)))
+
+;;; sentinel never enters history
+
+(ert-deftest test-wttrin-geolocation-sentinel-boundary-never-added-to-history ()
+ "Boundary: the sentinel string is never recorded in location history."
+ (testutil-wttrin-setup)
+ (unwind-protect
+ (let ((wttrin-default-locations '())
+ (wttrin--location-history nil))
+ (wttrin--add-to-location-history wttrin--geolocation-sentinel)
+ (should (null wttrin--location-history)))
+ (testutil-wttrin-teardown)))
+
+;;; wttrin-geolocation-enabled — opt-out switch
+
+(ert-deftest test-wttrin-geolocation-sentinel-normal-disabled-hides-sentinel ()
+ "Normal: with geolocation disabled, the sentinel is not offered."
+ (testutil-wttrin-setup)
+ (unwind-protect
+ (let ((wttrin-geolocation-enabled nil)
+ (wttrin-favorite-location nil)
+ (wttrin-default-locations '("Honolulu, HI"))
+ (wttrin--location-history '("Tokyo")))
+ (should-not (member wttrin--geolocation-sentinel
+ (wttrin--completion-candidates)))
+ (should (equal '("Honolulu, HI" "Tokyo")
+ (wttrin--completion-candidates))))
+ (testutil-wttrin-teardown)))
+
+(ert-deftest test-wttrin-geolocation-sentinel-boundary-enabled-shows-sentinel ()
+ "Boundary: with geolocation enabled (default), the sentinel is offered first."
+ (testutil-wttrin-setup)
+ (unwind-protect
+ (let ((wttrin-geolocation-enabled t)
+ (wttrin-favorite-location nil)
+ (wttrin-default-locations '("Honolulu, HI"))
+ (wttrin--location-history nil))
+ (should (equal wttrin--geolocation-sentinel
+ (car (wttrin--completion-candidates)))))
+ (testutil-wttrin-teardown)))
+
+(ert-deftest test-wttrin-geolocation-sentinel-error-disabled-detect-then-query-no-detect ()
+ "Error: with geolocation disabled, detect-then-query does not detect or query."
+ (testutil-wttrin-setup)
+ (unwind-protect
+ (let ((detected nil)
+ (queried nil)
+ (wttrin-geolocation-enabled nil))
+ (cl-letf (((symbol-function 'wttrin-geolocation-detect)
+ (lambda (_cb) (setq detected t)))
+ ((symbol-function 'wttrin-query)
+ (lambda (_loc) (setq queried t)))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin--detect-then-query))
+ (should-not detected)
+ (should-not queried))
+ (testutil-wttrin-teardown)))
+
+(ert-deftest test-wttrin-geolocation-sentinel-boundary-disabled-favorite-no-autodetect ()
+ "Boundary: with geolocation disabled, the t-favorite auto-detect does not fire."
+ (testutil-wttrin-setup)
+ (unwind-protect
+ (let ((detected nil)
+ (wttrin-geolocation-enabled nil)
+ (wttrin--favorite-location-pending nil))
+ (cl-letf (((symbol-function 'wttrin-geolocation-detect)
+ (lambda (_cb) (setq detected t))))
+ (wttrin--start-favorite-location-detect))
+ (should-not detected)
+ (should-not wttrin--favorite-location-pending))
+ (testutil-wttrin-teardown)))
+
+(provide 'test-wttrin-geolocation-sentinel)
+;;; test-wttrin-geolocation-sentinel.el ends here
diff --git a/tests/test-wttrin-location-history.el b/tests/test-wttrin-location-history.el
new file mode 100644
index 0000000..4af8235
--- /dev/null
+++ b/tests/test-wttrin-location-history.el
@@ -0,0 +1,242 @@
+;;; test-wttrin-location-history.el --- Tests for location search history -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024-2026 Craig Jennings
+
+;;; Commentary:
+;; Unit tests for the location search history feature: wttrin--add-to-location-history,
+;; wttrin--completion-candidates, wttrin-remove-location-history,
+;; wttrin-clear-location-history, and savehist integration.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'wttrin)
+(require 'testutil-wttrin)
+
+;;; Setup and Teardown
+
+(defun test-wttrin-location-history-setup ()
+ "Setup: isolate history and defaults from the user's real config."
+ (testutil-wttrin-setup)
+ (setq wttrin--location-history nil))
+
+(defun test-wttrin-location-history-teardown ()
+ "Teardown: clear history."
+ (setq wttrin--location-history nil)
+ (testutil-wttrin-teardown))
+
+;;; wttrin--add-to-location-history
+
+(ert-deftest test-wttrin-location-history-normal-adds-new-location ()
+ "A new location is pushed onto the front of history."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin-default-locations '())
+ (wttrin--location-history nil))
+ (wttrin--add-to-location-history "Tokyo")
+ (should (equal '("Tokyo") wttrin--location-history)))
+ (test-wttrin-location-history-teardown)))
+
+(ert-deftest test-wttrin-location-history-normal-promotes-existing-to-front ()
+ "Re-adding an existing location moves it to the front without duplicating."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin-default-locations '())
+ (wttrin--location-history '("Paris" "Tokyo" "Berlin")))
+ (wttrin--add-to-location-history "Tokyo")
+ (should (equal '("Tokyo" "Paris" "Berlin") wttrin--location-history)))
+ (test-wttrin-location-history-teardown)))
+
+(ert-deftest test-wttrin-location-history-normal-skips-default-location ()
+ "A location already in defaults is not added to history."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin-default-locations '("Honolulu, HI"))
+ (wttrin--location-history nil))
+ (wttrin--add-to-location-history "Honolulu, HI")
+ (should (null wttrin--location-history)))
+ (test-wttrin-location-history-teardown)))
+
+(ert-deftest test-wttrin-location-history-boundary-trims-to-max ()
+ "History is trimmed to `wttrin-location-history-max', keeping the most recent."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin-default-locations '())
+ (wttrin-location-history-max 3)
+ (wttrin--location-history '("c" "b" "a")))
+ (wttrin--add-to-location-history "d")
+ (should (equal '("d" "c" "b") wttrin--location-history)))
+ (test-wttrin-location-history-teardown)))
+
+(ert-deftest test-wttrin-location-history-boundary-empty-history ()
+ "Adding to empty history yields a single-entry list."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin-default-locations '())
+ (wttrin--location-history nil))
+ (wttrin--add-to-location-history "Reykjavik")
+ (should (equal '("Reykjavik") wttrin--location-history)))
+ (test-wttrin-location-history-teardown)))
+
+(ert-deftest test-wttrin-location-history-boundary-max-zero-keeps-none ()
+ "A max of 0 results in empty history after a trim."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin-default-locations '())
+ (wttrin-location-history-max 0)
+ (wttrin--location-history nil))
+ (wttrin--add-to-location-history "Nowhere")
+ (should (null wttrin--location-history)))
+ (test-wttrin-location-history-teardown)))
+
+(ert-deftest test-wttrin-location-history-error-nil-location-no-op ()
+ "A nil location is a no-op."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin-default-locations '())
+ (wttrin--location-history '("Paris")))
+ (wttrin--add-to-location-history nil)
+ (should (equal '("Paris") wttrin--location-history)))
+ (test-wttrin-location-history-teardown)))
+
+(ert-deftest test-wttrin-location-history-error-empty-string-no-op ()
+ "An empty string is a no-op."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin-default-locations '())
+ (wttrin--location-history '("Paris")))
+ (wttrin--add-to-location-history "")
+ (should (equal '("Paris") wttrin--location-history)))
+ (test-wttrin-location-history-teardown)))
+
+;;; wttrin--completion-candidates
+
+(ert-deftest test-wttrin-location-history-normal-candidates-defaults-then-history ()
+ "Candidates list the sentinel, then defaults, then history."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin-favorite-location nil)
+ (wttrin-default-locations '("Honolulu, HI" "Berkeley, CA"))
+ (wttrin--location-history '("Tokyo" "Paris")))
+ (should (equal (list wttrin--geolocation-sentinel
+ "Honolulu, HI" "Berkeley, CA" "Tokyo" "Paris")
+ (wttrin--completion-candidates))))
+ (test-wttrin-location-history-teardown)))
+
+(ert-deftest test-wttrin-location-history-normal-candidates-only-defaults ()
+ "With empty history, candidates are the sentinel then the defaults."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin-favorite-location nil)
+ (wttrin-default-locations '("Honolulu, HI"))
+ (wttrin--location-history nil))
+ (should (equal (list wttrin--geolocation-sentinel "Honolulu, HI")
+ (wttrin--completion-candidates))))
+ (test-wttrin-location-history-teardown)))
+
+(ert-deftest test-wttrin-location-history-normal-candidates-only-history ()
+ "With empty defaults, candidates are the sentinel then the history."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin-favorite-location nil)
+ (wttrin-default-locations '())
+ (wttrin--location-history '("Tokyo")))
+ (should (equal (list wttrin--geolocation-sentinel "Tokyo")
+ (wttrin--completion-candidates))))
+ (test-wttrin-location-history-teardown)))
+
+;;; wttrin-remove-location-history
+
+(ert-deftest test-wttrin-location-history-normal-remove-entry ()
+ "Removing an entry drops it from history."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin--location-history '("Tokyo" "Paris" "Berlin")))
+ (cl-letf (((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-remove-location-history "Paris"))
+ (should (equal '("Tokyo" "Berlin") wttrin--location-history)))
+ (test-wttrin-location-history-teardown)))
+
+(ert-deftest test-wttrin-location-history-normal-remove-absent-no-op ()
+ "Removing an entry not present leaves history unchanged."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin--location-history '("Tokyo")))
+ (cl-letf (((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-remove-location-history "Mars"))
+ (should (equal '("Tokyo") wttrin--location-history)))
+ (test-wttrin-location-history-teardown)))
+
+(ert-deftest test-wttrin-location-history-boundary-remove-last-leaves-empty ()
+ "Removing the only entry leaves an empty list."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin--location-history '("Tokyo")))
+ (cl-letf (((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-remove-location-history "Tokyo"))
+ (should (null wttrin--location-history)))
+ (test-wttrin-location-history-teardown)))
+
+;;; wttrin-clear-location-history
+
+(ert-deftest test-wttrin-location-history-normal-clear-confirmed ()
+ "Confirming the prompt clears all history."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin--location-history '("Tokyo" "Paris")))
+ (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-clear-location-history))
+ (should (null wttrin--location-history)))
+ (test-wttrin-location-history-teardown)))
+
+(ert-deftest test-wttrin-location-history-normal-clear-declined-keeps-history ()
+ "Declining the prompt leaves history intact."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin--location-history '("Tokyo" "Paris")))
+ (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) nil))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-clear-location-history))
+ (should (equal '("Tokyo" "Paris") wttrin--location-history)))
+ (test-wttrin-location-history-teardown)))
+
+;;; savehist integration
+
+(ert-deftest test-wttrin-location-history-integration-savehist-registers-variable ()
+ "Loading savehist registers wttrin--location-history for persistence."
+ (require 'savehist)
+ (should (memq 'wttrin--location-history savehist-additional-variables)))
+
+(ert-deftest test-wttrin-location-history-normal-savehist-register-adds-var ()
+ "wttrin--savehist-register adds the history variable to the save list."
+ (require 'savehist)
+ (let ((savehist-additional-variables '(kill-ring)))
+ (wttrin--savehist-register)
+ (should (memq 'wttrin--location-history savehist-additional-variables))))
+
+(ert-deftest test-wttrin-location-history-boundary-savehist-register-idempotent ()
+ "Registering an already-present variable does not duplicate it."
+ (require 'savehist)
+ (require 'cl-lib)
+ (let ((savehist-additional-variables '(wttrin--location-history wttrin-favorite-location)))
+ (wttrin--savehist-register)
+ (should (= 1 (cl-count 'wttrin--location-history savehist-additional-variables)))
+ (should (= 1 (cl-count 'wttrin-favorite-location savehist-additional-variables)))))
+
+(ert-deftest test-wttrin-location-history-integration-savehist-register-on-save-hook ()
+ "The registration runs on `savehist-save-hook' so it survives a clobber."
+ (require 'savehist)
+ (should (memq 'wttrin--savehist-register savehist-save-hook)))
+
+(ert-deftest test-wttrin-location-history-integration-savehist-survives-clobber ()
+ "A user setq that drops the variable is repaired before the next save."
+ (require 'savehist)
+ (let ((savehist-additional-variables '(kill-ring search-ring)))
+ ;; simulate the save path: savehist-save runs this hook first
+ (run-hooks 'savehist-save-hook)
+ (should (memq 'wttrin--location-history savehist-additional-variables))))
+
+(provide 'test-wttrin-location-history)
+;;; test-wttrin-location-history.el ends here
diff --git a/tests/test-wttrin-make-default.el b/tests/test-wttrin-make-default.el
new file mode 100644
index 0000000..e715e12
--- /dev/null
+++ b/tests/test-wttrin-make-default.el
@@ -0,0 +1,176 @@
+;;; test-wttrin-make-default.el --- Tests for promote-to-default command -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;;; Commentary:
+
+;; 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.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'wttrin)
+
+;;; --------------------------------------------------------------------------
+;;; wttrin--set-favorite-location
+;;; --------------------------------------------------------------------------
+
+;;; Normal Cases
+
+(ert-deftest test-wttrin--set-favorite-location-normal-sets-variable ()
+ "Normal: sets `wttrin-favorite-location' to the given location."
+ (let ((wttrin-favorite-location nil)
+ (savehist-additional-variables nil))
+ (wttrin--set-favorite-location "Paris, FR")
+ (should (equal wttrin-favorite-location "Paris, FR"))))
+
+(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))
+ ;; 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")))))
+
+(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--location-history '("Reykjavik" "Oslo, NO")))
+ (wttrin--set-favorite-location "Reykjavik")
+ (should-not (member "Reykjavik" wttrin--location-history))
+ (should (equal wttrin--location-history '("Oslo, NO")))))
+
+(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--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."
+ (require 'savehist)
+ (let ((savehist-additional-variables '(kill-ring)))
+ (wttrin--savehist-register)
+ (should (memq 'wttrin-favorite-location savehist-additional-variables))))
+
+;;; --------------------------------------------------------------------------
+;;; mode-line refresh when the favorite changes
+;;; --------------------------------------------------------------------------
+
+;;; Normal Cases
+
+(ert-deftest test-wttrin--set-favorite-location-normal-mode-line-on-refreshes ()
+ "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-mode-line-mode t)
+ (wttrin--mode-line-cache (cons 0.0 "Oslo, NO: sun"))
+ (fetched nil))
+ (cl-letf (((symbol-function 'wttrin--mode-line-fetch-weather)
+ (lambda () (setq fetched t)))
+ ((symbol-function 'wttrin--mode-line-set-placeholder)
+ (lambda () nil)))
+ (wttrin--set-favorite-location "Paris, FR")
+ (should (null wttrin--mode-line-cache))
+ (should fetched))))
+
+;;; Boundary Cases
+
+(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-mode-line-mode nil)
+ (fetched nil))
+ (cl-letf (((symbol-function 'wttrin--mode-line-fetch-weather)
+ (lambda () (setq fetched t))))
+ (wttrin--set-favorite-location "Paris, FR")
+ (should-not fetched))))
+
+(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")
+ (wttrin-mode-line-mode t)
+ (fetched nil))
+ (cl-letf (((symbol-function 'wttrin--mode-line-fetch-weather)
+ (lambda () (setq fetched t)))
+ ((symbol-function 'wttrin--mode-line-set-placeholder)
+ (lambda () nil)))
+ (wttrin--set-favorite-location "Paris, FR")
+ (should-not fetched))))
+
+;;; --------------------------------------------------------------------------
+;;; wttrin-make-default
+;;; --------------------------------------------------------------------------
+
+;;; Normal Cases
+
+(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)
+ (savehist-additional-variables nil))
+ (with-temp-buffer
+ (setq-local wttrin--current-location "Tokyo, JP")
+ (wttrin-make-default)
+ (should (equal wttrin-favorite-location "Tokyo, JP")))))
+
+;;; 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")
+ (savehist-additional-variables nil))
+ (with-temp-buffer
+ (setq-local wttrin--current-location nil)
+ (wttrin-make-default)
+ (should (equal wttrin-favorite-location "Berkeley, CA")))))
+
+;;; --------------------------------------------------------------------------
+;;; favorite in completion candidates
+;;; --------------------------------------------------------------------------
+
+;;; Normal Cases
+
+(ert-deftest test-wttrin-make-default-normal-favorite-prepended-to-candidates ()
+ "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"))
+ (should (equal (wttrin--completion-candidates)
+ (list wttrin--geolocation-sentinel
+ "Reykjavik" "Honolulu, HI" "Berkeley, CA")))))
+
+;;; Boundary Cases
+
+(ert-deftest test-wttrin-make-default-boundary-favorite-default-not-duplicated ()
+ "Boundary: a favorite that is already a default appears exactly once."
+ (require 'cl-lib)
+ (let ((wttrin-default-locations '("Honolulu, HI" "Berkeley, CA"))
+ (wttrin--location-history nil)
+ (wttrin-favorite-location "Berkeley, CA"))
+ (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))
+ (should (equal (wttrin--completion-candidates)
+ (list wttrin--geolocation-sentinel "Honolulu, HI" "Oslo, NO")))))
+
+;;; --------------------------------------------------------------------------
+;;; keymap binding
+;;; --------------------------------------------------------------------------
+
+(ert-deftest test-wttrin-make-default-normal-d-bound-in-mode-map ()
+ "Normal: the weather-buffer keymap binds \"d\" to the command."
+ (should (eq (lookup-key wttrin-mode-map (kbd "d")) 'wttrin-make-default)))
+
+(provide 'test-wttrin-make-default)
+;;; test-wttrin-make-default.el ends here
diff --git a/tests/test-wttrin-query.el b/tests/test-wttrin-query.el
index 396507b..4c4de87 100644
--- a/tests/test-wttrin-query.el
+++ b/tests/test-wttrin-query.el
@@ -102,20 +102,17 @@
"When fetch returns nil, the user should see an error message, not a crash."
(test-wttrin-query-setup)
(unwind-protect
- (let ((saved-callback nil)
- (displayed-message nil))
- (cl-letf (((symbol-function 'wttrin--get-cached-or-fetch)
- (lambda (_location callback)
- (setq saved-callback callback)))
- ((symbol-function 'message)
- (lambda (fmt &rest args)
- (setq displayed-message (apply #'format fmt args)))))
- (wttrin-query "BadLocation")
- ;; Simulate fetch returning nil
- (funcall saved-callback nil)
- ;; Should have shown error message (from wttrin--display-weather validation)
- (should displayed-message)
- (should (string-match-p "Cannot retrieve" displayed-message))))
+ (let ((saved-callback nil))
+ (testutil-wttrin-with-captured-message displayed-message
+ (cl-letf (((symbol-function 'wttrin--get-cached-or-fetch)
+ (lambda (_location callback)
+ (setq saved-callback callback))))
+ (wttrin-query "BadLocation")
+ ;; Simulate fetch returning nil
+ (funcall saved-callback nil)
+ ;; Should have shown error message (from wttrin--display-weather validation)
+ (should displayed-message)
+ (should (string-match-p "Cannot retrieve" displayed-message)))))
(test-wttrin-query-teardown)))
(provide 'test-wttrin-query)
diff --git a/tests/test-wttrin-requery-force.el b/tests/test-wttrin-requery-force.el
index d572348..30af7dd 100644
--- a/tests/test-wttrin-requery-force.el
+++ b/tests/test-wttrin-requery-force.el
@@ -35,7 +35,7 @@
(unwind-protect
(let ((queried-location nil))
(cl-letf (((symbol-function 'wttrin-query)
- (lambda (location) (setq queried-location location))))
+ (lambda (location &rest _) (setq queried-location location))))
;; Set up a weather buffer with a known location
(with-current-buffer (get-buffer-create "*wttr.in*")
(setq-local wttrin--current-location "Berlin, DE")
@@ -49,7 +49,7 @@
(unwind-protect
(let ((force-refresh-was-set nil))
(cl-letf (((symbol-function 'wttrin-query)
- (lambda (_location)
+ (lambda (_location &rest _)
(setq force-refresh-was-set wttrin--force-refresh))))
(with-current-buffer (get-buffer-create "*wttr.in*")
(setq-local wttrin--current-location "Paris")
@@ -63,15 +63,12 @@
"When no current location is set, user should be told there's nothing to refresh."
(test-wttrin-requery-force-setup)
(unwind-protect
- (let ((displayed-message nil))
- (cl-letf (((symbol-function 'message)
- (lambda (fmt &rest args)
- (setq displayed-message (apply #'format fmt args)))))
- (with-current-buffer (get-buffer-create "*wttr.in*")
- ;; wttrin--current-location is nil (buffer-local default)
- (wttrin-requery-force)
- (should displayed-message)
- (should (string-match-p "No location" displayed-message)))))
+ (testutil-wttrin-with-captured-message displayed-message
+ (with-current-buffer (get-buffer-create "*wttr.in*")
+ ;; wttrin--current-location is nil (buffer-local default)
+ (wttrin-requery-force)
+ (should displayed-message)
+ (should (string-match-p "No location" displayed-message))))
(test-wttrin-requery-force-teardown)))
(ert-deftest test-wttrin-requery-force-boundary-force-flag-does-not-leak ()
@@ -79,7 +76,7 @@
(test-wttrin-requery-force-setup)
(unwind-protect
(progn
- (cl-letf (((symbol-function 'wttrin-query) (lambda (_location) nil)))
+ (cl-letf (((symbol-function 'wttrin-query) (lambda (_location &rest _) nil)))
(with-current-buffer (get-buffer-create "*wttr.in*")
(setq-local wttrin--current-location "Paris")
(wttrin-requery-force)))
diff --git a/tests/test-wttrin-requery.el b/tests/test-wttrin-requery.el
index d6a8beb..e065c43 100644
--- a/tests/test-wttrin-requery.el
+++ b/tests/test-wttrin-requery.el
@@ -39,7 +39,7 @@
(test-wttrin-requery-setup)
(unwind-protect
(let ((old-buffer (get-buffer-create "*wttr.in*")))
- (cl-letf (((symbol-function 'wttrin-query) (lambda (_loc) nil)))
+ (cl-letf (((symbol-function 'wttrin-query) (lambda (_loc &rest _) nil)))
(wttrin--requery-location "Tokyo")
;; Old buffer should be dead
(should-not (buffer-live-p old-buffer))))
@@ -51,7 +51,7 @@
(unwind-protect
(let ((queried-location nil))
(cl-letf (((symbol-function 'wttrin-query)
- (lambda (loc) (setq queried-location loc))))
+ (lambda (loc &rest _) (setq queried-location loc))))
(wttrin--requery-location "Berlin, DE")
(should (equal queried-location "Berlin, DE"))))
(test-wttrin-requery-teardown)))
@@ -66,7 +66,7 @@
;; Ensure no buffer exists
(should-not (get-buffer "*wttr.in*"))
(cl-letf (((symbol-function 'wttrin-query)
- (lambda (loc) (setq queried-location loc))))
+ (lambda (loc &rest _) (setq queried-location loc))))
(wttrin--requery-location "Paris")
(should (equal queried-location "Paris"))))
(test-wttrin-requery-teardown)))
@@ -77,7 +77,7 @@
(unwind-protect
(let ((queried-location nil))
(cl-letf (((symbol-function 'wttrin-query)
- (lambda (loc) (setq queried-location loc))))
+ (lambda (loc &rest _) (setq queried-location loc))))
(wttrin--requery-location "Zürich, CH")
(should (equal queried-location "Zürich, CH"))))
(test-wttrin-requery-teardown)))
@@ -105,15 +105,22 @@ to the core requery function."
(test-wttrin-requery-setup)
(unwind-protect
(let ((offered-collection nil)
+ (wttrin-favorite-location nil)
+ (wttrin--location-history nil)
(wttrin-default-locations '("Paris" "London" "Tokyo")))
(cl-letf (((symbol-function 'completing-read)
(lambda (_prompt collection &rest _args)
(setq offered-collection collection)
"Paris"))
((symbol-function 'wttrin--requery-location)
- (lambda (_loc) nil)))
+ (lambda (_loc &rest _) nil)))
(wttrin-requery)
- (should (equal offered-collection '("Paris" "London" "Tokyo")))))
+ ;; The collection is now a completion table (a function) that pins
+ ;; the sentinel first; check the candidates it completes over.
+ (should (equal (list wttrin--geolocation-sentinel
+ "Paris" "London" "Tokyo")
+ (wttrin--sort-completions
+ (all-completions "" offered-collection))))))
(test-wttrin-requery-teardown)))
(ert-deftest test-wttrin-requery-boundary-single-default-prefills ()
@@ -127,7 +134,7 @@ to the core requery function."
(setq initial-input init)
"Solo City"))
((symbol-function 'wttrin--requery-location)
- (lambda (_loc) nil)))
+ (lambda (_loc &rest _) nil)))
(wttrin-requery)
(should (equal initial-input "Solo City"))))
(test-wttrin-requery-teardown)))
@@ -143,7 +150,7 @@ to the core requery function."
(setq initial-input init)
"Paris"))
((symbol-function 'wttrin--requery-location)
- (lambda (_loc) nil)))
+ (lambda (_loc &rest _) nil)))
(wttrin-requery)
(should-not initial-input)))
(test-wttrin-requery-teardown)))
diff --git a/tests/test-wttrin-saved-locations.el b/tests/test-wttrin-saved-locations.el
new file mode 100644
index 0000000..de97df0
--- /dev/null
+++ b/tests/test-wttrin-saved-locations.el
@@ -0,0 +1,418 @@
+;;; test-wttrin-saved-locations.el --- Tests for the named-locations directory -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024-2026 Craig Jennings
+
+;;; Commentary:
+;; Unit tests for the named-locations directory (Phase 1): the normalizer
+;; `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.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'wttrin)
+(require 'testutil-wttrin)
+
+;;; wttrin--saved-locations (normalizer)
+
+(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"))))
+ (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")))
+ (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 "))))
+ (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))))
+ (should (equal '(("Good" . "Tokyo")) (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"))))
+ (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"))))
+ (should (equal "Tokyo, JP" (wttrin--resolve-location-query "Tokyo, JP")))))
+
+;;; Candidate de-duplication and precedence
+
+(ert-deftest test-wttrin-saved-locations-normal-candidates-precedence ()
+ "Normal: candidates are saved, favorite, defaults, then history, deduped."
+ (testutil-wttrin-setup)
+ (unwind-protect
+ (let ((wttrin-geolocation-enabled nil)
+ (wttrin-saved-locations '(("Home" . "Paris, FR")))
+ (wttrin-favorite-location "Reykjavik")
+ (wttrin-default-locations '("Honolulu, HI"))
+ (wttrin--location-history '("Tokyo")))
+ (should (equal '("Home" "Reykjavik" "Honolulu, HI" "Tokyo")
+ (wttrin--completion-candidates))))
+ (testutil-wttrin-teardown)))
+
+(ert-deftest test-wttrin-saved-locations-boundary-candidates-dedup-saved-wins ()
+ "Boundary: a name present in saved and defaults appears once (saved first)."
+ (testutil-wttrin-setup)
+ (unwind-protect
+ (let ((wttrin-geolocation-enabled nil)
+ (wttrin-saved-locations '(("Honolulu, HI" . "Honolulu, HI")))
+ (wttrin-favorite-location nil)
+ (wttrin-default-locations '("Honolulu, HI" "Berkeley, CA"))
+ (wttrin--location-history nil))
+ (should (equal '("Honolulu, HI" "Berkeley, CA")
+ (wttrin--completion-candidates))))
+ (testutil-wttrin-teardown)))
+
+;;; Favorite-as-name resolution
+
+(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"))
+ (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"))
+ (should (equal "Home" (wttrin--favorite-location-display-name)))))
+
+;;; Alias cache identity
+
+(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"))))
+ (should (equal (wttrin--make-cache-key (wttrin--resolve-location-query "A"))
+ (wttrin--make-cache-key (wttrin--resolve-location-query "B"))))))
+
+;;; History suppression of saved names
+
+(ert-deftest test-wttrin-saved-locations-boundary-saved-name-not-logged ()
+ "Boundary: a saved-directory name is not added to history."
+ (testutil-wttrin-setup)
+ (unwind-protect
+ (let ((wttrin-saved-locations '(("Home" . "Paris, FR")))
+ (wttrin-default-locations '())
+ (wttrin--location-history nil))
+ (wttrin--add-to-location-history "Home")
+ (should (null wttrin--location-history)))
+ (testutil-wttrin-teardown)))
+
+;;; savehist
+
+(ert-deftest test-wttrin-saved-locations-integration-savehist-registers ()
+ "Integration: wttrin-saved-locations is registered for savehist persistence."
+ (require 'savehist)
+ (let ((savehist-additional-variables '(kill-ring)))
+ (wttrin--savehist-register)
+ (should (memq 'wttrin-saved-locations savehist-additional-variables))))
+
+;;; wttrin--coordinates-p
+
+(ert-deftest test-wttrin-saved-locations-normal-coordinates-p ()
+ "Normal/Boundary: coordinate strings match; place names do not."
+ (should (wttrin--coordinates-p "41.37,-71.83"))
+ (should (wttrin--coordinates-p "1.5,2.5"))
+ (should-not (wttrin--coordinates-p "New York, NY"))
+ (should-not (wttrin--coordinates-p "Berkeley, CA"))
+ (should-not (wttrin--coordinates-p "")))
+
+;;; wttrin--put-saved-location
+
+(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))
+ (wttrin--put-saved-location "Home" "Paris, FR")
+ (should (equal "Paris, FR" (wttrin--resolve-location-query "Home")))
+ (wttrin--put-saved-location "Home" "Tokyo, JP")
+ (should (equal "Tokyo, JP" (wttrin--resolve-location-query "Home")))
+ (should (= 1 (length (wttrin--saved-locations))))))
+
+(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))
+ (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")
+ :type 'user-error)))
+
+;;; wttrin-rename-location
+
+(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"))
+ (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))))
+
+(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"))))
+ (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")
+ (wttrin--location-history nil)
+ (wttrin-mode-line-mode t)
+ (fetched nil))
+ (cl-letf (((symbol-function 'wttrin--mode-line-fetch-weather)
+ (lambda () (setq fetched t)))
+ ((symbol-function 'wttrin--mode-line-set-placeholder)
+ (lambda () nil))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-rename-location "Home" "Casa"))
+ (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")
+ (wttrin--location-history nil)
+ (wttrin-mode-line-mode t)
+ (fetched nil))
+ (cl-letf (((symbol-function 'wttrin--mode-line-fetch-weather)
+ (lambda () (setq fetched t)))
+ ((symbol-function 'wttrin--mode-line-set-placeholder)
+ (lambda () nil))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-rename-location "Home" "Casa"))
+ (should-not fetched)))
+
+;;; wttrin-remove-location
+
+(ert-deftest test-wttrin-saved-locations-normal-remove-confirmed ()
+ "Normal: confirming removes the entry."
+ (let ((wttrin-saved-locations '(("Home" . "Paris, FR"))))
+ (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-remove-location "Home"))
+ (should (null (wttrin--saved-locations)))))
+
+(ert-deftest test-wttrin-saved-locations-boundary-remove-declined-keeps ()
+ "Boundary: declining the confirmation keeps the entry."
+ (let ((wttrin-saved-locations '(("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-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")
+ (wttrin-mode-line-mode t)
+ (fetched nil))
+ (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t))
+ ((symbol-function 'wttrin--mode-line-fetch-weather)
+ (lambda () (setq fetched t)))
+ ((symbol-function 'wttrin--mode-line-set-placeholder)
+ (lambda () nil))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-remove-location "Home"))
+ (should fetched)))
+
+(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")
+ (wttrin-mode-line-mode t)
+ (fetched nil))
+ (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t))
+ ((symbol-function 'wttrin--mode-line-fetch-weather)
+ (lambda () (setq fetched t)))
+ ((symbol-function 'wttrin--mode-line-set-placeholder)
+ (lambda () nil))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-remove-location "Home"))
+ (should-not fetched)))
+
+;;; wttrin-make-default — geolocation naming
+
+(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-favorite-location nil)
+ (wttrin-mode-line-mode nil))
+ (with-temp-buffer
+ (setq-local wttrin--current-location "41.37,-71.83")
+ (setq-local wttrin--current-display "41.37,-71.83")
+ (setq-local wttrin--current-address "Westerly, RI")
+ (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "Home"))
+ ((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))))
+
+(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-favorite-location nil)
+ (wttrin-mode-line-mode nil))
+ (with-temp-buffer
+ (setq-local wttrin--current-location "41.37,-71.83")
+ (setq-local wttrin--current-address "Westerly, RI")
+ (cl-letf (((symbol-function 'read-string) (lambda (&rest _) ""))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-make-default)))
+ (should (null (wttrin--saved-locations)))
+ (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-favorite-location nil)
+ (wttrin-mode-line-mode nil)
+ (prompted nil))
+ (with-temp-buffer
+ (setq-local wttrin--current-location "1500 Sugar Bowl Dr")
+ (setq-local wttrin--current-display "Craig's House")
+ (cl-letf (((symbol-function 'read-string)
+ (lambda (&rest _) (setq prompted t) "x"))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-make-default)))
+ (should-not prompted)
+ (should (equal "Craig's House" wttrin-favorite-location))
+ (should (equal "1500 Sugar Bowl Dr"
+ (wttrin--resolve-location-query "Craig's House")))))
+
+(ert-deftest test-wttrin-saved-locations-normal-d-typed-saves-to-directory ()
+ "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-favorite-location 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 (assoc "Reykjavik" (wttrin--saved-locations)))
+ (should (equal "Reykjavik" (wttrin--resolve-location-query "Reykjavik")))))
+
+;;; interactive entry smoke tests (cover the prompt forms)
+
+(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))
+ (with-temp-buffer
+ (setq-local wttrin--current-location "Paris, FR")
+ (setq-local wttrin--current-display "Paris, FR")
+ (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "Home"))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (call-interactively 'wttrin-save-location)))
+ (should (equal "Paris, FR" (wttrin--resolve-location-query "Home")))))
+
+(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"))))
+ (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "Home"))
+ ((symbol-function 'read-string) (lambda (&rest _) "Casa"))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (call-interactively 'wttrin-rename-location))
+ (should (equal "Paris, FR" (wttrin--resolve-location-query "Casa")))))
+
+(ert-deftest test-wttrin-saved-locations-normal-remove-interactive ()
+ "Normal: the interactive remove command prompts and confirms."
+ (let ((wttrin-saved-locations '(("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)))
+ (call-interactively 'wttrin-remove-location))
+ (should (null (wttrin--saved-locations)))))
+
+(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))
+ (with-temp-buffer
+ (setq-local wttrin--current-location "Paris, FR")
+ (cl-letf (((symbol-function 'read-string) (lambda (&rest _) " "))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (call-interactively 'wttrin-save-location)))
+ (should (null (wttrin--saved-locations)))))
+
+;;; coordinate suppression in history
+
+(ert-deftest test-wttrin-saved-locations-boundary-coordinates-not-logged ()
+ "Boundary: a raw coordinate string is never added to history."
+ (testutil-wttrin-setup)
+ (unwind-protect
+ (let ((wttrin-default-locations '())
+ (wttrin-saved-locations nil)
+ (wttrin--location-history nil))
+ (wttrin--add-to-location-history "41.37,-71.83")
+ (should (null wttrin--location-history)))
+ (testutil-wttrin-teardown)))
+
+;;; history stays in sync with the directory
+
+(ert-deftest test-wttrin-saved-locations-normal-save-drops-query-from-history ()
+ "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--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 170d0fb..f16b2ae 100644
--- a/tests/test-wttrin-set-location-from-geolocation.el
+++ b/tests/test-wttrin-set-location-from-geolocation.el
@@ -65,6 +65,29 @@
(should (string= "Pre-existing, Place" wttrin-favorite-location)))
(test-wttrin-set-location-from-geolocation-teardown)))
+(ert-deftest test-wttrin-set-location-from-geolocation-normal-confirm-refreshes-mode-line ()
+ "Normal: a confirmed detection refreshes the mode-line so it tracks the new
+favorite immediately instead of at the next scheduled fetch."
+ (test-wttrin-set-location-from-geolocation-setup)
+ (setq wttrin-favorite-location "Pre-existing, Place")
+ (unwind-protect
+ (let ((wttrin-geolocation-enabled t)
+ (wttrin-mode-line-mode t)
+ (wttrin--location-history nil)
+ (fetched nil))
+ (cl-letf (((symbol-function 'wttrin-geolocation-detect)
+ (lambda (callback) (funcall callback "Berkeley, California")))
+ ((symbol-function 'yes-or-no-p) (lambda (&rest _) t))
+ ((symbol-function 'message) (lambda (&rest _) nil))
+ ((symbol-function 'wttrin--mode-line-fetch-weather)
+ (lambda () (setq fetched t)))
+ ((symbol-function 'wttrin--mode-line-set-placeholder)
+ (lambda () nil)))
+ (wttrin-set-location-from-geolocation))
+ (should (string= "Berkeley, California" wttrin-favorite-location))
+ (should fetched))
+ (test-wttrin-set-location-from-geolocation-teardown)))
+
;;; Boundary Cases
(ert-deftest test-wttrin-set-location-from-geolocation-boundary-unicode-location ()
@@ -118,5 +141,34 @@
messages)))
(test-wttrin-set-location-from-geolocation-teardown)))
+;;; Opt-out
+
+(ert-deftest test-wttrin-set-location-from-geolocation-boundary-disabled-no-detect ()
+ "Boundary: with geolocation disabled, the command neither detects nor sets."
+ (test-wttrin-set-location-from-geolocation-setup)
+ (setq wttrin-favorite-location "Pre-existing, Place")
+ (unwind-protect
+ (let ((detected nil)
+ (wttrin-geolocation-enabled nil))
+ (cl-letf (((symbol-function 'wttrin-geolocation-detect)
+ (lambda (_cb) (setq detected t)))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-set-location-from-geolocation))
+ (should-not detected)
+ (should (string= "Pre-existing, Place" wttrin-favorite-location)))
+ (test-wttrin-set-location-from-geolocation-teardown)))
+
+;;; Deprecation
+
+(ert-deftest test-wttrin-set-location-from-geolocation-normal-marked-obsolete ()
+ "Normal: the command is marked obsolete with a steering message.
+The favorite-setting behavior is preserved (see the Normal cases above);
+this only asserts the obsolescence marker so callers get a deprecation
+notice steering them to the picker."
+ (let ((info (get 'wttrin-set-location-from-geolocation 'byte-obsolete-info)))
+ (should info)
+ ;; Option 1: a steering string, not an alias to another function.
+ (should (stringp (nth 0 info)))))
+
(provide 'test-wttrin-set-location-from-geolocation)
;;; test-wttrin-set-location-from-geolocation.el ends here
diff --git a/tests/test-wttrin-use-current-location.el b/tests/test-wttrin-use-current-location.el
new file mode 100644
index 0000000..4b61657
--- /dev/null
+++ b/tests/test-wttrin-use-current-location.el
@@ -0,0 +1,94 @@
+;;; test-wttrin-use-current-location.el --- Tests for wttrin-use-current-location -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024-2026 Craig Jennings
+
+;;; Commentary:
+;; Unit tests for the `wttrin-use-current-location' command, the labeled way to
+;; set `wttrin-favorite-location' to t (always auto-detect) instead of typing
+;; the bare symbol. Mocks `yes-or-no-p' and `message'; touches no network.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'wttrin)
+
+;;; Setup and Teardown
+
+(defvar test-wttrin-use-current-location--saved nil
+ "Snapshot of `wttrin-favorite-location' restored in teardown.")
+
+(defun test-wttrin-use-current-location-setup ()
+ "Snapshot `wttrin-favorite-location' and clear it."
+ (setq test-wttrin-use-current-location--saved wttrin-favorite-location)
+ (setq wttrin-favorite-location nil))
+
+(defun test-wttrin-use-current-location-teardown ()
+ "Restore `wttrin-favorite-location'."
+ (setq wttrin-favorite-location test-wttrin-use-current-location--saved))
+
+;;; Normal Cases
+
+(ert-deftest test-wttrin-use-current-location-normal-confirm-sets-t ()
+ "Normal: confirming sets the favorite to t (auto-detect)."
+ (test-wttrin-use-current-location-setup)
+ (unwind-protect
+ (let ((wttrin-geolocation-enabled t))
+ (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)))
+ (test-wttrin-use-current-location-teardown)))
+
+(ert-deftest test-wttrin-use-current-location-normal-decline-leaves-unchanged ()
+ "Normal: declining leaves the favorite untouched."
+ (test-wttrin-use-current-location-setup)
+ (setq wttrin-favorite-location "Berkeley, CA")
+ (unwind-protect
+ (let ((wttrin-geolocation-enabled t))
+ (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) nil))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-use-current-location))
+ (should (equal "Berkeley, CA" wttrin-favorite-location)))
+ (test-wttrin-use-current-location-teardown)))
+
+(ert-deftest test-wttrin-use-current-location-normal-confirm-refreshes-mode-line ()
+ "Normal: confirming refreshes the mode-line so it switches to the current
+location immediately rather than at the next scheduled fetch."
+ (test-wttrin-use-current-location-setup)
+ (setq wttrin-favorite-location "Berkeley, CA")
+ (unwind-protect
+ (let ((wttrin-geolocation-enabled t)
+ (wttrin-mode-line-mode t)
+ (wttrin--location-history nil)
+ (fetched nil))
+ (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t))
+ ((symbol-function 'message) (lambda (&rest _) nil))
+ ((symbol-function 'wttrin--mode-line-fetch-weather)
+ (lambda () (setq fetched t)))
+ ((symbol-function 'wttrin--mode-line-set-placeholder)
+ (lambda () nil)))
+ (wttrin-use-current-location))
+ (should (eq t wttrin-favorite-location))
+ (should fetched))
+ (test-wttrin-use-current-location-teardown)))
+
+;;; Boundary / Error Cases
+
+(ert-deftest test-wttrin-use-current-location-boundary-disabled-no-prompt-no-set ()
+ "Boundary: with geolocation disabled, no prompt and the favorite is unchanged."
+ (test-wttrin-use-current-location-setup)
+ (setq wttrin-favorite-location "Berkeley, CA")
+ (unwind-protect
+ (let ((prompted nil)
+ (wttrin-geolocation-enabled nil))
+ (cl-letf (((symbol-function 'yes-or-no-p)
+ (lambda (&rest _) (setq prompted t) t))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (wttrin-use-current-location))
+ (should-not prompted)
+ (should (equal "Berkeley, CA" wttrin-favorite-location)))
+ (test-wttrin-use-current-location-teardown)))
+
+(provide 'test-wttrin-use-current-location)
+;;; test-wttrin-use-current-location.el ends here
diff --git a/tests/testutil-wttrin.el b/tests/testutil-wttrin.el
index 7c71a84..e4e2e4e 100644
--- a/tests/testutil-wttrin.el
+++ b/tests/testutil-wttrin.el
@@ -111,6 +111,20 @@
(funcall callback nil)))))
,@body))
+;;; Message Capture Helpers
+
+(defmacro testutil-wttrin-with-captured-message (msg-var &rest body)
+ "Run BODY with `message' captured into MSG-VAR.
+MSG-VAR starts nil and is set to each formatted message string as `message'
+is called, so after BODY it holds the last message shown (or nil if none).
+Other mocks BODY needs can be set in a nested `cl-letf'."
+ (declare (indent 1))
+ `(let ((,msg-var nil))
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (setq ,msg-var (apply #'format fmt args)))))
+ ,@body)))
+
;;; Mode-line Cache Helpers
(defun testutil-wttrin-set-mode-line-cache (data &optional age-seconds)