summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test-wttrin--display-weather.el6
-rw-r--r--tests/test-wttrin--get-cached-or-fetch.el403
-rw-r--r--tests/test-wttrin-ansi-color-rendering.el416
-rw-r--r--tests/test-wttrin-mode-initialization-order.el98
4 files changed, 920 insertions, 3 deletions
diff --git a/tests/test-wttrin--display-weather.el b/tests/test-wttrin--display-weather.el
index 364908f..4b2a90d 100644
--- a/tests/test-wttrin--display-weather.el
+++ b/tests/test-wttrin--display-weather.el
@@ -87,7 +87,7 @@ Weather report: Paris, France
;; Check that keybindings are set (they should be in the local map)
(should (keymapp (current-local-map)))
(should (commandp (lookup-key (current-local-map) "q")))
- (should (commandp (lookup-key (current-local-map) "r")))
+ (should (commandp (lookup-key (current-local-map) "a")))
(should (commandp (lookup-key (current-local-map) "g")))))
(test-wttrin--display-weather-teardown)))
@@ -103,8 +103,8 @@ Weather report: Paris, France
(forward-line -2)
;; Should contain help text
(should (search-forward "Press:" nil t))
- (should (search-forward "[g] to query another location" nil t))
- (should (search-forward "[r] to refresh" 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))))
(test-wttrin--display-weather-teardown)))
diff --git a/tests/test-wttrin--get-cached-or-fetch.el b/tests/test-wttrin--get-cached-or-fetch.el
new file mode 100644
index 0000000..77a2689
--- /dev/null
+++ b/tests/test-wttrin--get-cached-or-fetch.el
@@ -0,0 +1,403 @@
+;;; test-wttrin--get-cached-or-fetch.el --- Tests for wttrin--get-cached-or-fetch -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025 Craig Jennings
+
+;;; Commentary:
+
+;; Unit tests for wttrin--get-cached-or-fetch function.
+;; Tests the core cache workflow: cache hits, misses, expiration, and error fallback.
+
+;;; Code:
+
+(require 'ert)
+(require 'wttrin)
+(require 'testutil-wttrin)
+
+;;; Test Data Fixtures
+
+(defconst test-wttrin--get-cached-or-fetch-sample-weather
+ "Weather for Paris: Sunny 22°C"
+ "Sample weather data for testing.")
+
+(defconst test-wttrin--get-cached-or-fetch-new-weather
+ "Weather for Paris: Cloudy 18°C"
+ "Updated weather data for testing cache updates.")
+
+;;; Test Setup and Teardown
+
+(defun test-wttrin--get-cached-or-fetch-setup ()
+ "Setup for get-cached-or-fetch tests."
+ (testutil-wttrin-setup))
+
+(defun test-wttrin--get-cached-or-fetch-teardown ()
+ "Teardown for get-cached-or-fetch tests."
+ (testutil-wttrin-teardown))
+
+;;; Normal Cases
+
+(ert-deftest test-wttrin--get-cached-or-fetch-normal-cache-hit-returns-cached-data ()
+ "Test that fresh cached data is returned without fetching."
+ (test-wttrin--get-cached-or-fetch-setup)
+ (unwind-protect
+ (let* ((location "Paris")
+ (cache-key (wttrin--make-cache-key location))
+ (now 1000.0)
+ (callback-result nil)
+ (fetch-called nil))
+ ;; Pre-populate cache with fresh data
+ (puthash cache-key (cons now test-wttrin--get-cached-or-fetch-sample-weather)
+ wttrin--cache)
+
+ ;; Mock time to be 100 seconds later (well within TTL of 900)
+ (cl-letf (((symbol-function 'float-time)
+ (lambda () (+ now 100.0)))
+ ((symbol-function 'wttrin-fetch-raw-string)
+ (lambda (_location _callback)
+ (setq fetch-called t))))
+
+ (wttrin--get-cached-or-fetch
+ location
+ (lambda (data) (setq callback-result data)))
+
+ ;; Should return cached data immediately
+ (should (equal callback-result test-wttrin--get-cached-or-fetch-sample-weather))
+ ;; Should NOT call fetch
+ (should-not fetch-called)))
+ (test-wttrin--get-cached-or-fetch-teardown)))
+
+(ert-deftest test-wttrin--get-cached-or-fetch-normal-cache-miss-fetches-new-data ()
+ "Test that missing cache entry triggers fetch and stores result."
+ (test-wttrin--get-cached-or-fetch-setup)
+ (unwind-protect
+ (let* ((location "London")
+ (cache-key (wttrin--make-cache-key location))
+ (now 1000.0)
+ (callback-result nil)
+ (fetch-called nil))
+
+ (cl-letf (((symbol-function 'float-time)
+ (lambda () now))
+ ((symbol-function 'wttrin-fetch-raw-string)
+ (lambda (_location callback)
+ (setq fetch-called t)
+ (funcall callback test-wttrin--get-cached-or-fetch-new-weather)))
+ ((symbol-function 'wttrin--cleanup-cache-if-needed)
+ (lambda () nil)))
+
+ (wttrin--get-cached-or-fetch
+ location
+ (lambda (data) (setq callback-result data)))
+
+ ;; Should call fetch
+ (should fetch-called)
+ ;; Should return fresh data
+ (should (equal callback-result test-wttrin--get-cached-or-fetch-new-weather))
+ ;; Should store in cache
+ (let ((cached (gethash cache-key wttrin--cache)))
+ (should cached)
+ (should (equal (car cached) now))
+ (should (equal (cdr cached) test-wttrin--get-cached-or-fetch-new-weather)))))
+ (test-wttrin--get-cached-or-fetch-teardown)))
+
+(ert-deftest test-wttrin--get-cached-or-fetch-normal-expired-cache-fetches-new-data ()
+ "Test that expired cache triggers fetch and updates cache."
+ (test-wttrin--get-cached-or-fetch-setup)
+ (unwind-protect
+ (let* ((location "Tokyo")
+ (cache-key (wttrin--make-cache-key location))
+ (old-time 1000.0)
+ (new-time (+ old-time 1000.0)) ; 1000 seconds later (> 900 TTL)
+ (callback-result nil)
+ (fetch-called nil))
+
+ ;; Pre-populate cache with old data
+ (puthash cache-key (cons old-time test-wttrin--get-cached-or-fetch-sample-weather)
+ wttrin--cache)
+
+ (cl-letf (((symbol-function 'float-time)
+ (lambda () new-time))
+ ((symbol-function 'wttrin-fetch-raw-string)
+ (lambda (_location callback)
+ (setq fetch-called t)
+ (funcall callback test-wttrin--get-cached-or-fetch-new-weather)))
+ ((symbol-function 'wttrin--cleanup-cache-if-needed)
+ (lambda () nil)))
+
+ (wttrin--get-cached-or-fetch
+ location
+ (lambda (data) (setq callback-result data)))
+
+ ;; Should call fetch due to expiration
+ (should fetch-called)
+ ;; Should return new data
+ (should (equal callback-result test-wttrin--get-cached-or-fetch-new-weather))
+ ;; Should update cache timestamp
+ (let ((cached (gethash cache-key wttrin--cache)))
+ (should (equal (car cached) new-time))
+ (should (equal (cdr cached) test-wttrin--get-cached-or-fetch-new-weather)))))
+ (test-wttrin--get-cached-or-fetch-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-wttrin--get-cached-or-fetch-boundary-exactly-at-ttl-fetches ()
+ "Test that cache exactly at TTL boundary triggers fetch."
+ (test-wttrin--get-cached-or-fetch-setup)
+ (unwind-protect
+ (let* ((location "Berlin")
+ (cache-key (wttrin--make-cache-key location))
+ (old-time 1000.0)
+ ;; Exactly at TTL boundary (900 seconds = wttrin-cache-ttl)
+ (new-time (+ old-time wttrin-cache-ttl))
+ (callback-result nil)
+ (fetch-called nil))
+
+ ;; Pre-populate cache
+ (puthash cache-key (cons old-time test-wttrin--get-cached-or-fetch-sample-weather)
+ wttrin--cache)
+
+ (cl-letf (((symbol-function 'float-time)
+ (lambda () new-time))
+ ((symbol-function 'wttrin-fetch-raw-string)
+ (lambda (_location callback)
+ (setq fetch-called t)
+ (funcall callback test-wttrin--get-cached-or-fetch-new-weather)))
+ ((symbol-function 'wttrin--cleanup-cache-if-needed)
+ (lambda () nil)))
+
+ (wttrin--get-cached-or-fetch
+ location
+ (lambda (data) (setq callback-result data)))
+
+ ;; At exactly TTL, should fetch (not <)
+ (should fetch-called)
+ (should (equal callback-result test-wttrin--get-cached-or-fetch-new-weather))))
+ (test-wttrin--get-cached-or-fetch-teardown)))
+
+(ert-deftest test-wttrin--get-cached-or-fetch-boundary-one-second-before-ttl-uses-cache ()
+ "Test that cache one second before TTL uses cached data."
+ (test-wttrin--get-cached-or-fetch-setup)
+ (unwind-protect
+ (let* ((location "Madrid")
+ (cache-key (wttrin--make-cache-key location))
+ (old-time 1000.0)
+ ;; One second before TTL expiration
+ (new-time (+ old-time (- wttrin-cache-ttl 1)))
+ (callback-result nil)
+ (fetch-called nil))
+
+ ;; Pre-populate cache
+ (puthash cache-key (cons old-time test-wttrin--get-cached-or-fetch-sample-weather)
+ wttrin--cache)
+
+ (cl-letf (((symbol-function 'float-time)
+ (lambda () new-time))
+ ((symbol-function 'wttrin-fetch-raw-string)
+ (lambda (_location _callback)
+ (setq fetch-called t))))
+
+ (wttrin--get-cached-or-fetch
+ location
+ (lambda (data) (setq callback-result data)))
+
+ ;; Should use cache (still fresh)
+ (should-not fetch-called)
+ (should (equal callback-result test-wttrin--get-cached-or-fetch-sample-weather))))
+ (test-wttrin--get-cached-or-fetch-teardown)))
+
+(ert-deftest test-wttrin--get-cached-or-fetch-boundary-force-refresh-bypasses-fresh-cache ()
+ "Test that force refresh flag bypasses fresh cache."
+ (test-wttrin--get-cached-or-fetch-setup)
+ (unwind-protect
+ (let* ((location "Rome")
+ (cache-key (wttrin--make-cache-key location))
+ (now 1000.0)
+ (callback-result nil)
+ (fetch-called nil)
+ (wttrin--force-refresh t)) ; Force refresh enabled
+
+ ;; Pre-populate cache with fresh data
+ (puthash cache-key (cons now test-wttrin--get-cached-or-fetch-sample-weather)
+ wttrin--cache)
+
+ (cl-letf (((symbol-function 'float-time)
+ (lambda () (+ now 100.0))) ; Well within TTL
+ ((symbol-function 'wttrin-fetch-raw-string)
+ (lambda (_location callback)
+ (setq fetch-called t)
+ (funcall callback test-wttrin--get-cached-or-fetch-new-weather)))
+ ((symbol-function 'wttrin--cleanup-cache-if-needed)
+ (lambda () nil)))
+
+ (wttrin--get-cached-or-fetch
+ location
+ (lambda (data) (setq callback-result data)))
+
+ ;; Should fetch despite fresh cache
+ (should fetch-called)
+ (should (equal callback-result test-wttrin--get-cached-or-fetch-new-weather))))
+ (test-wttrin--get-cached-or-fetch-teardown)))
+
+(ert-deftest test-wttrin--get-cached-or-fetch-boundary-empty-cache-fetches ()
+ "Test that completely empty cache triggers fetch."
+ (test-wttrin--get-cached-or-fetch-setup)
+ (unwind-protect
+ (let* ((location "Athens")
+ (callback-result nil)
+ (fetch-called nil))
+
+ ;; Cache is already empty from setup
+ (should (= (hash-table-count wttrin--cache) 0))
+
+ (cl-letf (((symbol-function 'float-time)
+ (lambda () 1000.0))
+ ((symbol-function 'wttrin-fetch-raw-string)
+ (lambda (_location callback)
+ (setq fetch-called t)
+ (funcall callback test-wttrin--get-cached-or-fetch-new-weather)))
+ ((symbol-function 'wttrin--cleanup-cache-if-needed)
+ (lambda () nil)))
+
+ (wttrin--get-cached-or-fetch
+ location
+ (lambda (data) (setq callback-result data)))
+
+ ;; Should fetch
+ (should fetch-called)
+ (should (equal callback-result test-wttrin--get-cached-or-fetch-new-weather))))
+ (test-wttrin--get-cached-or-fetch-teardown)))
+
+(ert-deftest test-wttrin--get-cached-or-fetch-boundary-cache-key-includes-unit-system ()
+ "Test that cache keys differentiate by unit system."
+ (test-wttrin--get-cached-or-fetch-setup)
+ (unwind-protect
+ (let* ((location "Oslo")
+ (now 1000.0)
+ (metric-data "Weather: 20°C")
+ (imperial-data "Weather: 68°F")
+ (callback-result nil))
+
+ ;; Cache metric version
+ (let ((wttrin-unit-system "m"))
+ (puthash (wttrin--make-cache-key location)
+ (cons now metric-data)
+ wttrin--cache))
+
+ ;; Cache imperial version
+ (let ((wttrin-unit-system "u"))
+ (puthash (wttrin--make-cache-key location)
+ (cons now imperial-data)
+ wttrin--cache))
+
+ (cl-letf (((symbol-function 'float-time)
+ (lambda () (+ now 100.0))))
+
+ ;; Fetch with metric - should get metric cache
+ (let ((wttrin-unit-system "m"))
+ (wttrin--get-cached-or-fetch
+ location
+ (lambda (data) (setq callback-result data)))
+ (should (equal callback-result metric-data)))
+
+ ;; Fetch with imperial - should get imperial cache
+ (let ((wttrin-unit-system "u"))
+ (wttrin--get-cached-or-fetch
+ location
+ (lambda (data) (setq callback-result data)))
+ (should (equal callback-result imperial-data)))))
+ (test-wttrin--get-cached-or-fetch-teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-wttrin--get-cached-or-fetch-error-fetch-fails-with-stale-cache-returns-stale ()
+ "Test that fetch failure with stale cache falls back to stale data."
+ (test-wttrin--get-cached-or-fetch-setup)
+ (unwind-protect
+ (let* ((location "Vienna")
+ (cache-key (wttrin--make-cache-key location))
+ (old-time 1000.0)
+ (new-time (+ old-time 2000.0)) ; Well expired
+ (callback-result nil)
+ (message-shown nil))
+
+ ;; Pre-populate cache with expired data
+ (puthash cache-key (cons old-time test-wttrin--get-cached-or-fetch-sample-weather)
+ wttrin--cache)
+
+ (cl-letf (((symbol-function 'float-time)
+ (lambda () new-time))
+ ((symbol-function 'wttrin-fetch-raw-string)
+ (lambda (_location callback)
+ ;; Simulate network failure
+ (funcall callback nil)))
+ ((symbol-function 'wttrin--cleanup-cache-if-needed)
+ (lambda () nil))
+ ((symbol-function 'message)
+ (lambda (format-string &rest _args)
+ (setq message-shown format-string))))
+
+ (wttrin--get-cached-or-fetch
+ location
+ (lambda (data) (setq callback-result data)))
+
+ ;; Should fall back to stale cache
+ (should (equal callback-result test-wttrin--get-cached-or-fetch-sample-weather))
+ ;; Should show message about using cached version
+ (should message-shown)
+ (should (string-match-p "cached" message-shown))))
+ (test-wttrin--get-cached-or-fetch-teardown)))
+
+(ert-deftest test-wttrin--get-cached-or-fetch-error-fetch-fails-with-no-cache-returns-nil ()
+ "Test that fetch failure with no cache returns nil."
+ (test-wttrin--get-cached-or-fetch-setup)
+ (unwind-protect
+ (let* ((location "Dublin")
+ (callback-result 'unset))
+
+ ;; Cache is empty
+ (should (= (hash-table-count wttrin--cache) 0))
+
+ (cl-letf (((symbol-function 'float-time)
+ (lambda () 1000.0))
+ ((symbol-function 'wttrin-fetch-raw-string)
+ (lambda (_location callback)
+ ;; Simulate network failure
+ (funcall callback nil)))
+ ((symbol-function 'wttrin--cleanup-cache-if-needed)
+ (lambda () nil)))
+
+ (wttrin--get-cached-or-fetch
+ location
+ (lambda (data) (setq callback-result data)))
+
+ ;; Should return nil (no fallback available)
+ (should (null callback-result))))
+ (test-wttrin--get-cached-or-fetch-teardown)))
+
+(ert-deftest test-wttrin--get-cached-or-fetch-error-nil-location-creates-cache-key ()
+ "Test that nil location is handled (creates cache key with nil)."
+ (test-wttrin--get-cached-or-fetch-setup)
+ (unwind-protect
+ (let* ((location nil)
+ (callback-result nil)
+ (fetch-called nil))
+
+ (cl-letf (((symbol-function 'float-time)
+ (lambda () 1000.0))
+ ((symbol-function 'wttrin-fetch-raw-string)
+ (lambda (_location callback)
+ (setq fetch-called t)
+ (funcall callback test-wttrin--get-cached-or-fetch-new-weather)))
+ ((symbol-function 'wttrin--cleanup-cache-if-needed)
+ (lambda () nil)))
+
+ (wttrin--get-cached-or-fetch
+ location
+ (lambda (data) (setq callback-result data)))
+
+ ;; Should attempt to fetch (nil is a valid location input)
+ (should fetch-called)
+ (should (equal callback-result test-wttrin--get-cached-or-fetch-new-weather))))
+ (test-wttrin--get-cached-or-fetch-teardown)))
+
+(provide 'test-wttrin--get-cached-or-fetch)
+;;; test-wttrin--get-cached-or-fetch.el ends here
diff --git a/tests/test-wttrin-ansi-color-rendering.el b/tests/test-wttrin-ansi-color-rendering.el
new file mode 100644
index 0000000..0945a2f
--- /dev/null
+++ b/tests/test-wttrin-ansi-color-rendering.el
@@ -0,0 +1,416 @@
+;;; test-wttrin-ansi-color-rendering.el --- Test ANSI color rendering -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025 Craig Jennings
+
+;;; Commentary:
+
+;; Test that ANSI color codes are properly rendered in the weather buffer.
+;; This reproduces the bug where clicking the mode-line icon shows white text.
+
+;;; Code:
+
+(require 'ert)
+(require 'wttrin)
+(require 'xterm-color)
+(require 'testutil-wttrin)
+
+;;; Test Data - Realistic wttr.in response with ANSI codes
+
+(defconst test-wttrin-ansi-sample-with-colors
+ "Weather report: Paris
+
+\x1b[38;5;226m \\ /\x1b[0m Partly cloudy
+\x1b[38;5;226m _ /\"\"\x1b[38;5;250m.-.\x1b[0m \x1b[38;5;118m+13\x1b[0m(\x1b[38;5;082m12\x1b[0m) °C
+\x1b[38;5;226m \\_\x1b[38;5;250m( ). \x1b[0m ↑ \x1b[38;5;190m12\x1b[0m km/h
+"
+ "Sample weather data with ANSI color codes (escape sequences).")
+
+;;; Test Setup and Teardown
+
+(defun test-wttrin-ansi-setup ()
+ "Setup for ANSI color rendering tests."
+ (testutil-wttrin-setup)
+ (when (get-buffer "*wttr.in*")
+ (kill-buffer "*wttr.in*")))
+
+(defun test-wttrin-ansi-teardown ()
+ "Teardown for ANSI color rendering tests."
+ (testutil-wttrin-teardown)
+ (when (get-buffer "*wttr.in*")
+ (kill-buffer "*wttr.in*")))
+
+;;; Helper Functions
+
+(defun test-wttrin-ansi--count-colored-text ()
+ "Count number of characters with color text properties in current buffer.
+Returns cons (colored-chars . total-chars)."
+ (save-excursion
+ (goto-char (point-min))
+ (let ((colored 0)
+ (total 0))
+ (while (not (eobp))
+ (let ((props (text-properties-at (point))))
+ (setq total (1+ total))
+ ;; Check for xterm-color face properties
+ (when (or (plist-get props 'face)
+ (plist-get props 'font-lock-face))
+ (setq colored (1+ colored))))
+ (forward-char 1))
+ (cons colored total))))
+
+(defun test-wttrin-ansi--has-ansi-escape-codes (str)
+ "Check if STR contains ANSI escape codes."
+ (string-match-p "\x1b\\[" str))
+
+;;; Tests
+
+(ert-deftest test-wttrin-ansi-xterm-color-filter-processes-codes ()
+ "Test that xterm-color-filter properly processes ANSI codes."
+ (test-wttrin-ansi-setup)
+ (unwind-protect
+ (let ((filtered (xterm-color-filter test-wttrin-ansi-sample-with-colors)))
+ ;; After filtering, ANSI escape codes should be removed
+ (should-not (test-wttrin-ansi--has-ansi-escape-codes filtered))
+
+ ;; The filtered text should be shorter (escape codes removed)
+ (should (< (length filtered) (length test-wttrin-ansi-sample-with-colors)))
+
+ ;; Text should still contain the actual weather content
+ (should (string-match-p "Paris" filtered))
+ (should (string-match-p "Partly cloudy" filtered)))
+ (test-wttrin-ansi-teardown)))
+
+(ert-deftest test-wttrin-ansi-display-weather-renders-colors ()
+ "Test that display-weather properly renders ANSI colors in buffer."
+ (test-wttrin-ansi-setup)
+ (unwind-protect
+ (progn
+ (wttrin--display-weather "Paris" test-wttrin-ansi-sample-with-colors)
+
+ (should (get-buffer "*wttr.in*"))
+
+ (with-current-buffer "*wttr.in*"
+ ;; Buffer should not contain raw ANSI escape codes
+ (let ((buffer-text (buffer-substring-no-properties (point-min) (point-max))))
+ (should-not (test-wttrin-ansi--has-ansi-escape-codes buffer-text)))
+
+ ;; Buffer should contain the weather text
+ (goto-char (point-min))
+ (should (search-forward "Paris" nil t))
+ (should (search-forward "Partly cloudy" nil t))
+
+ ;; Check that some text has color properties
+ (let* ((counts (test-wttrin-ansi--count-colored-text))
+ (colored (car counts))
+ (total (cdr counts)))
+ ;; At least some characters should have color properties
+ ;; (not all white text)
+ (should (> colored 0))
+
+ ;; Colored text should be a reasonable portion (not just 2 chars)
+ ;; With ANSI codes, we expect at least 10% of text to be colored
+ (should (> colored (/ total 10))))))
+ (test-wttrin-ansi-teardown)))
+
+(ert-deftest test-wttrin-ansi-mode-line-click-renders-colors ()
+ "Test that clicking mode-line icon renders colors properly.
+This reproduces the bug where mode-line click shows white text."
+ (test-wttrin-ansi-setup)
+ (unwind-protect
+ (let* ((location "Paris")
+ (cache-key (wttrin--make-cache-key location))
+ (now 1000.0)
+ (wttrin-mode-line-favorite-location location))
+
+ ;; Mock the async fetch to return ANSI-coded data
+ (cl-letf (((symbol-function 'float-time)
+ (lambda () now))
+ ((symbol-function 'wttrin-fetch-raw-string)
+ (lambda (_location callback)
+ (funcall callback test-wttrin-ansi-sample-with-colors)))
+ ((symbol-function 'wttrin--cleanup-cache-if-needed)
+ (lambda () nil)))
+
+ ;; Simulate mode-line click by calling wttrin
+ ;; (wttrin-mode-line-click just calls this)
+ (wttrin location)
+
+ ;; Give async callback time to execute
+ ;; (In real execution this is async, but our mock is synchronous)
+ (should (get-buffer "*wttr.in*"))
+
+ (with-current-buffer "*wttr.in*"
+ ;; Buffer should not contain raw ANSI codes
+ (let ((buffer-text (buffer-substring-no-properties (point-min) (point-max))))
+ (should-not (test-wttrin-ansi--has-ansi-escape-codes buffer-text)))
+
+ ;; Check that text has color properties (not all white)
+ (let* ((counts (test-wttrin-ansi--count-colored-text))
+ (colored (car counts)))
+ ;; Should have colored text (proving colors are rendered)
+ (should (> colored 0))))))
+ (test-wttrin-ansi-teardown)))
+
+(ert-deftest test-wttrin-ansi-cached-data-preserves-colors ()
+ "Test that cached weather data preserves color information.
+Verifies that cache doesn't strip ANSI codes or color properties."
+ (test-wttrin-ansi-setup)
+ (unwind-protect
+ (let* ((location "Berlin")
+ (cache-key (wttrin--make-cache-key location))
+ (now 1000.0))
+
+ ;; Pre-populate cache with ANSI-coded data
+ (puthash cache-key (cons now test-wttrin-ansi-sample-with-colors)
+ wttrin--cache)
+
+ (cl-letf (((symbol-function 'float-time)
+ (lambda () (+ now 100.0)))) ; Within TTL
+
+ ;; Call wttrin - should use cached data
+ (wttrin location)
+
+ (should (get-buffer "*wttr.in*"))
+
+ (with-current-buffer "*wttr.in*"
+ ;; Even with cached data, colors should be rendered
+ (let* ((counts (test-wttrin-ansi--count-colored-text))
+ (colored (car counts))
+ (total (cdr counts)))
+ (should (> colored 0))
+ ;; Should have reasonable color coverage
+ (should (> colored (/ total 10)))))))
+ (test-wttrin-ansi-teardown)))
+
+(ert-deftest test-wttrin-ansi-buffer-face-mode-doesnt-strip-colors ()
+ "Test that buffer-face-mode doesn't strip xterm-color face properties.
+This reproduces the bug where weather buffer shows mostly white text."
+ (test-wttrin-ansi-setup)
+ (unwind-protect
+ (progn
+ (wttrin--display-weather "Paris" test-wttrin-ansi-sample-with-colors)
+
+ (should (get-buffer "*wttr.in*"))
+
+ (with-current-buffer "*wttr.in*"
+ ;; Check that face-remapping is active (for font customization)
+ (should face-remapping-alist)
+
+ ;; But colors should still be present despite face remapping
+ (let* ((counts (test-wttrin-ansi--count-colored-text))
+ (colored (car counts))
+ (total (cdr counts))
+ (percentage (if (> total 0)
+ (* 100.0 (/ (float colored) total))
+ 0)))
+ ;; Should have substantial colored text (>10%)
+ ;; If this fails with ~5% or less, buffer-face-mode is interfering
+ (should (> percentage 10.0))
+
+ ;; With face-remap-add-relative fix, we get ~30-40 colored chars
+ ;; (our test data is small, so absolute count is low)
+ ;; The key is the percentage, not absolute count
+ (should (> colored 20)))))
+ (test-wttrin-ansi-teardown)))
+
+(ert-deftest test-wttrin-ansi-mode-line-doesnt-pollute-main-cache ()
+ "Test that mode-line weather fetch doesn't pollute main cache with plain text.
+This reproduces the bug where clicking mode-line shows white text."
+ (test-wttrin-ansi-setup)
+ (unwind-protect
+ (let* ((location "Berlin")
+ (cache-key (wttrin--make-cache-key location))
+ (now 1000.0)
+ (plain-text-weather "Berlin: ☀️ +20°C Clear") ; No ANSI codes
+ (ansi-weather test-wttrin-ansi-sample-with-colors)) ; Has ANSI codes
+
+ ;; Simulate mode-line storing plain-text in cache (the bug)
+ ;; This shouldn't happen, but let's verify the system is resilient
+ (puthash cache-key (cons now plain-text-weather) wttrin--cache)
+
+ (cl-letf (((symbol-function 'float-time)
+ (lambda () (+ now 100.0))) ; Within TTL, will use cache
+ ((symbol-function 'wttrin-fetch-raw-string)
+ (lambda (_location callback)
+ ;; Should never be called since cache is fresh
+ (error "Should not fetch when cache is fresh"))))
+
+ ;; Call wttrin - it will use cached data
+ (wttrin location)
+
+ (should (get-buffer "*wttr.in*"))
+
+ (with-current-buffer "*wttr.in*"
+ ;; Even if cache has plain text, should we handle it gracefully?
+ ;; At minimum, buffer should exist and not error
+ (should (> (buffer-size) 0))
+
+ ;; The real fix: mode-line should use separate cache or namespace
+ ;; For now, document that plain-text cache = no colors
+ (let* ((buffer-text (buffer-substring-no-properties (point-min) (point-max))))
+ ;; Should contain the weather data (even if not colored)
+ (should (string-match-p "Berlin" buffer-text))))))
+ (test-wttrin-ansi-teardown)))
+
+(ert-deftest test-wttrin-ansi-full-scenario-mode-line-then-click ()
+ "Test full scenario: mode-line fetch, then user clicks to open buffer.
+This is the exact user workflow that exposes the bug."
+ (test-wttrin-ansi-setup)
+ (unwind-protect
+ (let* ((location "Tokyo")
+ (now 1000.0)
+ (wttrin-mode-line-favorite-location location)
+ (mode-line-fetch-count 0)
+ (main-fetch-count 0))
+
+ (cl-letf (((symbol-function 'float-time)
+ (lambda () now))
+ ((symbol-function 'url-retrieve)
+ (lambda (url callback)
+ ;; Mode-line uses url-retrieve directly
+ (setq mode-line-fetch-count (1+ mode-line-fetch-count))
+ ;; Simulate async callback with plain-text format
+ (with-temp-buffer
+ (insert "HTTP/1.1 200 OK\n\n")
+ (insert "Tokyo: ☀️ +25°C Clear") ; Plain text, no ANSI
+ (funcall callback nil))))
+ ((symbol-function 'wttrin-fetch-raw-string)
+ (lambda (_location callback)
+ ;; Main buffer fetch should get ANSI codes
+ (setq main-fetch-count (1+ main-fetch-count))
+ (funcall callback test-wttrin-ansi-sample-with-colors)))
+ ((symbol-function 'wttrin--cleanup-cache-if-needed)
+ (lambda () nil)))
+
+ ;; Step 1: Mode-line fetches weather (simulated)
+ (wttrin--mode-line-fetch-weather)
+ ;; Mode-line should have fetched
+ (should (= mode-line-fetch-count 1))
+
+ ;; Step 2: User clicks mode-line icon (calls wttrin)
+ (wttrin location)
+ ;; Main fetch should have happened (cache miss)
+ (should (= main-fetch-count 1))
+
+ ;; Step 3: Verify buffer has colors
+ (should (get-buffer "*wttr.in*"))
+
+ (with-current-buffer "*wttr.in*"
+ (let* ((counts (test-wttrin-ansi--count-colored-text))
+ (colored (car counts))
+ (total (cdr counts)))
+ ;; Should have colored text
+ (should (> colored 0))
+ ;; Should be substantial (>10%)
+ (should (> colored (/ total 10)))))))
+ (test-wttrin-ansi-teardown)))
+
+(ert-deftest test-wttrin-ansi-cache-stores-ansi-codes ()
+ "Test that cache stores raw ANSI codes, not filtered text.
+This verifies the cache workflow preserves ANSI codes for later filtering."
+ (test-wttrin-ansi-setup)
+ (unwind-protect
+ (let* ((location "Vienna")
+ (cache-key (wttrin--make-cache-key location))
+ (now 1000.0)
+ (fetch-count 0))
+
+ (cl-letf (((symbol-function 'float-time)
+ (lambda () now))
+ ((symbol-function 'wttrin-fetch-raw-string)
+ (lambda (_location callback)
+ (setq fetch-count (1+ fetch-count))
+ ;; Simulate fetch returning ANSI codes
+ (funcall callback test-wttrin-ansi-sample-with-colors)))
+ ((symbol-function 'wttrin--cleanup-cache-if-needed)
+ (lambda () nil)))
+
+ ;; Fetch weather (will cache the result)
+ (wttrin location)
+
+ ;; Verify fetch was called
+ (should (= fetch-count 1))
+
+ ;; Check what's in the cache
+ (let* ((cached (gethash cache-key wttrin--cache))
+ (cached-data (cdr cached)))
+
+ ;; Cache should have data
+ (should cached)
+ (should cached-data)
+
+ ;; CRITICAL: Cache should store RAW ANSI codes
+ ;; NOT filtered text with face properties
+ (should (stringp cached-data))
+ (should (string-match-p "\x1b\\[" cached-data))
+
+ ;; Verify it's the original ANSI string
+ (should (string-match-p "Partly cloudy" cached-data)))))
+ (test-wttrin-ansi-teardown)))
+
+(ert-deftest test-wttrin-ansi-fetch-returns-ansi-codes ()
+ "Test that wttrin-fetch-raw-string returns data with ANSI codes.
+This verifies the fetch function returns unfiltered data from wttr.in."
+ (test-wttrin-ansi-setup)
+ (unwind-protect
+ (let ((location "Prague")
+ (callback-data nil)
+ (url-retrieve-called nil)
+ (wttrin-unit-system "u")) ; Set unit system for URL generation
+
+ ;; Mock url-retrieve to simulate wttr.in response
+ (cl-letf (((symbol-function 'url-retrieve)
+ (lambda (url callback)
+ (setq url-retrieve-called t)
+ ;; Verify URL has ANSI format flag
+ ;; wttr.in uses ?mA, ?uA, or ?A for ANSI colored output
+ (should (or (string-match-p "\\?mA" url)
+ (string-match-p "\\?uA" url)
+ (string-match-p "\\?A" url)))
+ ;; Simulate HTTP response with ANSI codes
+ (with-temp-buffer
+ (insert "HTTP/1.1 200 OK\n\n")
+ (insert test-wttrin-ansi-sample-with-colors)
+ (funcall callback nil)))))
+
+ ;; Call fetch
+ (wttrin-fetch-raw-string
+ location
+ (lambda (data)
+ (setq callback-data data)))
+
+ ;; Verify url-retrieve was called
+ (should url-retrieve-called)
+
+ ;; Verify callback received ANSI codes
+ (should callback-data)
+ (should (string-match-p "\x1b\\[" callback-data))))
+ (test-wttrin-ansi-teardown)))
+
+(ert-deftest test-wttrin-ansi-build-url-includes-ansi-flag ()
+ "Test that wttrin--build-url includes ANSI color flag in URL.
+The 'A' flag tells wttr.in to include ANSI color codes in the response."
+ (test-wttrin-ansi-setup)
+ (unwind-protect
+ (progn
+ ;; Test with metric system
+ (let ((wttrin-unit-system "m"))
+ (let ((url (wttrin--build-url "Berlin")))
+ ;; Should have ?mA flag (metric + ANSI)
+ (should (string-match-p "\\?mA" url))))
+
+ ;; Test with USCS system
+ (let ((wttrin-unit-system "u"))
+ (let ((url (wttrin--build-url "London")))
+ ;; Should have ?uA flag (USCS + ANSI)
+ (should (string-match-p "\\?uA" url))))
+
+ ;; Test with no unit system
+ (let ((wttrin-unit-system nil))
+ (let ((url (wttrin--build-url "Paris")))
+ ;; Should have just ?A flag (ANSI)
+ (should (string-match-p "\\?A" url)))))
+ (test-wttrin-ansi-teardown)))
+
+(provide 'test-wttrin-ansi-color-rendering)
+;;; test-wttrin-ansi-color-rendering.el ends here
diff --git a/tests/test-wttrin-mode-initialization-order.el b/tests/test-wttrin-mode-initialization-order.el
new file mode 100644
index 0000000..20adf06
--- /dev/null
+++ b/tests/test-wttrin-mode-initialization-order.el
@@ -0,0 +1,98 @@
+;;; test-wttrin-mode-initialization-order.el --- Test mode initialization order -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025 Craig Jennings
+
+;;; Commentary:
+
+;; This test verifies that wttrin--display-weather initializes wttrin-mode
+;; BEFORE setting buffer-local variables. This prevents kill-all-local-variables
+;; (called by derived modes) from wiping out important state like xterm-color--state.
+;;
+;; Bug context: On fresh Emacs launch, weather displayed with no colors because
+;; xterm-color--state was set buffer-local BEFORE wttrin-mode was called, and
+;; wttrin-mode's kill-all-local-variables wiped it out.
+
+;;; Code:
+
+(require 'ert)
+(require 'wttrin)
+(require 'testutil-wttrin)
+
+(defun test-wttrin-mode-init-setup ()
+ "Setup for mode initialization tests."
+ (testutil-wttrin-setup)
+ (when (get-buffer "*wttr.in*")
+ (kill-buffer "*wttr.in*")))
+
+(defun test-wttrin-mode-init-teardown ()
+ "Teardown for mode initialization tests."
+ (testutil-wttrin-teardown)
+ (when (get-buffer "*wttr.in*")
+ (kill-buffer "*wttr.in*")))
+
+(ert-deftest test-wttrin-mode-initialization-order-mode-before-buffer-local-vars ()
+ "Test that wttrin-mode is activated before setting buffer-local variables.
+
+This test verifies the fix for the color rendering bug where xterm-color--state
+was wiped by kill-all-local-variables.
+
+The test strategy:
+1. Advise wttrin-mode to record when it's called
+2. Advise setq-local to record when buffer-local vars are set
+3. Call wttrin--display-weather
+4. Verify wttrin-mode was called BEFORE any buffer-local vars were set"
+ (test-wttrin-mode-init-setup)
+ (unwind-protect
+ (let ((mode-called-at nil)
+ (first-setq-local-at nil)
+ (call-counter 0))
+
+ ;; Advise to track when wttrin-mode is called
+ (cl-letf (((symbol-function 'wttrin-mode)
+ (let ((orig-fn (symbol-function 'wttrin-mode)))
+ (lambda ()
+ (setq mode-called-at (cl-incf call-counter))
+ (funcall orig-fn))))
+
+ ;; Advise to track first buffer-local variable set
+ ((symbol-function 'set)
+ (let ((orig-fn (symbol-function 'set)))
+ (lambda (symbol value)
+ ;; Track xterm-color--state specifically
+ (when (and (eq symbol 'xterm-color--state)
+ (null first-setq-local-at))
+ (setq first-setq-local-at (cl-incf call-counter)))
+ (funcall orig-fn symbol value)))))
+
+ (wttrin--display-weather "Paris" "Test weather data")
+
+ ;; Verify mode was called
+ (should mode-called-at)
+ ;; Verify buffer-local var was set
+ (should first-setq-local-at)
+ ;; Critical: mode must be called BEFORE buffer-local var
+ (should (< mode-called-at first-setq-local-at))))
+
+ (test-wttrin-mode-init-teardown)))
+
+(ert-deftest test-wttrin-mode-initialization-order-xterm-color-state-survives ()
+ "Test that xterm-color--state remains buffer-local after wttrin--display-weather.
+
+This verifies that the state isn't wiped by kill-all-local-variables."
+ (test-wttrin-mode-init-setup)
+ (unwind-protect
+ (progn
+ (wttrin--display-weather "London" "Test data")
+
+ (should (get-buffer "*wttr.in*"))
+
+ (with-current-buffer "*wttr.in*"
+ ;; xterm-color--state should be buffer-local
+ (should (local-variable-p 'xterm-color--state))
+ ;; It should have the correct value
+ (should (eq xterm-color--state :char))))
+
+ (test-wttrin-mode-init-teardown)))
+
+(provide 'test-wttrin-mode-initialization-order)
+;;; test-wttrin-mode-initialization-order.el ends here