From 8b461ecd3d4fae3be0a93523d19932e0a3d21c6c Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 2 Jul 2026 22:37:06 -0400 Subject: fix: persist runtime favorite and saved locations in a state file savehist rewrites its whole file on every save, keeping only the variables registered in the running process. Any Emacs that saved savehist without wttrin loaded (a batch tool, a session that never opened wttrin) silently deleted the persisted favorite and saved-location entries. A d-set default then reverted to the init value on the next restart. The runtime favorite and directory now live in wttrin-state-file, written only by wttrin: write-through on every location command, atomic temp-file + rename, UTF-8 pinned both ways, read with read (never eval), versioned plist. Values restore at load and re-assert on savehist-mode-hook, so a stale legacy savehist restore can't clobber them. With no state file present, legacy savehist values are adopted and written once, so existing users migrate with zero steps. Search history stays on savehist. It's scrub-tolerant throwaway data, exactly what savehist is for. Also: the README documents the mode-line tooltip forecast option, and the gitignore covers one more local tooling artifact. --- tests/test-wttrin-favorite-override.el | 13 +- tests/test-wttrin-make-default.el | 9 +- tests/test-wttrin-saved-locations.el | 9 +- tests/test-wttrin-state-file.el | 258 +++++++++++++++++++++++++++++++++ tests/testutil-wttrin.el | 19 ++- 5 files changed, 293 insertions(+), 15 deletions(-) create mode 100644 tests/test-wttrin-state-file.el (limited to 'tests') diff --git a/tests/test-wttrin-favorite-override.el b/tests/test-wttrin-favorite-override.el index 0236901..95916c7 100644 --- a/tests/test-wttrin-favorite-override.el +++ b/tests/test-wttrin-favorite-override.el @@ -64,15 +64,18 @@ with `d' reverted to the init value on restart." (should (equal "New Orleans, LA" wttrin-favorite-location)) (should (equal "Paris, FR" (wttrin--favorite-location))))) -;;; savehist registration targets the runtime vars, not the config defcustoms +;;; savehist registration covers only the scrub-tolerant search history; +;;; the runtime vars persist in `wttrin-state-file' (see test-wttrin-state-file.el) -(ert-deftest test-wttrin-favorite-override-normal-savehist-registers-override () - "Normal: savehist persists the override, not the config defcustom." +(ert-deftest test-wttrin-favorite-override-normal-savehist-excludes-runtime-vars () + "Normal: savehist persists neither the runtime vars nor the config defcustoms. +The runtime favorite and directory live in `wttrin-state-file' — a savehist +entry would be scrubbed by any session that saves savehist without wttrin." (require 'savehist) (let ((savehist-additional-variables '(kill-ring))) (wttrin--savehist-register) - (should (memq 'wttrin--favorite-override savehist-additional-variables)) - (should (memq 'wttrin--saved-locations-runtime savehist-additional-variables)) + (should-not (memq 'wttrin--favorite-override savehist-additional-variables)) + (should-not (memq 'wttrin--saved-locations-runtime savehist-additional-variables)) (should-not (memq 'wttrin-favorite-location savehist-additional-variables)) (should-not (memq 'wttrin-saved-locations savehist-additional-variables)))) diff --git a/tests/test-wttrin-make-default.el b/tests/test-wttrin-make-default.el index e824194..c7954c8 100644 --- a/tests/test-wttrin-make-default.el +++ b/tests/test-wttrin-make-default.el @@ -60,13 +60,14 @@ unbound); persistence is left to `wttrin--savehist-register'." (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 runtime override, not the -`wttrin-favorite-location' defcustom." +(ert-deftest test-wttrin-favorite-savehist-register-excludes-favorite () + "Normal: `wttrin--savehist-register' does not register the runtime override. +The favorite persists in `wttrin-state-file' instead — a savehist entry would +be scrubbed by any Emacs session that saves savehist without wttrin loaded." (require 'savehist) (let ((savehist-additional-variables '(kill-ring))) (wttrin--savehist-register) - (should (memq 'wttrin--favorite-override savehist-additional-variables)) + (should-not (memq 'wttrin--favorite-override savehist-additional-variables)) (should-not (memq 'wttrin-favorite-location savehist-additional-variables)))) ;;; -------------------------------------------------------------------------- diff --git a/tests/test-wttrin-saved-locations.el b/tests/test-wttrin-saved-locations.el index 1a08aaf..b41d7ee 100644 --- a/tests/test-wttrin-saved-locations.el +++ b/tests/test-wttrin-saved-locations.el @@ -159,13 +159,14 @@ Two names with the same query share a key; the name never leaks into the key." ;;; savehist -(ert-deftest test-wttrin-saved-locations-integration-savehist-registers () - "Integration: the runtime directory is registered for savehist persistence, -not the `wttrin-saved-locations' defcustom (which the user sets in init)." +(ert-deftest test-wttrin-saved-locations-integration-savehist-excludes-runtime () + "Integration: the runtime directory is not registered with savehist. +It persists in `wttrin-state-file' instead — a savehist entry would be +scrubbed by any Emacs session that saves savehist without wttrin loaded." (require 'savehist) (let ((savehist-additional-variables '(kill-ring))) (wttrin--savehist-register) - (should (memq 'wttrin--saved-locations-runtime savehist-additional-variables)) + (should-not (memq 'wttrin--saved-locations-runtime savehist-additional-variables)) (should-not (memq 'wttrin-saved-locations savehist-additional-variables)))) ;;; wttrin--coordinates-p diff --git a/tests/test-wttrin-state-file.el b/tests/test-wttrin-state-file.el new file mode 100644 index 0000000..5648a13 --- /dev/null +++ b/tests/test-wttrin-state-file.el @@ -0,0 +1,258 @@ +;;; test-wttrin-state-file.el --- Tests for state-file persistence -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;;; Commentary: + +;; Unit tests for the wttrin state file: `wttrin--state-save', +;; `wttrin--state-read', and `wttrin--state-load'. The state file persists +;; the runtime favorite (`wttrin--favorite-override') and runtime directory +;; (`wttrin--saved-locations-runtime') in a file only wttrin writes, so a +;; foreign Emacs process saving savehist without wttrin loaded can no longer +;; scrub them. Covers round-trips, authority over in-memory values, the +;; savehist-legacy adoption path, setter integration, and failure isolation. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'wttrin) + +(defmacro test-wttrin-state-file--with-sandbox (&rest body) + "Run BODY with `wttrin-state-file' bound to a fresh temp path. +The runtime vars start nil. The file is removed afterward." + (declare (indent 0)) + `(let ((wttrin-state-file (expand-file-name + (format "wttrin-test-state-%s.el" (random 1000000)) + temporary-file-directory)) + (wttrin--favorite-override nil) + (wttrin--saved-locations-runtime nil)) + (unwind-protect + (progn ,@body) + (when (file-exists-p wttrin-state-file) + (delete-file wttrin-state-file))))) + +;;; -------------------------------------------------------------------------- +;;; Normal Cases +;;; -------------------------------------------------------------------------- + +(ert-deftest test-wttrin-state-file-normal-save-load-round-trip () + "Normal: save writes both vars; load restores them after they are cleared." + (test-wttrin-state-file--with-sandbox + (setq wttrin--favorite-override "Hyatt Place Warwick, RI") + (setq wttrin--saved-locations-runtime + '(("Hyatt Place Warwick, RI" . "41.7266678,-71.443097"))) + (wttrin--state-save) + (setq wttrin--favorite-override nil) + (setq wttrin--saved-locations-runtime nil) + (wttrin--state-load) + (should (equal wttrin--favorite-override "Hyatt Place Warwick, RI")) + (should (equal wttrin--saved-locations-runtime + '(("Hyatt Place Warwick, RI" . "41.7266678,-71.443097")))))) + +(ert-deftest test-wttrin-state-file-normal-file-wins-over-memory () + "Normal: an existing state file is authoritative over in-memory values. +This is the guard against a later savehist restore of stale legacy lines." + (test-wttrin-state-file--with-sandbox + (setq wttrin--favorite-override "Newer, ME") + (wttrin--state-save) + (setq wttrin--favorite-override "Stale Legacy, LA") + (setq wttrin--saved-locations-runtime '(("Stale" . "stale"))) + (wttrin--state-load) + (should (equal wttrin--favorite-override "Newer, ME")) + (should (null wttrin--saved-locations-runtime)))) + +(ert-deftest test-wttrin-state-file-normal-set-favorite-writes-file () + "Normal: `wttrin--set-favorite-location' persists to the state file." + (test-wttrin-state-file--with-sandbox + (let ((wttrin-favorite-location nil) + (wttrin--location-history nil)) + (wttrin--set-favorite-location "Paris, FR") + (should (file-exists-p wttrin-state-file)) + (should (equal (plist-get (wttrin--state-read) :favorite-override) + "Paris, FR"))))) + +(ert-deftest test-wttrin-state-file-normal-put-saved-location-writes-file () + "Normal: `wttrin--put-saved-location' persists to the state file." + (test-wttrin-state-file--with-sandbox + (wttrin--put-saved-location "Home" "New Orleans, LA") + (should (equal (plist-get (wttrin--state-read) :saved-locations) + '(("Home" . "New Orleans, LA")))))) + +(ert-deftest test-wttrin-state-file-normal-remove-saved-location-writes-file () + "Normal: `wttrin--remove-saved-location' persists the removal." + (test-wttrin-state-file--with-sandbox + (wttrin--put-saved-location "Home" "New Orleans, LA") + (wttrin--remove-saved-location "Home") + (should (null (plist-get (wttrin--state-read) :saved-locations))))) + +(ert-deftest test-wttrin-state-file-normal-rename-location-writes-file () + "Normal: `wttrin-rename-location' persists the rename and favorite update." + (test-wttrin-state-file--with-sandbox + (let ((wttrin-saved-locations nil) + (wttrin-favorite-location nil) + (wttrin--location-history nil)) + (wttrin--put-saved-location "Hotel" "41.72,-71.44") + (wttrin--set-favorite-location "Hotel") + (wttrin-rename-location "Hotel" "Hyatt") + (let ((data (wttrin--state-read))) + (should (equal (plist-get data :saved-locations) + '(("Hyatt" . "41.72,-71.44")))) + (should (equal (plist-get data :favorite-override) "Hyatt")))))) + +(ert-deftest test-wttrin-state-file-normal-adopts-savehist-legacy-values () + "Normal: with no state file, non-nil vars (savehist legacy) are adopted." + (test-wttrin-state-file--with-sandbox + (setq wttrin--favorite-override "Legacy, TX") + (wttrin--state-load) + (should (file-exists-p wttrin-state-file)) + (should (equal (plist-get (wttrin--state-read) :favorite-override) + "Legacy, TX")))) + +(ert-deftest test-wttrin-state-file-normal-savehist-mode-hook-wired () + "Normal: `wttrin--state-load' is on `savehist-mode-hook' so a savehist +restore after wttrin loads cannot clobber state-file values." + (should (memq #'wttrin--state-load savehist-mode-hook))) + +;;; -------------------------------------------------------------------------- +;;; Boundary Cases +;;; -------------------------------------------------------------------------- + +(ert-deftest test-wttrin-state-file-boundary-tri-state-t-round-trips () + "Boundary: the favorite's auto-detect value t survives a round-trip." + (test-wttrin-state-file--with-sandbox + (setq wttrin--favorite-override t) + (wttrin--state-save) + (setq wttrin--favorite-override nil) + (wttrin--state-load) + (should (eq wttrin--favorite-override t)))) + +(ert-deftest test-wttrin-state-file-boundary-nils-round-trip () + "Boundary: an explicitly saved all-nil state loads as nils." + (test-wttrin-state-file--with-sandbox + (wttrin--state-save) + (setq wttrin--favorite-override "Ghost, AZ") + (setq wttrin--saved-locations-runtime '(("Ghost" . "ghost"))) + (wttrin--state-load) + (should (null wttrin--favorite-override)) + (should (null wttrin--saved-locations-runtime)))) + +(ert-deftest test-wttrin-state-file-boundary-long-directory-round-trips () + "Boundary: a long saved-locations alist survives intact. +Guards the `print-length' / `print-level' bindings in the writer." + (test-wttrin-state-file--with-sandbox + (let ((entries (cl-loop for i from 1 to 60 + collect (cons (format "Place %02d" i) + (format "%d.0,-%d.0" i i))))) + (setq wttrin--saved-locations-runtime entries) + (wttrin--state-save) + (setq wttrin--saved-locations-runtime nil) + (wttrin--state-load) + (should (equal wttrin--saved-locations-runtime entries)) + (should (= (length wttrin--saved-locations-runtime) 60))))) + +(ert-deftest test-wttrin-state-file-boundary-unicode-round-trips () + "Boundary: unicode names and queries survive a round-trip." + (test-wttrin-state-file--with-sandbox + (setq wttrin--favorite-override "Zürich 🌦️") + (setq wttrin--saved-locations-runtime '(("北京" . "Beijing, CN"))) + (wttrin--state-save) + (setq wttrin--favorite-override nil) + (setq wttrin--saved-locations-runtime nil) + (wttrin--state-load) + (should (equal wttrin--favorite-override "Zürich 🌦️")) + (should (equal wttrin--saved-locations-runtime '(("北京" . "Beijing, CN")))))) + +(ert-deftest test-wttrin-state-file-boundary-read-absent-file-returns-nil () + "Boundary: reading a nonexistent state file returns nil." + (test-wttrin-state-file--with-sandbox + (should-not (wttrin--state-read)))) + +(ert-deftest test-wttrin-state-file-boundary-absent-file-nil-vars-noop () + "Boundary: no file and nil vars is a silent no-op — no file created." + (test-wttrin-state-file--with-sandbox + (wttrin--state-load) + (should-not (file-exists-p wttrin-state-file)) + (should (null wttrin--favorite-override)) + (should (null wttrin--saved-locations-runtime)))) + +;;; -------------------------------------------------------------------------- +;;; Error Cases +;;; -------------------------------------------------------------------------- + +(ert-deftest test-wttrin-state-file-error-corrupt-file-does-not-signal () + "Error: unreadable lisp in the state file is ignored, vars untouched." + (test-wttrin-state-file--with-sandbox + (with-temp-file wttrin-state-file (insert "(((( not lisp")) + (setq wttrin--favorite-override "Kept, OK") + (wttrin--state-load) + (should (equal wttrin--favorite-override "Kept, OK")))) + +(ert-deftest test-wttrin-state-file-error-empty-file-does-not-signal () + "Error: an empty state file is ignored, vars untouched." + (test-wttrin-state-file--with-sandbox + (with-temp-file wttrin-state-file) + (setq wttrin--favorite-override "Kept, OK") + (wttrin--state-load) + (should (equal wttrin--favorite-override "Kept, OK")))) + +(ert-deftest test-wttrin-state-file-error-wrong-shape-data-ignored () + "Error: readable lisp that is not a versioned plist is treated as corrupt." + (test-wttrin-state-file--with-sandbox + (with-temp-file wttrin-state-file (insert "[1 2 3]")) + (setq wttrin--favorite-override "Kept, OK") + (wttrin--state-load) + (should (equal wttrin--favorite-override "Kept, OK")))) + +(ert-deftest test-wttrin-state-file-error-failed-write-preserves-file () + "Error: a failed write leaves the previous state file intact. +The writer goes through a temp file + rename, so an error before the +rename cannot truncate or clobber the existing file." + (test-wttrin-state-file--with-sandbox + (setq wttrin--favorite-override "Good, OK") + (wttrin--state-save) + (setq wttrin--favorite-override "Never Written, NV") + (cl-letf (((symbol-function 'write-region) + (lambda (&rest _) (error "Disk full")))) + (wttrin--state-save)) + (should (equal (plist-get (wttrin--state-read) :favorite-override) + "Good, OK")))) + +(ert-deftest test-wttrin-state-file-error-corrupt-file-survives-adoption () + "Error: adoption never writes over an existing corrupt state file. +The file is left byte-for-byte in place for inspection; non-nil vars do +not trigger the missing-file adoption write because the file exists." + (test-wttrin-state-file--with-sandbox + (with-temp-file wttrin-state-file (insert "(((( not lisp")) + (setq wttrin--favorite-override "Legacy, TX") + (wttrin--state-load) + (should (equal (with-temp-buffer + (insert-file-contents wttrin-state-file) + (buffer-string)) + "(((( not lisp")))) + +(ert-deftest test-wttrin-state-file-error-unwritable-path-does-not-signal () + "Error: a save to an unwritable path messages instead of signaling." + (let ((wttrin-state-file "/nonexistent-root-dir/wttrin/state.el") + (wttrin--favorite-override "Anywhere, US") + (wttrin--saved-locations-runtime nil)) + (should-not + (condition-case nil (progn (wttrin--state-save) nil) (error t))))) + +;;; -------------------------------------------------------------------------- +;;; savehist registration (post state-file) +;;; -------------------------------------------------------------------------- + +(ert-deftest test-wttrin-state-file-normal-savehist-excludes-state-vars () + "Normal: `wttrin--savehist-register' no longer registers the state-file +vars; only the scrub-tolerant search history stays with savehist." + (require 'savehist) + (let ((savehist-additional-variables '(kill-ring))) + (wttrin--savehist-register) + (should (memq 'wttrin--location-history savehist-additional-variables)) + (should-not (memq 'wttrin--favorite-override savehist-additional-variables)) + (should-not (memq 'wttrin--saved-locations-runtime + savehist-additional-variables)))) + +(provide 'test-wttrin-state-file) +;;; test-wttrin-state-file.el ends here diff --git a/tests/testutil-wttrin.el b/tests/testutil-wttrin.el index e4e2e4e..1373813 100644 --- a/tests/testutil-wttrin.el +++ b/tests/testutil-wttrin.el @@ -136,17 +136,32 @@ Other mocks BODY needs can be set in a nested `cl-letf'." ;;; Test Setup and Teardown +;; Redirect state-file writes for the whole batch: any test that exercises a +;; location setter triggers `wttrin--state-save', which must never touch the +;; developer's real `wttrin-state-file'. Process-wide, set once at load. +(setq wttrin-state-file + (expand-file-name (format "wttrin-test-state-%d.el" (emacs-pid)) + temporary-file-directory)) + (defun testutil-wttrin-setup () "Common setup for wttrin tests. Call this at the beginning of each test." (testutil-wttrin-clear-cache) - (setq wttrin--force-refresh nil)) + (setq wttrin--force-refresh nil) + (setq wttrin--favorite-override nil) + (setq wttrin--saved-locations-runtime nil) + (when (file-exists-p wttrin-state-file) + (delete-file wttrin-state-file))) (defun testutil-wttrin-teardown () "Common teardown for wttrin tests. Call this at the end of each test." (testutil-wttrin-clear-cache) - (setq wttrin--force-refresh nil)) + (setq wttrin--force-refresh nil) + (setq wttrin--favorite-override nil) + (setq wttrin--saved-locations-runtime nil) + (when (file-exists-p wttrin-state-file) + (delete-file wttrin-state-file))) (provide 'testutil-wttrin) ;;; testutil-wttrin.el ends here -- cgit v1.2.3