aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-30 13:27:29 -0500
committerCraig Jennings <c@cjennings.net>2026-05-30 13:27:29 -0500
commita6313954fc297ee4a6c1c42ba903730a364cd5df (patch)
treef55cc085d966684253c6e7daaeee27593ca08801
parent0234e52b727b34ade93961eb05b5638685f4406f (diff)
downloadrulesets-a6313954fc297ee4a6c1c42ba903730a364cd5df.tar.gz
rulesets-a6313954fc297ee4a6c1c42ba903730a364cd5df.zip
test(scripts): cover drill-to-anki internals, broadcast, and daily-prep
I backfilled the gaps left after the flashcard work landed. drill-to-anki.py had tests only for its two default helpers. I added coverage for the core parser and its pieces: parse (section-to-tag mapping, drawer-only body, blank trimming, multiline join, no-card input), strip_org_metadata (drawer and planning-line stripping, unclosed drawer), section_to_tag, escape_html, and the deterministic stable_id. I also filled the remaining drill-deck-stats / drill-deck-diff-ids branches (missing-title and PROPERTIES-mismatch warnings, the appeared-IDs note path). I added test_cross_project_broadcast.py for the two scripts that had none here: is_broadcastable / discover (SEARCH_ROOTS pointed at a tmp tree) / sender_project / inbox_send_path, plus an ERT suite for daily-prep-agenda.el (dp-iso-date, dp-bucket with the clock pinned, dp-format-entry, and dp-collect end to end on a temp org file). daily-prep-agenda.el needed one change to be loadable under ERT: its batch entrypoint fired on any load. I gated it behind dp--cli-invocation-p, the same readable-files check lint-org.el already uses, so requiring the file for tests no longer runs the extractor. A real invocation with a file argument still fires. A no-argument run now no-ops instead of printing an empty header.
-rw-r--r--.ai/scripts/daily-prep-agenda.el9
-rw-r--r--.ai/scripts/tests/test-daily-prep-agenda.el106
-rw-r--r--.ai/scripts/tests/test_cross_project_broadcast.py116
-rw-r--r--.ai/scripts/tests/test_drill_deck_diff_ids.py21
-rw-r--r--.ai/scripts/tests/test_drill_deck_stats.py38
-rw-r--r--.ai/scripts/tests/test_drill_to_anki.py122
-rw-r--r--claude-templates/.ai/scripts/daily-prep-agenda.el9
-rw-r--r--claude-templates/.ai/scripts/tests/test-daily-prep-agenda.el106
-rw-r--r--claude-templates/.ai/scripts/tests/test_cross_project_broadcast.py116
-rw-r--r--claude-templates/.ai/scripts/tests/test_drill_deck_diff_ids.py21
-rw-r--r--claude-templates/.ai/scripts/tests/test_drill_deck_stats.py38
-rw-r--r--claude-templates/.ai/scripts/tests/test_drill_to_anki.py122
12 files changed, 822 insertions, 2 deletions
diff --git a/.ai/scripts/daily-prep-agenda.el b/.ai/scripts/daily-prep-agenda.el
index 4c6041c..5d6e971 100644
--- a/.ai/scripts/daily-prep-agenda.el
+++ b/.ai/scripts/daily-prep-agenda.el
@@ -118,8 +118,15 @@
(dolist (e entries)
(princ (dp-format-entry e)))))
+(defun dp--cli-invocation-p ()
+ "Non-nil when the trailing args look like a real invocation (readable files).
+Keeps the batch entrypoint from firing when this file is loaded under ERT,
+where the trailing args are ERT's own flags rather than org files."
+ (and command-line-args-left
+ (cl-every #'file-readable-p command-line-args-left)))
+
;; Main entrypoint
-(when noninteractive
+(when (and noninteractive (dp--cli-invocation-p))
(let* ((files command-line-args-left)
(entries (dp-collect files))
(groups (seq-group-by #'dp-bucket entries)))
diff --git a/.ai/scripts/tests/test-daily-prep-agenda.el b/.ai/scripts/tests/test-daily-prep-agenda.el
new file mode 100644
index 0000000..c7f1683
--- /dev/null
+++ b/.ai/scripts/tests/test-daily-prep-agenda.el
@@ -0,0 +1,106 @@
+;;; test-daily-prep-agenda.el --- ERT tests for daily-prep-agenda -*- lexical-binding: t; -*-
+
+;; Run: emacs --batch -q -L .ai/scripts -l ert -l test-daily-prep-agenda.el \
+;; -f ert-run-tests-batch-and-exit
+
+(require 'ert)
+(require 'cl-lib)
+(require 'daily-prep-agenda)
+
+;;; dp-iso-date --------------------------------------------------------------
+
+(ert-deftest dp-iso-date-extracts-from-active-timestamp ()
+ (should (equal (dp-iso-date "<2026-04-25 Sat 16:00>") "2026-04-25")))
+
+(ert-deftest dp-iso-date-extracts-from-inactive-timestamp ()
+ (should (equal (dp-iso-date "[2026-05-29 Fri]") "2026-05-29")))
+
+(ert-deftest dp-iso-date-no-date-returns-nil ()
+ (should (null (dp-iso-date "no date in here"))))
+
+(ert-deftest dp-iso-date-nil-input-returns-nil ()
+ (should (null (dp-iso-date nil))))
+
+;;; dp-bucket --------------------------------------------------------------
+;; dp-today / dp-week-end are dynamic; pin them so bucketing is deterministic.
+
+(defmacro dp-test--with-clock (&rest body)
+ `(let ((dp-today "2026-05-15")
+ (dp-week-end "2026-05-22"))
+ ,@body))
+
+(ert-deftest dp-bucket-deadline-before-today-is-overdue ()
+ (dp-test--with-clock
+ (should (eq (dp-bucket '(:deadline "2026-05-10")) 'overdue))))
+
+(ert-deftest dp-bucket-deadline-today-is-today ()
+ (dp-test--with-clock
+ (should (eq (dp-bucket '(:deadline "2026-05-15")) 'today))))
+
+(ert-deftest dp-bucket-scheduled-today-is-today ()
+ (dp-test--with-clock
+ (should (eq (dp-bucket '(:scheduled "2026-05-15")) 'today))))
+
+(ert-deftest dp-bucket-scheduled-before-today-is-overdue ()
+ (dp-test--with-clock
+ (should (eq (dp-bucket '(:scheduled "2026-05-12")) 'overdue))))
+
+(ert-deftest dp-bucket-deadline-within-week-is-this-week ()
+ (dp-test--with-clock
+ (should (eq (dp-bucket '(:deadline "2026-05-20")) 'this-week))))
+
+(ert-deftest dp-bucket-priority-a-undated-is-pri-a ()
+ (dp-test--with-clock
+ (should (eq (dp-bucket '(:priority ?A)) 'pri-a))))
+
+(ert-deftest dp-bucket-priority-b-undated-is-pri-b ()
+ (dp-test--with-clock
+ (should (eq (dp-bucket '(:priority ?B)) 'pri-b))))
+
+(ert-deftest dp-bucket-no-date-no-priority-is-other ()
+ (dp-test--with-clock
+ (should (eq (dp-bucket '(:heading "x")) 'other))))
+
+;;; dp-format-entry --------------------------------------------------------
+
+(ert-deftest dp-format-entry-renders-heading-loc-deadline-body ()
+ (let ((out (dp-format-entry
+ '(:state "TODO" :priority ?A :heading "Ship it"
+ :file "/x/todo.org" :line 42
+ :deadline-raw "<2026-05-20 Wed>" :scheduled-raw nil
+ :body "do the thing"))))
+ (should (string-match-p "\\*\\* TODO \\[#A\\] Ship it" out))
+ (should (string-match-p ":LOC: todo.org:42" out))
+ (should (string-match-p "DEADLINE: <2026-05-20 Wed>" out))
+ (should (string-match-p " do the thing" out))))
+
+(ert-deftest dp-format-entry-omits-priority-cookie-when-absent ()
+ (let ((out (dp-format-entry
+ '(:state "TODO" :heading "No priority" :file "f.org" :line 1))))
+ (should (string-match-p "\\*\\* TODO No priority" out))
+ (should-not (string-match-p "\\[#" out))))
+
+;;; dp-collect (exercises dp-active-candidate-p + dp-entry-info) ------------
+
+(ert-deftest dp-collect-picks-active-priority-and-dated-entries ()
+ (let ((tmp (make-temp-file
+ "dp-test" nil ".org"
+ (concat
+ "* TODO [#A] urgent thing\n"
+ "* TODO [#C] low prio no date\n"
+ "* TODO scheduled thing\n"
+ "SCHEDULED: <2026-05-20 Wed>\n"
+ "* DONE [#A] finished\n"))))
+ (unwind-protect
+ (let ((headings (mapcar (lambda (e) (plist-get e :heading))
+ (dp-collect (list tmp)))))
+ ;; [#A]-active and the SCHEDULED entry qualify;
+ ;; the undated [#C] and the DONE entry do not.
+ (should (member "urgent thing" headings))
+ (should (member "scheduled thing" headings))
+ (should-not (member "low prio no date" headings))
+ (should-not (member "finished" headings)))
+ (delete-file tmp))))
+
+(provide 'test-daily-prep-agenda)
+;;; test-daily-prep-agenda.el ends here
diff --git a/.ai/scripts/tests/test_cross_project_broadcast.py b/.ai/scripts/tests/test_cross_project_broadcast.py
new file mode 100644
index 0000000..5919fbf
--- /dev/null
+++ b/.ai/scripts/tests/test_cross_project_broadcast.py
@@ -0,0 +1,116 @@
+"""Tests for cross-project-broadcast.py: project fingerprinting + discovery.
+
+Plain python3 script. The pure-ish helpers are driven against tmp project
+trees; discovery is exercised with SEARCH_ROOTS monkeypatched to the tree, and
+the cwd-based helpers with monkeypatch.chdir.
+"""
+from __future__ import annotations
+
+import importlib.util
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).resolve().parents[1] / "cross-project-broadcast.py"
+
+
+@pytest.fixture(scope="module")
+def bcast():
+ spec = importlib.util.spec_from_file_location("cross_project_broadcast", SCRIPT)
+ assert spec and spec.loader
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def _make_project(root: Path, name: str, with_inbox: bool = True,
+ with_protocols: bool = True) -> Path:
+ p = root / name
+ (p / ".ai").mkdir(parents=True)
+ if with_protocols:
+ (p / ".ai" / "protocols.org").write_text("#+TITLE: protocols\n")
+ if with_inbox:
+ (p / "inbox").mkdir()
+ return p
+
+
+# --- is_broadcastable ---
+
+def test_is_broadcastable_true_with_protocols_and_inbox(bcast, tmp_path):
+ assert bcast.is_broadcastable(_make_project(tmp_path, "proj")) is True
+
+
+def test_is_broadcastable_false_without_inbox(bcast, tmp_path):
+ p = _make_project(tmp_path, "proj", with_inbox=False)
+ assert bcast.is_broadcastable(p) is False
+
+
+def test_is_broadcastable_false_without_protocols(bcast, tmp_path):
+ p = _make_project(tmp_path, "proj", with_protocols=False)
+ assert bcast.is_broadcastable(p) is False
+
+
+def test_is_broadcastable_false_on_plain_dir(bcast, tmp_path):
+ assert bcast.is_broadcastable(tmp_path) is False
+
+
+# --- discover (SEARCH_ROOTS monkeypatched onto the tmp tree) ---
+
+def test_discover_finds_broadcastable_subprojects(bcast, tmp_path, monkeypatch):
+ root = tmp_path / "code"
+ root.mkdir()
+ _make_project(root, "alpha")
+ _make_project(root, "beta")
+ _make_project(root, "no-inbox", with_inbox=False) # not broadcastable
+ monkeypatch.setattr(bcast, "SEARCH_ROOTS", [root])
+ assert [p.name for p in bcast.discover()] == ["alpha", "beta"]
+
+
+def test_discover_handles_root_that_is_itself_a_project(bcast, tmp_path, monkeypatch):
+ root = _make_project(tmp_path, ".emacs.d")
+ monkeypatch.setattr(bcast, "SEARCH_ROOTS", [root])
+ assert [p.name for p in bcast.discover()] == [".emacs.d"]
+
+
+def test_discover_dedups_by_basename_across_roots(bcast, tmp_path, monkeypatch):
+ root1 = tmp_path / "code"
+ root1.mkdir()
+ root2 = tmp_path / "projects"
+ root2.mkdir()
+ _make_project(root1, "dup")
+ _make_project(root2, "dup")
+ monkeypatch.setattr(bcast, "SEARCH_ROOTS", [root1, root2])
+ assert [p.name for p in bcast.discover()] == ["dup"]
+
+
+def test_discover_skips_missing_roots(bcast, tmp_path, monkeypatch):
+ monkeypatch.setattr(bcast, "SEARCH_ROOTS", [tmp_path / "does-not-exist"])
+ assert bcast.discover() == []
+
+
+# --- sender_project / inbox_send_path (cwd-based) ---
+
+def test_sender_project_returns_basename_inside_an_ai_project(bcast, tmp_path, monkeypatch):
+ p = _make_project(tmp_path, "myproj")
+ monkeypatch.chdir(p)
+ assert bcast.sender_project() == "myproj"
+
+
+def test_sender_project_none_outside_an_ai_project(bcast, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ assert bcast.sender_project() is None
+
+
+def test_inbox_send_path_found_in_project(bcast, tmp_path, monkeypatch):
+ p = _make_project(tmp_path, "myproj")
+ (p / ".ai" / "scripts").mkdir()
+ helper = p / ".ai" / "scripts" / "inbox-send.py"
+ helper.write_text("# stub\n")
+ monkeypatch.chdir(p)
+ assert bcast.inbox_send_path() == helper
+
+
+def test_inbox_send_path_raises_when_missing(bcast, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ with pytest.raises(SystemExit):
+ bcast.inbox_send_path()
diff --git a/.ai/scripts/tests/test_drill_deck_diff_ids.py b/.ai/scripts/tests/test_drill_deck_diff_ids.py
index 9cd8305..15fb148 100644
--- a/.ai/scripts/tests/test_drill_deck_diff_ids.py
+++ b/.ai/scripts/tests/test_drill_deck_diff_ids.py
@@ -86,3 +86,24 @@ def test_cli_dropped_id_warns_and_exits_one(tmp_path):
assert r.returncode == 1
assert "disappeared" in r.stdout.lower()
assert "id-2" in r.stdout
+
+
+DECK_ONE = """* Section
+** What is DeepSat? :drill:
+:PROPERTIES:
+:ID: id-1
+:END:
+Body.
+"""
+
+
+def test_cli_appeared_only_notes_new_ids_and_exits_one(tmp_path):
+ # before has id-1; after adds id-2 and drops nothing.
+ before = tmp_path / "before.org"
+ before.write_text(DECK_ONE)
+ after = tmp_path / "after.org"
+ after.write_text(DECK_A)
+ r = _run(before, after)
+ assert r.returncode == 1
+ assert "appeared" in r.stdout.lower()
+ assert "id-2" in r.stdout
diff --git a/.ai/scripts/tests/test_drill_deck_stats.py b/.ai/scripts/tests/test_drill_deck_stats.py
index 02d9c4e..3154d42 100644
--- a/.ai/scripts/tests/test_drill_deck_stats.py
+++ b/.ai/scripts/tests/test_drill_deck_stats.py
@@ -94,3 +94,41 @@ def test_cli_dirty_deck_warns_and_exits_one(tmp_path):
def test_cli_missing_file_exits_two(tmp_path):
r = _run(tmp_path / "nope.org")
assert r.returncode == 2
+
+
+NO_TITLE_DECK = """* Section
+** What is DeepSat? :drill:
+:PROPERTIES:
+:ID: card-1
+:END:
+A satellite company.
+"""
+
+# Two cards, only one PROPERTIES drawer.
+PROP_MISMATCH_DECK = """#+TITLE: DeepSat Flashcards
+
+* Section
+** What is DeepSat? :drill:
+A satellite company.
+** Who founded it? :drill:
+:PROPERTIES:
+:ID: card-2
+:END:
+The team.
+"""
+
+
+def test_cli_missing_title_warns_and_exits_one(tmp_path):
+ f = tmp_path / "notitle.org"
+ f.write_text(NO_TITLE_DECK)
+ r = _run(f)
+ assert r.returncode == 1
+ assert "no #+TITLE" in r.stdout
+
+
+def test_cli_properties_count_mismatch_warns_and_exits_one(tmp_path):
+ f = tmp_path / "mismatch.org"
+ f.write_text(PROP_MISMATCH_DECK)
+ r = _run(f)
+ assert r.returncode == 1
+ assert "does not match card count" in r.stdout
diff --git a/.ai/scripts/tests/test_drill_to_anki.py b/.ai/scripts/tests/test_drill_to_anki.py
index 6490e58..6c5ef9b 100644
--- a/.ai/scripts/tests/test_drill_to_anki.py
+++ b/.ai/scripts/tests/test_drill_to_anki.py
@@ -42,3 +42,125 @@ def test_default_deck_name_is_raw_basename(drill):
def test_default_deck_name_keeps_hyphens(drill):
"""A hyphenated basename is kept verbatim rather than title-cased."""
assert drill.default_deck_name(Path("/x/health-drill.org")) == "health-drill"
+
+
+# --- section_to_tag (pure) ---
+
+def test_section_to_tag_slugifies_words(drill):
+ assert drill.section_to_tag("Orbital Regimes") == "orbital-regimes"
+
+
+def test_section_to_tag_strips_leading_and_trailing_nonalnum(drill):
+ assert drill.section_to_tag(" People & Roles! ") == "people-roles"
+
+
+def test_section_to_tag_empty_string(drill):
+ assert drill.section_to_tag("") == ""
+
+
+# --- escape_html (pure) ---
+
+def test_escape_html_escapes_amp_lt_gt(drill):
+ assert drill.escape_html("a & b < c > d") == "a &amp; b &lt; c &gt; d"
+
+
+def test_escape_html_plain_text_unchanged(drill):
+ assert drill.escape_html("plain text") == "plain text"
+
+
+def test_escape_html_escapes_amp_first_so_existing_entity_is_literal(drill):
+ # & is replaced before < / >, so a literal "&lt;" becomes "&amp;lt;",
+ # not silently treated as an already-escaped entity.
+ assert drill.escape_html("&lt;") == "&amp;lt;"
+
+
+def test_escape_html_empty_string(drill):
+ assert drill.escape_html("") == ""
+
+
+# --- stable_id (pure) ---
+
+def test_stable_id_is_deterministic(drill):
+ assert drill.stable_id("DeepSat", "deck") == drill.stable_id("DeepSat", "deck")
+
+
+def test_stable_id_salt_changes_the_result(drill):
+ assert drill.stable_id("DeepSat", "deck") != drill.stable_id("DeepSat", "model")
+
+
+def test_stable_id_stays_within_the_reserved_range(drill):
+ value = drill.stable_id("anything", "deck")
+ assert drill.ID_BASE <= value < drill.ID_BASE + drill.ID_RANGE
+
+
+# --- strip_org_metadata (pure) ---
+
+def test_strip_org_metadata_drops_properties_drawer(drill):
+ body = [":PROPERTIES:", ":ID: x", ":END:", "real content"]
+ assert drill.strip_org_metadata(body) == ["real content"]
+
+
+def test_strip_org_metadata_drops_planning_lines(drill):
+ body = ["SCHEDULED: <2026-05-30>", "DEADLINE: <2026-06-01>",
+ "CLOSED: [2026-05-29]", "body"]
+ assert drill.strip_org_metadata(body) == ["body"]
+
+
+def test_strip_org_metadata_leaves_plain_body_unchanged(drill):
+ body = ["line one", "line two"]
+ assert drill.strip_org_metadata(body) == ["line one", "line two"]
+
+
+def test_strip_org_metadata_empty_list(drill):
+ assert drill.strip_org_metadata([]) == []
+
+
+def test_strip_org_metadata_unclosed_drawer_swallows_the_rest(drill):
+ # An unterminated :PROPERTIES: drawer consumes everything after it.
+ body = [":PROPERTIES:", ":ID: x", "still in drawer"]
+ assert drill.strip_org_metadata(body) == []
+
+
+# --- parse (pure, core parser) ---
+
+SECTIONED = """* Orbital Regimes
+** What is LEO? :drill:
+Low Earth Orbit.
+** What is GEO? :drill:
+Geostationary Earth Orbit.
+"""
+
+
+def test_parse_returns_front_back_tag_per_card(drill):
+ cards = drill.parse(SECTIONED)
+ assert len(cards) == 2
+ assert cards[0] == ("What is LEO?", "Low Earth Orbit.", "orbital-regimes")
+ assert cards[1][0] == "What is GEO?"
+
+
+def test_parse_card_without_a_section_gets_the_drill_tag(drill):
+ assert drill.parse("** Lone card? :drill:\nbody\n") == [("Lone card?", "body", "drill")]
+
+
+def test_parse_strips_properties_drawer_from_back(drill):
+ text = "** Q? :drill:\n:PROPERTIES:\n:ID: abc\n:END:\nThe answer.\n"
+ assert drill.parse(text) == [("Q?", "The answer.", "drill")]
+
+
+def test_parse_trims_leading_and_trailing_blank_body_lines(drill):
+ cards = drill.parse("** Q? :drill:\n\n\nanswer\n\n\n")
+ assert cards[0][1] == "answer"
+
+
+def test_parse_card_with_only_a_drawer_has_empty_back(drill):
+ text = "** Q? :drill:\n:PROPERTIES:\n:ID: x\n:END:\n"
+ assert drill.parse(text) == [("Q?", "", "drill")]
+
+
+def test_parse_joins_multiline_body_with_br(drill):
+ cards = drill.parse("** Q? :drill:\nline one\nline two\n")
+ assert cards[0][1] == "line one<br>line two"
+
+
+def test_parse_no_drill_cards_returns_empty(drill):
+ assert drill.parse("* Section\nno drill cards here\n") == []
diff --git a/claude-templates/.ai/scripts/daily-prep-agenda.el b/claude-templates/.ai/scripts/daily-prep-agenda.el
index 4c6041c..5d6e971 100644
--- a/claude-templates/.ai/scripts/daily-prep-agenda.el
+++ b/claude-templates/.ai/scripts/daily-prep-agenda.el
@@ -118,8 +118,15 @@
(dolist (e entries)
(princ (dp-format-entry e)))))
+(defun dp--cli-invocation-p ()
+ "Non-nil when the trailing args look like a real invocation (readable files).
+Keeps the batch entrypoint from firing when this file is loaded under ERT,
+where the trailing args are ERT's own flags rather than org files."
+ (and command-line-args-left
+ (cl-every #'file-readable-p command-line-args-left)))
+
;; Main entrypoint
-(when noninteractive
+(when (and noninteractive (dp--cli-invocation-p))
(let* ((files command-line-args-left)
(entries (dp-collect files))
(groups (seq-group-by #'dp-bucket entries)))
diff --git a/claude-templates/.ai/scripts/tests/test-daily-prep-agenda.el b/claude-templates/.ai/scripts/tests/test-daily-prep-agenda.el
new file mode 100644
index 0000000..c7f1683
--- /dev/null
+++ b/claude-templates/.ai/scripts/tests/test-daily-prep-agenda.el
@@ -0,0 +1,106 @@
+;;; test-daily-prep-agenda.el --- ERT tests for daily-prep-agenda -*- lexical-binding: t; -*-
+
+;; Run: emacs --batch -q -L .ai/scripts -l ert -l test-daily-prep-agenda.el \
+;; -f ert-run-tests-batch-and-exit
+
+(require 'ert)
+(require 'cl-lib)
+(require 'daily-prep-agenda)
+
+;;; dp-iso-date --------------------------------------------------------------
+
+(ert-deftest dp-iso-date-extracts-from-active-timestamp ()
+ (should (equal (dp-iso-date "<2026-04-25 Sat 16:00>") "2026-04-25")))
+
+(ert-deftest dp-iso-date-extracts-from-inactive-timestamp ()
+ (should (equal (dp-iso-date "[2026-05-29 Fri]") "2026-05-29")))
+
+(ert-deftest dp-iso-date-no-date-returns-nil ()
+ (should (null (dp-iso-date "no date in here"))))
+
+(ert-deftest dp-iso-date-nil-input-returns-nil ()
+ (should (null (dp-iso-date nil))))
+
+;;; dp-bucket --------------------------------------------------------------
+;; dp-today / dp-week-end are dynamic; pin them so bucketing is deterministic.
+
+(defmacro dp-test--with-clock (&rest body)
+ `(let ((dp-today "2026-05-15")
+ (dp-week-end "2026-05-22"))
+ ,@body))
+
+(ert-deftest dp-bucket-deadline-before-today-is-overdue ()
+ (dp-test--with-clock
+ (should (eq (dp-bucket '(:deadline "2026-05-10")) 'overdue))))
+
+(ert-deftest dp-bucket-deadline-today-is-today ()
+ (dp-test--with-clock
+ (should (eq (dp-bucket '(:deadline "2026-05-15")) 'today))))
+
+(ert-deftest dp-bucket-scheduled-today-is-today ()
+ (dp-test--with-clock
+ (should (eq (dp-bucket '(:scheduled "2026-05-15")) 'today))))
+
+(ert-deftest dp-bucket-scheduled-before-today-is-overdue ()
+ (dp-test--with-clock
+ (should (eq (dp-bucket '(:scheduled "2026-05-12")) 'overdue))))
+
+(ert-deftest dp-bucket-deadline-within-week-is-this-week ()
+ (dp-test--with-clock
+ (should (eq (dp-bucket '(:deadline "2026-05-20")) 'this-week))))
+
+(ert-deftest dp-bucket-priority-a-undated-is-pri-a ()
+ (dp-test--with-clock
+ (should (eq (dp-bucket '(:priority ?A)) 'pri-a))))
+
+(ert-deftest dp-bucket-priority-b-undated-is-pri-b ()
+ (dp-test--with-clock
+ (should (eq (dp-bucket '(:priority ?B)) 'pri-b))))
+
+(ert-deftest dp-bucket-no-date-no-priority-is-other ()
+ (dp-test--with-clock
+ (should (eq (dp-bucket '(:heading "x")) 'other))))
+
+;;; dp-format-entry --------------------------------------------------------
+
+(ert-deftest dp-format-entry-renders-heading-loc-deadline-body ()
+ (let ((out (dp-format-entry
+ '(:state "TODO" :priority ?A :heading "Ship it"
+ :file "/x/todo.org" :line 42
+ :deadline-raw "<2026-05-20 Wed>" :scheduled-raw nil
+ :body "do the thing"))))
+ (should (string-match-p "\\*\\* TODO \\[#A\\] Ship it" out))
+ (should (string-match-p ":LOC: todo.org:42" out))
+ (should (string-match-p "DEADLINE: <2026-05-20 Wed>" out))
+ (should (string-match-p " do the thing" out))))
+
+(ert-deftest dp-format-entry-omits-priority-cookie-when-absent ()
+ (let ((out (dp-format-entry
+ '(:state "TODO" :heading "No priority" :file "f.org" :line 1))))
+ (should (string-match-p "\\*\\* TODO No priority" out))
+ (should-not (string-match-p "\\[#" out))))
+
+;;; dp-collect (exercises dp-active-candidate-p + dp-entry-info) ------------
+
+(ert-deftest dp-collect-picks-active-priority-and-dated-entries ()
+ (let ((tmp (make-temp-file
+ "dp-test" nil ".org"
+ (concat
+ "* TODO [#A] urgent thing\n"
+ "* TODO [#C] low prio no date\n"
+ "* TODO scheduled thing\n"
+ "SCHEDULED: <2026-05-20 Wed>\n"
+ "* DONE [#A] finished\n"))))
+ (unwind-protect
+ (let ((headings (mapcar (lambda (e) (plist-get e :heading))
+ (dp-collect (list tmp)))))
+ ;; [#A]-active and the SCHEDULED entry qualify;
+ ;; the undated [#C] and the DONE entry do not.
+ (should (member "urgent thing" headings))
+ (should (member "scheduled thing" headings))
+ (should-not (member "low prio no date" headings))
+ (should-not (member "finished" headings)))
+ (delete-file tmp))))
+
+(provide 'test-daily-prep-agenda)
+;;; test-daily-prep-agenda.el ends here
diff --git a/claude-templates/.ai/scripts/tests/test_cross_project_broadcast.py b/claude-templates/.ai/scripts/tests/test_cross_project_broadcast.py
new file mode 100644
index 0000000..5919fbf
--- /dev/null
+++ b/claude-templates/.ai/scripts/tests/test_cross_project_broadcast.py
@@ -0,0 +1,116 @@
+"""Tests for cross-project-broadcast.py: project fingerprinting + discovery.
+
+Plain python3 script. The pure-ish helpers are driven against tmp project
+trees; discovery is exercised with SEARCH_ROOTS monkeypatched to the tree, and
+the cwd-based helpers with monkeypatch.chdir.
+"""
+from __future__ import annotations
+
+import importlib.util
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).resolve().parents[1] / "cross-project-broadcast.py"
+
+
+@pytest.fixture(scope="module")
+def bcast():
+ spec = importlib.util.spec_from_file_location("cross_project_broadcast", SCRIPT)
+ assert spec and spec.loader
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def _make_project(root: Path, name: str, with_inbox: bool = True,
+ with_protocols: bool = True) -> Path:
+ p = root / name
+ (p / ".ai").mkdir(parents=True)
+ if with_protocols:
+ (p / ".ai" / "protocols.org").write_text("#+TITLE: protocols\n")
+ if with_inbox:
+ (p / "inbox").mkdir()
+ return p
+
+
+# --- is_broadcastable ---
+
+def test_is_broadcastable_true_with_protocols_and_inbox(bcast, tmp_path):
+ assert bcast.is_broadcastable(_make_project(tmp_path, "proj")) is True
+
+
+def test_is_broadcastable_false_without_inbox(bcast, tmp_path):
+ p = _make_project(tmp_path, "proj", with_inbox=False)
+ assert bcast.is_broadcastable(p) is False
+
+
+def test_is_broadcastable_false_without_protocols(bcast, tmp_path):
+ p = _make_project(tmp_path, "proj", with_protocols=False)
+ assert bcast.is_broadcastable(p) is False
+
+
+def test_is_broadcastable_false_on_plain_dir(bcast, tmp_path):
+ assert bcast.is_broadcastable(tmp_path) is False
+
+
+# --- discover (SEARCH_ROOTS monkeypatched onto the tmp tree) ---
+
+def test_discover_finds_broadcastable_subprojects(bcast, tmp_path, monkeypatch):
+ root = tmp_path / "code"
+ root.mkdir()
+ _make_project(root, "alpha")
+ _make_project(root, "beta")
+ _make_project(root, "no-inbox", with_inbox=False) # not broadcastable
+ monkeypatch.setattr(bcast, "SEARCH_ROOTS", [root])
+ assert [p.name for p in bcast.discover()] == ["alpha", "beta"]
+
+
+def test_discover_handles_root_that_is_itself_a_project(bcast, tmp_path, monkeypatch):
+ root = _make_project(tmp_path, ".emacs.d")
+ monkeypatch.setattr(bcast, "SEARCH_ROOTS", [root])
+ assert [p.name for p in bcast.discover()] == [".emacs.d"]
+
+
+def test_discover_dedups_by_basename_across_roots(bcast, tmp_path, monkeypatch):
+ root1 = tmp_path / "code"
+ root1.mkdir()
+ root2 = tmp_path / "projects"
+ root2.mkdir()
+ _make_project(root1, "dup")
+ _make_project(root2, "dup")
+ monkeypatch.setattr(bcast, "SEARCH_ROOTS", [root1, root2])
+ assert [p.name for p in bcast.discover()] == ["dup"]
+
+
+def test_discover_skips_missing_roots(bcast, tmp_path, monkeypatch):
+ monkeypatch.setattr(bcast, "SEARCH_ROOTS", [tmp_path / "does-not-exist"])
+ assert bcast.discover() == []
+
+
+# --- sender_project / inbox_send_path (cwd-based) ---
+
+def test_sender_project_returns_basename_inside_an_ai_project(bcast, tmp_path, monkeypatch):
+ p = _make_project(tmp_path, "myproj")
+ monkeypatch.chdir(p)
+ assert bcast.sender_project() == "myproj"
+
+
+def test_sender_project_none_outside_an_ai_project(bcast, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ assert bcast.sender_project() is None
+
+
+def test_inbox_send_path_found_in_project(bcast, tmp_path, monkeypatch):
+ p = _make_project(tmp_path, "myproj")
+ (p / ".ai" / "scripts").mkdir()
+ helper = p / ".ai" / "scripts" / "inbox-send.py"
+ helper.write_text("# stub\n")
+ monkeypatch.chdir(p)
+ assert bcast.inbox_send_path() == helper
+
+
+def test_inbox_send_path_raises_when_missing(bcast, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ with pytest.raises(SystemExit):
+ bcast.inbox_send_path()
diff --git a/claude-templates/.ai/scripts/tests/test_drill_deck_diff_ids.py b/claude-templates/.ai/scripts/tests/test_drill_deck_diff_ids.py
index 9cd8305..15fb148 100644
--- a/claude-templates/.ai/scripts/tests/test_drill_deck_diff_ids.py
+++ b/claude-templates/.ai/scripts/tests/test_drill_deck_diff_ids.py
@@ -86,3 +86,24 @@ def test_cli_dropped_id_warns_and_exits_one(tmp_path):
assert r.returncode == 1
assert "disappeared" in r.stdout.lower()
assert "id-2" in r.stdout
+
+
+DECK_ONE = """* Section
+** What is DeepSat? :drill:
+:PROPERTIES:
+:ID: id-1
+:END:
+Body.
+"""
+
+
+def test_cli_appeared_only_notes_new_ids_and_exits_one(tmp_path):
+ # before has id-1; after adds id-2 and drops nothing.
+ before = tmp_path / "before.org"
+ before.write_text(DECK_ONE)
+ after = tmp_path / "after.org"
+ after.write_text(DECK_A)
+ r = _run(before, after)
+ assert r.returncode == 1
+ assert "appeared" in r.stdout.lower()
+ assert "id-2" in r.stdout
diff --git a/claude-templates/.ai/scripts/tests/test_drill_deck_stats.py b/claude-templates/.ai/scripts/tests/test_drill_deck_stats.py
index 02d9c4e..3154d42 100644
--- a/claude-templates/.ai/scripts/tests/test_drill_deck_stats.py
+++ b/claude-templates/.ai/scripts/tests/test_drill_deck_stats.py
@@ -94,3 +94,41 @@ def test_cli_dirty_deck_warns_and_exits_one(tmp_path):
def test_cli_missing_file_exits_two(tmp_path):
r = _run(tmp_path / "nope.org")
assert r.returncode == 2
+
+
+NO_TITLE_DECK = """* Section
+** What is DeepSat? :drill:
+:PROPERTIES:
+:ID: card-1
+:END:
+A satellite company.
+"""
+
+# Two cards, only one PROPERTIES drawer.
+PROP_MISMATCH_DECK = """#+TITLE: DeepSat Flashcards
+
+* Section
+** What is DeepSat? :drill:
+A satellite company.
+** Who founded it? :drill:
+:PROPERTIES:
+:ID: card-2
+:END:
+The team.
+"""
+
+
+def test_cli_missing_title_warns_and_exits_one(tmp_path):
+ f = tmp_path / "notitle.org"
+ f.write_text(NO_TITLE_DECK)
+ r = _run(f)
+ assert r.returncode == 1
+ assert "no #+TITLE" in r.stdout
+
+
+def test_cli_properties_count_mismatch_warns_and_exits_one(tmp_path):
+ f = tmp_path / "mismatch.org"
+ f.write_text(PROP_MISMATCH_DECK)
+ r = _run(f)
+ assert r.returncode == 1
+ assert "does not match card count" in r.stdout
diff --git a/claude-templates/.ai/scripts/tests/test_drill_to_anki.py b/claude-templates/.ai/scripts/tests/test_drill_to_anki.py
index 6490e58..6c5ef9b 100644
--- a/claude-templates/.ai/scripts/tests/test_drill_to_anki.py
+++ b/claude-templates/.ai/scripts/tests/test_drill_to_anki.py
@@ -42,3 +42,125 @@ def test_default_deck_name_is_raw_basename(drill):
def test_default_deck_name_keeps_hyphens(drill):
"""A hyphenated basename is kept verbatim rather than title-cased."""
assert drill.default_deck_name(Path("/x/health-drill.org")) == "health-drill"
+
+
+# --- section_to_tag (pure) ---
+
+def test_section_to_tag_slugifies_words(drill):
+ assert drill.section_to_tag("Orbital Regimes") == "orbital-regimes"
+
+
+def test_section_to_tag_strips_leading_and_trailing_nonalnum(drill):
+ assert drill.section_to_tag(" People & Roles! ") == "people-roles"
+
+
+def test_section_to_tag_empty_string(drill):
+ assert drill.section_to_tag("") == ""
+
+
+# --- escape_html (pure) ---
+
+def test_escape_html_escapes_amp_lt_gt(drill):
+ assert drill.escape_html("a & b < c > d") == "a &amp; b &lt; c &gt; d"
+
+
+def test_escape_html_plain_text_unchanged(drill):
+ assert drill.escape_html("plain text") == "plain text"
+
+
+def test_escape_html_escapes_amp_first_so_existing_entity_is_literal(drill):
+ # & is replaced before < / >, so a literal "&lt;" becomes "&amp;lt;",
+ # not silently treated as an already-escaped entity.
+ assert drill.escape_html("&lt;") == "&amp;lt;"
+
+
+def test_escape_html_empty_string(drill):
+ assert drill.escape_html("") == ""
+
+
+# --- stable_id (pure) ---
+
+def test_stable_id_is_deterministic(drill):
+ assert drill.stable_id("DeepSat", "deck") == drill.stable_id("DeepSat", "deck")
+
+
+def test_stable_id_salt_changes_the_result(drill):
+ assert drill.stable_id("DeepSat", "deck") != drill.stable_id("DeepSat", "model")
+
+
+def test_stable_id_stays_within_the_reserved_range(drill):
+ value = drill.stable_id("anything", "deck")
+ assert drill.ID_BASE <= value < drill.ID_BASE + drill.ID_RANGE
+
+
+# --- strip_org_metadata (pure) ---
+
+def test_strip_org_metadata_drops_properties_drawer(drill):
+ body = [":PROPERTIES:", ":ID: x", ":END:", "real content"]
+ assert drill.strip_org_metadata(body) == ["real content"]
+
+
+def test_strip_org_metadata_drops_planning_lines(drill):
+ body = ["SCHEDULED: <2026-05-30>", "DEADLINE: <2026-06-01>",
+ "CLOSED: [2026-05-29]", "body"]
+ assert drill.strip_org_metadata(body) == ["body"]
+
+
+def test_strip_org_metadata_leaves_plain_body_unchanged(drill):
+ body = ["line one", "line two"]
+ assert drill.strip_org_metadata(body) == ["line one", "line two"]
+
+
+def test_strip_org_metadata_empty_list(drill):
+ assert drill.strip_org_metadata([]) == []
+
+
+def test_strip_org_metadata_unclosed_drawer_swallows_the_rest(drill):
+ # An unterminated :PROPERTIES: drawer consumes everything after it.
+ body = [":PROPERTIES:", ":ID: x", "still in drawer"]
+ assert drill.strip_org_metadata(body) == []
+
+
+# --- parse (pure, core parser) ---
+
+SECTIONED = """* Orbital Regimes
+** What is LEO? :drill:
+Low Earth Orbit.
+** What is GEO? :drill:
+Geostationary Earth Orbit.
+"""
+
+
+def test_parse_returns_front_back_tag_per_card(drill):
+ cards = drill.parse(SECTIONED)
+ assert len(cards) == 2
+ assert cards[0] == ("What is LEO?", "Low Earth Orbit.", "orbital-regimes")
+ assert cards[1][0] == "What is GEO?"
+
+
+def test_parse_card_without_a_section_gets_the_drill_tag(drill):
+ assert drill.parse("** Lone card? :drill:\nbody\n") == [("Lone card?", "body", "drill")]
+
+
+def test_parse_strips_properties_drawer_from_back(drill):
+ text = "** Q? :drill:\n:PROPERTIES:\n:ID: abc\n:END:\nThe answer.\n"
+ assert drill.parse(text) == [("Q?", "The answer.", "drill")]
+
+
+def test_parse_trims_leading_and_trailing_blank_body_lines(drill):
+ cards = drill.parse("** Q? :drill:\n\n\nanswer\n\n\n")
+ assert cards[0][1] == "answer"
+
+
+def test_parse_card_with_only_a_drawer_has_empty_back(drill):
+ text = "** Q? :drill:\n:PROPERTIES:\n:ID: x\n:END:\n"
+ assert drill.parse(text) == [("Q?", "", "drill")]
+
+
+def test_parse_joins_multiline_body_with_br(drill):
+ cards = drill.parse("** Q? :drill:\nline one\nline two\n")
+ assert cards[0][1] == "line one<br>line two"
+
+
+def test_parse_no_drill_cards_returns_empty(drill):
+ assert drill.parse("* Section\nno drill cards here\n") == []