aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.org18
-rw-r--r--tests/test-wttrin-location-history.el207
-rw-r--r--wttrin.el60
3 files changed, 283 insertions, 2 deletions
diff --git a/README.org b/README.org
index b6a824d..8fd3fd2 100644
--- a/README.org
+++ b/README.org
@@ -129,6 +129,24 @@ Most people will just want to add a bunch of cities to the location list. Howeve
"41.89,12.48")) ;; GPS Coordinates for Rome
#+end_src
+*** Location Search History
+
+Locations you search successfully are remembered and offered as completion candidates the next time you run =M-x wttrin=, listed after your configured defaults. Only successful lookups are saved, so typos and not-found locations never enter the history. A location already in =wttrin-default-locations= is not duplicated into the history.
+
+History is capped at =wttrin-location-history-max= entries (default 20); the oldest fall off as new ones arrive.
+
+#+begin_src emacs-lisp
+ (setq wttrin-location-history-max 20)
+#+end_src
+
+To persist the history across Emacs restarts, enable the built-in =savehist-mode= (Wttrin registers its history variable automatically). Without it, history lasts for the session only.
+
+#+begin_src emacs-lisp
+ (savehist-mode 1)
+#+end_src
+
+Two commands manage the history: =M-x wttrin-remove-location-history= drops a single entry (with completion), and =M-x wttrin-clear-location-history= clears all of it.
+
*** Default Language
Customizing 'wttrin-default-languages' allows users to tell Wttrin which language to request for the text it displays. For instance, this changes the language used for days of the week, periods of the day, and other related text.
diff --git a/tests/test-wttrin-location-history.el b/tests/test-wttrin-location-history.el
new file mode 100644
index 0000000..d03430d
--- /dev/null
+++ b/tests/test-wttrin-location-history.el
@@ -0,0 +1,207 @@
+;;; 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 defaults first, then history."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin-default-locations '("Honolulu, HI" "Berkeley, CA"))
+ (wttrin--location-history '("Tokyo" "Paris")))
+ (should (equal '("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 just the defaults."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin-default-locations '("Honolulu, HI"))
+ (wttrin--location-history nil))
+ (should (equal '("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 just the history."
+ (test-wttrin-location-history-setup)
+ (unwind-protect
+ (let ((wttrin-default-locations '())
+ (wttrin--location-history '("Tokyo")))
+ (should (equal '("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)))
+
+(provide 'test-wttrin-location-history)
+;;; test-wttrin-location-history.el ends here
diff --git a/wttrin.el b/wttrin.el
index b6f59fb..f9d10f0 100644
--- a/wttrin.el
+++ b/wttrin.el
@@ -494,6 +494,61 @@ Handles header skipping, UTF-8 decoding, and error handling automatically."
CALLBACK is called with the weather data string when ready, or nil on error."
(wttrin--fetch-url (wttrin--build-url query) callback))
+;;; Location Search History
+
+(defcustom wttrin-location-history-max 20
+ "Maximum number of entries to keep in location search history.
+When the history exceeds this limit, the oldest entries are removed."
+ :group 'wttrin
+ :type 'integer)
+
+(defvar wttrin--location-history nil
+ "History of successfully searched locations, most recent first.
+Persisted across sessions via `savehist-mode'.")
+
+;; Declared so the byte-compiler doesn't warn; savehist defines it for real.
+(defvar savehist-additional-variables)
+
+(with-eval-after-load 'savehist
+ (add-to-list 'savehist-additional-variables 'wttrin--location-history))
+
+(defun wttrin--add-to-location-history (location)
+ "Record LOCATION as a recent successful search.
+No-op when LOCATION is nil, empty, or already a default location. An existing
+entry is promoted to most-recent, and the list is trimmed to
+`wttrin-location-history-max'."
+ (when (and location
+ (not (string= location ""))
+ (not (member location wttrin-default-locations)))
+ (setq wttrin--location-history (delete location wttrin--location-history))
+ (push location wttrin--location-history)
+ (let ((max (max 0 wttrin-location-history-max)))
+ (when (> (length wttrin--location-history) max)
+ (setq wttrin--location-history
+ (butlast wttrin--location-history
+ (- (length wttrin--location-history) max)))))))
+
+(defun wttrin--completion-candidates ()
+ "Return default locations followed by search-history entries.
+History already excludes defaults (see `wttrin--add-to-location-history')."
+ (append wttrin-default-locations wttrin--location-history))
+
+(defun wttrin-remove-location-history (location)
+ "Remove LOCATION from the search history.
+Prompts with completion over the current history entries."
+ (interactive
+ (list (completing-read "Remove from history: "
+ wttrin--location-history nil t)))
+ (setq wttrin--location-history (delete location wttrin--location-history))
+ (message "Removed '%s' from location history" location))
+
+(defun wttrin-clear-location-history ()
+ "Clear all location search history."
+ (interactive)
+ (when (yes-or-no-p "Clear all location search history? ")
+ (setq wttrin--location-history nil)
+ (message "Location history cleared")))
+
(defun wttrin--requery-location (new-location)
"Kill current weather buffer and query NEW-LOCATION."
(when (get-buffer "*wttr.in*")
@@ -504,7 +559,7 @@ CALLBACK is called with the weather data string when ready, or nil on error."
"Kill buffer and requery wttrin."
(interactive)
(let ((new-location (completing-read
- "Location Name: " wttrin-default-locations nil nil
+ "Location Name: " (wttrin--completion-candidates) nil nil
(when (= (length wttrin-default-locations) 1)
(car wttrin-default-locations)))))
(wttrin--requery-location new-location)))
@@ -593,6 +648,7 @@ the generic error message."
(message "wttrin: %s"
(or error-msg
"Cannot retrieve weather data. Perhaps the location was misspelled?"))
+ (wttrin--add-to-location-history location-name)
(let ((buffer (get-buffer-create (format "*wttr.in*"))))
(switch-to-buffer buffer)
@@ -1008,7 +1064,7 @@ When enabled, shows weather for `wttrin-favorite-location'."
Weather data is fetched asynchronously to avoid blocking Emacs."
(interactive
(list
- (completing-read "Location Name: " wttrin-default-locations nil nil
+ (completing-read "Location Name: " (wttrin--completion-candidates) nil nil
(when (= (length wttrin-default-locations) 1)
(car wttrin-default-locations)))))
(wttrin-query location))