diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-30 13:27:29 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-30 13:27:29 -0500 |
| commit | a6313954fc297ee4a6c1c42ba903730a364cd5df (patch) | |
| tree | f55cc085d966684253c6e7daaeee27593ca08801 | |
| parent | 0234e52b727b34ade93961eb05b5638685f4406f (diff) | |
| download | rulesets-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.el | 9 | ||||
| -rw-r--r-- | .ai/scripts/tests/test-daily-prep-agenda.el | 106 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_cross_project_broadcast.py | 116 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_drill_deck_diff_ids.py | 21 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_drill_deck_stats.py | 38 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_drill_to_anki.py | 122 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/daily-prep-agenda.el | 9 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test-daily-prep-agenda.el | 106 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test_cross_project_broadcast.py | 116 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test_drill_deck_diff_ids.py | 21 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test_drill_deck_stats.py | 38 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test_drill_to_anki.py | 122 |
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 & b < c > 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 "<" becomes "&lt;", + # not silently treated as an already-escaped entity. + assert drill.escape_html("<") == "&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 & b < c > 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 "<" becomes "&lt;", + # not silently treated as an already-escaped entity. + assert drill.escape_html("<") == "&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") == [] |
