diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-31 11:04:42 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-31 11:04:42 -0500 |
| commit | 445a0be315f3d6fff585f1558262fb5dacd10d12 (patch) | |
| tree | ca12cae724f4516f30e3e0995436bb89fef6ed71 /tests | |
| parent | 37f21839ca62617fef07684b2f98268de47a71c2 (diff) | |
| download | dotemacs-445a0be315f3d6fff585f1558262fb5dacd10d12.tar.gz dotemacs-445a0be315f3d6fff585f1558262fb5dacd10d12.zip | |
feat(agenda): refresh the render cache on a timer, without the daemon
The surface reading this has to stay correct while Emacs is down, and
emacsclient is exactly the thing that cannot. So the writer is a batch
Emacs and a user timer runs it every five minutes.
- scripts/agenda-render-cache writes the cache from a batch Emacs. It
resolves the agenda file list through the config's own resolver rather
than restating it, so adding a calendar source stays a one-place change.
- The timer uses OnCalendar so Persistent actually applies. On a monotonic
schedule it is silently ignored, and a machine that slept would come back
to a stale file with nothing to trigger a catch-up.
- The window is now three days, yesterday through tomorrow. A consumer
drawing a rolling window centred on now has nothing to draw for the part
of its span outside today, which late in the evening is half the surface.
The keyword list moved to user-constants, where a batch Emacs can reach it
without this config's package dependencies. Without it org did not
recognise DOING or VERIFY, so it stopped parsing those headlines as tasks
at all: the keyword and priority cookie stayed glued to the front of every
title and each row reported no keyword and not-done. The file parsed
cleanly and was wrong, which is the failure worth guarding.
The bats tests run against a fixture and this checkout, not the machine's
real agenda and installed config. Asserting over live data let the same
suite pass on a day that happened to have no keyworded entries.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test-agenda-query--render.el | 64 | ||||
| -rw-r--r-- | tests/test-agenda-render-cache.bats | 131 |
2 files changed, 183 insertions, 12 deletions
diff --git a/tests/test-agenda-query--render.el b/tests/test-agenda-query--render.el index 1f7232f2..48d28773 100644 --- a/tests/test-agenda-query--render.el +++ b/tests/test-agenda-query--render.el @@ -26,6 +26,9 @@ (add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) (require 'agenda-query) +;; Supplies `cj/org-todo-keywords', the vocabulary the batch writer sets and +;; the editor's org config reads. +(require 'user-constants) (defun test-aq-render--epoch (min hour day month year) "Return the epoch for local MIN HOUR DAY MONTH YEAR." @@ -136,10 +139,43 @@ The renderer parses the same shape whether or not anything is scheduled." (let ((org-agenda-files nil)) (should (equal "[]" (cj/agenda-render-json 1785474000 1785477600))))) +;;; ---------- the batch reader has to know the keyword vocabulary ---------- + +(ert-deftest test-agenda-render-normal-custom-keyword-is-parsed () + "Normal: a config keyword is recognised, so it leaves the title. + +The batch writer runs without the editor's init, so it has to set +`org-todo-keywords' itself. When it does not, org stops parsing the headline +as a task at all: DOING and the priority cookie stay glued to the front of the +title and the row reads as having no keyword. That is a wrong answer that +still parses as JSON, which is the worst kind." + (let ((org-todo-keywords cj/org-todo-keywords)) + (test-aq-render--with-agenda-file + "* DOING [#A] Justin Johns advisor projects\nSCHEDULED: <2026-07-31 Fri 09:00>\n" + (let ((row (car rows))) + (should (equal "Justin Johns advisor projects" (alist-get 't row))) + (should (equal "DOING" (alist-get 'keyword row))))))) + +(ert-deftest test-agenda-render-boundary-unknown-keyword-stays-in-title () + "Boundary: with stock keywords the same headline degrades visibly. + +Pinning the failure mode, not endorsing it. This is what the surface showed +before the batch writer learned the vocabulary, and it is why the test above +exists." + (let ((org-todo-keywords '((sequence "TODO" "|" "DONE")))) + (test-aq-render--with-agenda-file + "* DOING [#A] Justin Johns advisor projects\nSCHEDULED: <2026-07-31 Fri 09:00>\n" + (should (string-prefix-p "DOING" (alist-get 't (car rows))))))) + ;;; ---------- the cache writer ---------- (ert-deftest test-agenda-render-normal-cache-update-writes-today () - "Normal: the cache writer covers the whole local day and creates its dir. + "Normal: the cache writer covers three whole local days and creates its dir. + +Yesterday's midnight through tomorrow's day close. A consumer drawing a +rolling window centred on now needs both sides of midnight: with a single +calendar day, the part of its span outside today has nothing to draw, which +late in the evening is half the surface. This is the function that actually ships the feature, so it gets its own coverage rather than riding on the tests for the profile beneath it. The @@ -161,19 +197,23 @@ or the next 24 from now, would still produce a plausible-looking file." (cj/agenda-render-cache-update))) (should (file-exists-p cj/agenda-render-cache-file)) (let* ((window (car windows)) - (d (decode-time (car window)))) + (start (decode-time (car window))) + (now (decode-time))) ;; Opens at local midnight... - (should (= 0 (nth 2 d))) - (should (= 0 (nth 1 d))) - (should (= 0 (nth 0 d))) - ;; ...and closes one second before the next. + (should (= 0 (nth 2 start))) + (should (= 0 (nth 1 start))) + (should (= 0 (nth 0 start))) + ;; ...on yesterday, and closes at the end of tomorrow. + (should (= (car window) + (cj/--agenda-query-epoch + 0 0 0 (1- (nth 3 now)) (nth 4 now) (nth 5 now)))) (should (= (cdr window) - (1- (cj/--agenda-query-epoch - 0 0 0 (1+ (nth 3 d)) (nth 4 d) (nth 5 d))))) - ;; It is today's day, not an arbitrary one. - (should (equal (list (nth 3 d) (nth 4 d) (nth 5 d)) - (let ((now (decode-time))) - (list (nth 3 now) (nth 4 now) (nth 5 now))))))) + (cj/--agenda-query-day-close + (1+ (nth 3 now)) (nth 4 now) (nth 5 now)))) + ;; Three whole days, give or take an hour at a DST changeover. + (let ((hours (/ (- (cdr window) (car window)) 3600.0))) + (should (>= hours 70.9)) + (should (<= hours 73.1))))) (delete-directory dir t)))) (ert-deftest test-agenda-render-boundary-cache-update-is-idempotent () diff --git a/tests/test-agenda-render-cache.bats b/tests/test-agenda-render-cache.bats new file mode 100644 index 00000000..3e949c16 --- /dev/null +++ b/tests/test-agenda-render-cache.bats @@ -0,0 +1,131 @@ +#!/usr/bin/env bats +# Tests for scripts/agenda-render-cache — the batch writer behind the timer. +# +# The elisp tests cover the query and the row shape. What only a shell test +# can cover is the thing that actually broke: the script runs a batch Emacs +# with -Q, so none of the editor's configuration is loaded, and anything it +# forgets to set up degrades silently rather than erroring. The first version +# wrote a file that parsed fine and was wrong, because org did not know DOING +# was a keyword and left it glued to the front of every title. +# +# Two isolation rules, both learned the hard way: +# +# EMACS_D points at THIS checkout, not $HOME/.emacs.d. Without it the script +# under test runs the installed config's elisp, so a broken tree passes. +# +# AGENDA_RENDER_FILES points at a fixture written here. Asserting over the +# machine's real agenda makes the result depend on what Craig happens to have +# scheduled today: the keyword assertion only bites if some entry carries a +# keyword, and on an empty agenda every all() assertion passes over an empty +# list. The fixture makes the failure mode reachable every run. + +setup() { + SCRIPT="${BATS_TEST_DIRNAME}/../scripts/agenda-render-cache" + export EMACS_D="${BATS_TEST_DIRNAME}/.." + export XDG_CACHE_HOME="${BATS_TEST_TMPDIR}/cache" + OUT="${XDG_CACHE_HOME}/settings/agenda.json" + + FIXTURE="${BATS_TEST_TMPDIR}/fixture.org" + TODAY="$(date +%Y-%m-%d)" + DOW="$(date +%a)" + { + printf '* DOING [#A] Advisor projects\nSCHEDULED: <%s %s 09:00>\n' "$TODAY" "$DOW" + printf '* TODO Standup\nSCHEDULED: <%s %s 10:00-10:15>\n' "$TODAY" "$DOW" + printf '* Lunch\n<%s %s 12:00-13:00>\n' "$TODAY" "$DOW" + printf '* VERIFY [#B] Check the render\nDEADLINE: <%s %s 17:00>\n' "$TODAY" "$DOW" + } > "$FIXTURE" + export AGENDA_RENDER_FILES="$FIXTURE" +} + +# Every assertion below runs through this, so an empty result can never pass +# vacuously — all() over an empty list is true, which is how a writer that +# produced nothing would look like a writer that produced correct rows. +assert_json() { + python3 -c " +import json, sys +rows = json.load(open(sys.argv[1])) +assert isinstance(rows, list), 'not a JSON array' +assert len(rows) == 4, 'expected 4 fixture rows, got %d' % len(rows) +$1 +" "$OUT" +} + +@test "the script is executable" { + [ -x "$SCRIPT" ] +} + +@test "writes a parseable JSON array and creates its directory" { + run "$SCRIPT" + [ "$status" -eq 0 ] + [ -f "$OUT" ] + run assert_json "pass" + [ "$status" -eq 0 ] +} + +@test "every row carries the three keys the renderer reads" { + run "$SCRIPT" + [ "$status" -eq 0 ] + run assert_json " +assert all({'s','e','t'} <= set(r) for r in rows), 'missing s/e/t' +assert all(isinstance(r['s'], int) and isinstance(r['e'], int) for r in rows), 'non-integer instant' +assert all(r['e'] >= r['s'] for r in rows), 'negative duration' +" + [ "$status" -eq 0 ] +} + +@test "instants are milliseconds, not seconds" { + run "$SCRIPT" + [ "$status" -eq 0 ] + # Seconds since 1970 are 10 digits; milliseconds are 13. + run assert_json "assert all(len(str(r['s'])) == 13 for r in rows), 'looks like seconds'" + [ "$status" -eq 0 ] +} + +@test "titles do not carry the TODO keyword vocabulary" { + run "$SCRIPT" + [ "$status" -eq 0 ] + # The regression this file exists for. The fixture carries DOING, TODO and + # VERIFY entries plus two priority cookies, so a script that has not set + # org-todo-keywords fails here every run rather than only on days when + # Craig happens to have such an entry. + run assert_json " +KEYWORDS = {'TODO','PROJECT','DOING','WAITING','VERIFY','STALLED', + 'DELEGATED','FAILED','DONE','CANCELLED'} +bad = [r['t'] for r in rows if r['t'].split(' ')[0] in KEYWORDS] +assert not bad, 'keyword left in title: %r' % bad +cookie = [r['t'] for r in rows if r['t'].startswith('[#')] +assert not cookie, 'priority cookie left in title: %r' % cookie +assert sorted(r['t'] for r in rows) == [ + 'Advisor projects', 'Check the render', 'Lunch', 'Standup'] +" + [ "$status" -eq 0 ] +} + +@test "keyword and completion state survive the batch environment" { + run "$SCRIPT" + [ "$status" -eq 0 ] + run assert_json " +by_title = {r['t']: r for r in rows} +assert by_title['Advisor projects']['keyword'] == 'DOING', by_title['Advisor projects'] +assert by_title['Check the render']['keyword'] == 'VERIFY' +assert by_title['Lunch']['keyword'] is None +assert all(r['done'] is False for r in rows) +assert by_title['Check the render']['type'] == 'deadline' +" + [ "$status" -eq 0 ] +} + +@test "replaces an existing file rather than appending" { + mkdir -p "$(dirname "$OUT")" + printf 'stale garbage that is not json at all\n' > "$OUT" + run "$SCRIPT" + [ "$status" -eq 0 ] + run assert_json "pass" + [ "$status" -eq 0 ] +} + +@test "fails loudly when the config directory is missing" { + run env EMACS_D="${BATS_TEST_TMPDIR}/nonexistent" "$SCRIPT" + [ "$status" -ne 0 ] + [[ "$output" == *"no modules directory"* ]] +} |
