aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-25 16:03:18 -0400
committerCraig Jennings <c@cjennings.net>2026-06-25 16:03:18 -0400
commit7027cccea9eb7170ea0f08e1def3f979f2e59932 (patch)
tree51752f8cac0d92b4442048d913272e923211b619 /tests
parent33621b5a6e5407da190767b89756e287698ef234 (diff)
downloademacs-wttrin-7027cccea9eb7170ea0f08e1def3f979f2e59932.tar.gz
emacs-wttrin-7027cccea9eb7170ea0f08e1def3f979f2e59932.zip
feat: add external-command geolocation, opt-out, and example adapters
These build on the current-location picker with the rest of the geolocation work. wttrin-geolocation-command runs a command that prints {lat,lng} (and optionally an address) and queries wttr.in by those coordinates. IP geolocation only finds the network's exit point, which is wrong on a VPN or hotspot. A command that scans nearby WiFi resolves to street level. It runs asynchronously, falls back to the IP provider when unset or failing, and assumes nothing about the OS, so it's inert until set. When the command returns an address, wttrin shows it on a "Location:" line, so the resolved place is readable even though the fetch is by raw coordinates. wttrin-use-current-location is a labeled command that sets the favorite to auto-detect, so the bare t value never has to be typed into init by hand. wttrin-geolocation-enabled (default t) turns every geolocation surface off for anyone who wants that: the picker entry, the auto-detect favorite, and the command. examples/geolocation/ ships two reference adapters for the command: google-geolocate.py (Google API, key via the environment or ~/.authinfo.gpg) and apple-wps.py (Apple's keyless WiFi positioning, which uses an undocumented endpoint, so read its caveat). Both are Python 3 standard library and scan via nmcli, with notes on adapting the scan to other systems.
Diffstat (limited to 'tests')
-rw-r--r--tests/test-wttrin--format-location-line.el35
-rw-r--r--tests/test-wttrin-geolocation-command.el161
-rw-r--r--tests/test-wttrin-geolocation-sentinel.el2
-rw-r--r--tests/test-wttrin-use-current-location.el73
4 files changed, 270 insertions, 1 deletions
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-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
index 0536173..61d8997 100644
--- a/tests/test-wttrin-geolocation-sentinel.el
+++ b/tests/test-wttrin-geolocation-sentinel.el
@@ -115,7 +115,7 @@ through `wttrin--query-selection' (smoke test of the entry wrapper)."
(cl-letf (((symbol-function 'wttrin-geolocation-detect)
(lambda (callback) (funcall callback "Austin, TX")))
((symbol-function 'wttrin-query)
- (lambda (loc) (setq captured loc)))
+ (lambda (loc &optional _address) (setq captured loc)))
((symbol-function 'message) (lambda (&rest _) nil)))
(wttrin--query-selection wttrin--geolocation-sentinel))
(should (equal "Austin, TX" captured)))
diff --git a/tests/test-wttrin-use-current-location.el b/tests/test-wttrin-use-current-location.el
new file mode 100644
index 0000000..2b08448
--- /dev/null
+++ b/tests/test-wttrin-use-current-location.el
@@ -0,0 +1,73 @@
+;;; 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)))
+
+;;; 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