diff options
Diffstat (limited to '.ai/scripts/tests')
| -rw-r--r-- | .ai/scripts/tests/agent-lock.bats | 214 | ||||
| -rw-r--r-- | .ai/scripts/tests/flashcard-sync.bats | 25 | ||||
| -rw-r--r-- | .ai/scripts/tests/inbox-status.bats | 12 | ||||
| -rw-r--r-- | .ai/scripts/tests/test-lint-org.el | 281 | ||||
| -rw-r--r-- | .ai/scripts/tests/test-todo-cleanup.el | 243 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_apkg_to_orgdrill.py | 301 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_cj_remove_block.py | 167 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_flashcard_stats.py | 25 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_flashcard_to_anki.py | 61 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_inbox_send.py | 114 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_route_recommend.py | 28 |
11 files changed, 1462 insertions, 9 deletions
diff --git a/.ai/scripts/tests/agent-lock.bats b/.ai/scripts/tests/agent-lock.bats new file mode 100644 index 0000000..dbcffe1 --- /dev/null +++ b/.ai/scripts/tests/agent-lock.bats @@ -0,0 +1,214 @@ +#!/usr/bin/env bats +# +# Tests for claude-templates/.ai/scripts/agent-lock — a mkdir-atomic advisory +# lock helper for agent workflows (sentry's single-runner and roam-write +# locks). flock can't span an agent's tool calls: every Bash call is its own +# short-lived shell, so a flock dies with the call that took it. This helper +# persists the lock on disk between calls and self-clears after a crash via +# age-based staleness reclaim. +# +# Contract under test: +# agent-lock acquire <name> [--ttl=SECONDS] [--wait[=SECONDS]] +# exit 0 → acquired (fresh, or reclaimed from a stale prior holder). +# exit 1 → busy: a live lock holds <name>; deferred (note on stderr). +# exit 2 → usage error (bad/absent name, unknown subcommand). +# agent-lock refresh <name> → re-touch a held lock (heartbeat); exit 1 if absent. +# agent-lock release <name> → remove the lock; idempotent (exit 0 if already free). +# agent-lock status <name> → print free|held|stale + metadata; exit 0 (query). +# agent-lock path <name> → print the resolved lock dir path; does not create it. +# +# Staleness is age-based on the metadata file's mtime versus the lock's own +# recorded TTL, so a crashed holder's lock expires instead of wedging every +# later acquire. Heartbeat (refresh) re-touches the mtime, keeping a live +# holder's lock young. Every reclaim surfaces a note (never silent). +# +# Lock home: /run/user/<uid>/agent-locks/<name>/ (tmpfs: host-local, out of +# every repo, cleared on reboot), with ~/.cache/agent-locks/ as the fallback +# where no runtime dir exists. AGENT_LOCK_DIR overrides the base for tests and +# advanced callers; the helper otherwise owns the path scheme and callers pass +# only names. +# +# Strategy: AGENT_LOCK_DIR points every lock at a temp base, so tests never +# touch a real runtime dir. Staleness is exercised by aging the metadata +# file's mtime with `touch` rather than sleeping. + +SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/agent-lock" +BASH_BIN="$(command -v bash)" + +setup() { + TEST_DIR="$(mktemp -d -t agent-lock-bats.XXXXXX)" + LOCK_BASE="$TEST_DIR/locks" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +lock() { + run env AGENT_LOCK_DIR="$LOCK_BASE" "$BASH_BIN" "$SCRIPT" "$@" +} + +# meta-file path for a lock name, for direct inspection / aging. +meta_of() { + printf '%s/%s/meta\n' "$LOCK_BASE" "$1" +} + +# ---- acquire: fresh win + metadata -------------------------------------- + +@test "acquire: fresh name wins (exit 0) and writes pid/host/timestamp/ttl" { + lock acquire job + [ "$status" -eq 0 ] + local meta; meta="$(meta_of job)" + [ -f "$meta" ] + grep -q "^pid=$$\|^pid=[0-9][0-9]*$" "$meta" + grep -q "^host=$(uname -n)$" "$meta" + grep -qE "^acquired=[0-9]{4}-[0-9]{2}-[0-9]{2}T" "$meta" + grep -qE "^ttl=[0-9]+$" "$meta" +} + +@test "acquire: honors an explicit --ttl in the metadata" { + lock acquire job --ttl=45 + [ "$status" -eq 0 ] + grep -q "^ttl=45$" "$(meta_of job)" +} + +# ---- acquire: contention (one winner) ----------------------------------- + +@test "acquire: a second acquire of a live lock defers (exit 1, note)" { + lock acquire job + [ "$status" -eq 0 ] + lock acquire job + [ "$status" -eq 1 ] + [[ "$output" == *job* ]] +} + +@test "acquire: two racing acquires yield exactly one winner" { + # Fire both without releasing; exactly one mkdir wins. + env AGENT_LOCK_DIR="$LOCK_BASE" "$BASH_BIN" "$SCRIPT" acquire race & p1=$! + env AGENT_LOCK_DIR="$LOCK_BASE" "$BASH_BIN" "$SCRIPT" acquire race & p2=$! + local r1=0 r2=0 + wait $p1 || r1=$? + wait $p2 || r2=$? + # One exits 0 (won), one exits 1 (deferred). + [ "$((r1 + r2))" -eq 1 ] +} + +# ---- release: frees the lock -------------------------------------------- + +@test "release: frees a held lock so the next acquire wins" { + lock acquire job + [ "$status" -eq 0 ] + lock release job + [ "$status" -eq 0 ] + [ ! -d "$LOCK_BASE/job" ] + lock acquire job + [ "$status" -eq 0 ] +} + +@test "release: is idempotent on an already-free lock (exit 0)" { + lock release never-held + [ "$status" -eq 0 ] +} + +# ---- staleness reclaim (surfaced, never silent) ------------------------- + +@test "acquire: reclaims a stale lock and surfaces the reclaim note" { + lock acquire job --ttl=1 + [ "$status" -eq 0 ] + # Age the metadata mtime well past the 1s TTL. + touch -d '1 hour ago' "$(meta_of job)" + lock acquire job --ttl=1 + [ "$status" -eq 0 ] + [[ "$output" == *reclaim* ]] + [[ "$output" == *job* ]] + # The reclaim installed fresh metadata (young again), not the aged holder's. + lock status job + [[ "$output" == *held* ]] + [[ "$output" != *stale* ]] +} + +@test "acquire: a lock inside its TTL is not stale (stays deferred)" { + lock acquire job --ttl=3600 + [ "$status" -eq 0 ] + lock acquire job --ttl=3600 + [ "$status" -eq 1 ] +} + +# ---- heartbeat (refresh keeps a live lock young) ------------------------ + +@test "refresh: re-touches a held lock so it is no longer stale" { + lock acquire job --ttl=1 + [ "$status" -eq 0 ] + touch -d '1 hour ago' "$(meta_of job)" + lock status job + [[ "$output" == *stale* ]] + lock refresh job + [ "$status" -eq 0 ] + lock status job + [[ "$output" == *held* ]] + [[ "$output" != *stale* ]] +} + +@test "refresh: an absent lock cannot be refreshed (exit 1)" { + lock refresh nothing + [ "$status" -eq 1 ] +} + +# ---- status query ------------------------------------------------------- + +@test "status: reports free for an unheld lock (exit 0)" { + lock status job + [ "$status" -eq 0 ] + [[ "$output" == *free* ]] +} + +@test "status: reports held with metadata for a live lock" { + lock acquire job --ttl=3600 + lock status job + [ "$status" -eq 0 ] + [[ "$output" == *held* ]] + [[ "$output" == *"host=$(uname -n)"* ]] +} + +# ---- path resolution: runtime dir home with cache fallback -------------- + +@test "path: resolves under AGENT_LOCK_DIR when set" { + lock path job + [ "$status" -eq 0 ] + [ "$output" = "$LOCK_BASE/job" ] + [ ! -d "$LOCK_BASE/job" ] # path does not create the lock +} + +@test "path: prefers the runtime dir home when no override is set" { + local rt="$TEST_DIR/run" + mkdir -p "$rt" + run env -u AGENT_LOCK_DIR XDG_RUNTIME_DIR="$rt" "$BASH_BIN" "$SCRIPT" path job + [ "$status" -eq 0 ] + [ "$output" = "$rt/agent-locks/job" ] +} + +@test "path: falls back to the cache home when no runtime dir exists" { + local home="$TEST_DIR/home" + mkdir -p "$home" + run env -u AGENT_LOCK_DIR -u XDG_RUNTIME_DIR -u XDG_CACHE_HOME \ + HOME="$home" "$BASH_BIN" "$SCRIPT" path job + [ "$status" -eq 0 ] + [ "$output" = "$home/.cache/agent-locks/job" ] +} + +# ---- usage errors ------------------------------------------------------- + +@test "usage: a missing name is a usage error (exit 2)" { + lock acquire + [ "$status" -eq 2 ] +} + +@test "usage: a name with a slash is rejected (exit 2)" { + lock acquire bad/name + [ "$status" -eq 2 ] +} + +@test "usage: an unknown subcommand is a usage error (exit 2)" { + lock frobnicate job + [ "$status" -eq 2 ] +} diff --git a/.ai/scripts/tests/flashcard-sync.bats b/.ai/scripts/tests/flashcard-sync.bats index 608a280..e6ffc21 100644 --- a/.ai/scripts/tests/flashcard-sync.bats +++ b/.ai/scripts/tests/flashcard-sync.bats @@ -6,6 +6,7 @@ setup() { SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" SYNC="$SCRIPT_DIR/flashcard-sync" + STATS="$SCRIPT_DIR/flashcard-stats.py" TMP="$(mktemp -d)" } @@ -36,3 +37,27 @@ EOF [ "$status" -eq 1 ] [ ! -f "$HOME/sync/phone/anki/dirty.apkg" ] } + +@test "flashcard-stats: a multi-tagged :fundamental:drill: card still counts" { + # Regression guard: a curated card carrying a second org tag must not drop + # from the count. A :drill:$ anchor would have counted only one card here. + cat > "$TMP/multitag.org" <<'EOF' +#+TITLE: Multitag Test + +* Orbital Regimes +** What is LEO? :fundamental:drill: +:PROPERTIES: +:ID: c1 +:END: +Low Earth Orbit is the region below about 2000 kilometers. +** What is GEO? :drill: +:PROPERTIES: +:ID: c2 +:END: +Geostationary orbit sits at roughly 35786 kilometers of altitude. +EOF + run python3 "$STATS" "$TMP/multitag.org" + [ "$status" -eq 0 ] + [[ "$output" == *"Cards: 2"* ]] + [[ "$output" == *clean* ]] +} diff --git a/.ai/scripts/tests/inbox-status.bats b/.ai/scripts/tests/inbox-status.bats index bc8a734..27a497e 100644 --- a/.ai/scripts/tests/inbox-status.bats +++ b/.ai/scripts/tests/inbox-status.bats @@ -45,6 +45,18 @@ teardown() { [[ "$output" == *"0 pending"* ]] } +@test "inbox-status: ignores an in-flight .inbox-send-* temp file" { + mkdir "$TMP/inbox" + # inbox-send writes to a .inbox-send-* temp then renames it into place; + # during that window the temp must not read as a pending handoff, or a + # concurrent boundary check blocks on a file that's about to become real. + touch "$TMP/inbox/.inbox-send-abc123.org" + cd "$TMP" + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"0 pending"* ]] +} + @test "inbox-status: -q suppresses the per-item lines" { mkdir "$TMP/inbox" echo body > "$TMP/inbox/handoff.org" diff --git a/.ai/scripts/tests/test-lint-org.el b/.ai/scripts/tests/test-lint-org.el index 8e3e190..ceee209 100644 --- a/.ai/scripts/tests/test-lint-org.el +++ b/.ai/scripts/tests/test-lint-org.el @@ -193,6 +193,65 @@ real suspicious-language warning here #+end_src ") +;; invalid-block, false-positive case — a correctly paired example block whose +;; body holds a heading-shaped line. org's parser reads the `** ' inside the +;; verbatim body as a structural break, loses the open block, and flags BOTH +;; delimiters as "Possible incomplete block". +(defconst lo-test--verbatim-heading-block "\ +* Heading + +#+begin_example +** Feature Name or Topic +Body line. +#+end_example + +Trailing prose. +") + +;; invalid-block, literal-delimiter case — a paired src block whose body holds +;; a literal `#+end_example' plus a heading-shaped line. Only `#+end_src' +;; closes a src block, so all three findings here are false. +(defconst lo-test--literal-end-in-src "\ +* Heading + +#+begin_src text +#+end_example +** heading shaped +#+end_src +") + +;; invalid-block, uppercase-delimiter case — org accepts #+BEGIN_/#+END_ in +;; either case, and the pre-fix script flagged both delimiters here too. +(defconst lo-test--uppercase-verbatim-block "\ +* Heading + +#+BEGIN_EXAMPLE +** heading shaped +#+END_EXAMPLE +") + +;; invalid-block, genuine case — a block that really is never closed. The +;; suppression must not reach this one. +(defconst lo-test--unterminated-block "\ +* Heading + +#+begin_example +truly unterminated block body +") + +;; A genuinely unterminated block *after* a correctly paired one — verifies the +;; suppression is scoped per block rather than per file. +(defconst lo-test--paired-then-unterminated "\ +* Heading + +#+begin_example +** heading shaped +#+end_example + +#+begin_example +never closed +") + ;; Mixed fixture — each category once. (defconst lo-test--mixed "\ * Mixed @@ -392,6 +451,55 @@ suspicious-language judgment." (should (= 1 suspicious)))) ;;; --------------------------------------------------------------------------- +;;; invalid-block — false positives on correctly paired verbatim blocks + +(ert-deftest lo-verbatim-heading-block-emits-no-invalid-block () + "Normal: a paired example block containing a heading-shaped body line emits +no invalid-block judgment. Both delimiters are flagged by org-lint because the +parser treats the `** ' inside the verbatim body as a structural break." + (let* ((out (lo-test--run lo-test--verbatim-heading-block)) + (res (plist-get out :result)) + (judgments (lo-test--judgments (plist-get out :issues)))) + ;; File untouched, no fixes applied — suppression only, never a rewrite. + (should (equal lo-test--verbatim-heading-block res)) + (should (= 0 (plist-get out :fixes))) + (should-not (member 'invalid-block (lo-test--checkers judgments))))) + +(ert-deftest lo-literal-end-delimiter-in-src-emits-no-invalid-block () + "Boundary: a paired src block whose body holds a literal `#+end_example' and +a heading-shaped line emits no invalid-block judgment. Only `#+end_src' closes +a src block, so the interior delimiter is body text." + (let* ((out (lo-test--run lo-test--literal-end-in-src)) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'invalid-block (lo-test--checkers judgments))))) + +(ert-deftest lo-uppercase-verbatim-block-emits-no-invalid-block () + "Boundary: block delimiters are case-insensitive in org, so an uppercase +`#+BEGIN_EXAMPLE' pair is suppressed the same as a lowercase one." + (let* ((out (lo-test--run lo-test--uppercase-verbatim-block)) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'invalid-block (lo-test--checkers judgments))))) + +(ert-deftest lo-unterminated-block-still-emits-invalid-block () + "Error: a block that is never closed still emits its invalid-block judgment. +This is the finding the checker exists for — the suppression must not mask it." + (let* ((out (lo-test--run lo-test--unterminated-block)) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (member 'invalid-block (lo-test--checkers judgments))))) + +(ert-deftest lo-invalid-block-suppression-is-scoped-per-block () + "Boundary: a paired block and an unterminated block in the same file — the +paired one is suppressed and the unterminated one still reports. Exactly one +invalid-block judgment, and it points at the unterminated opener (line 7)." + (let* ((out (lo-test--run lo-test--paired-then-unterminated)) + (judgments (lo-test--judgments (plist-get out :issues))) + (invalid (cl-remove-if-not + (lambda (i) (eq (plist-get i :checker) 'invalid-block)) + judgments))) + (should (= 1 (length invalid))) + (should (= 7 (plist-get (car invalid) :line))))) + +;;; --------------------------------------------------------------------------- ;;; --check mode (ert-deftest lo-check-mode-does-not-modify-file () @@ -739,6 +847,48 @@ missing-rules violation." (judgments (lo-test--judgments (plist-get out :issues)))) (should-not (member 'subtask-done-not-dated (lo-test--checkers judgments))))) +;;; dated-log-heading-active-timestamp check (stale SCHEDULED/DEADLINE on a +;;; completed dated-log entry — the home 2026-07-17 agenda-pollution bug) + +(ert-deftest lo-dated-log-active-scheduled-is-flagged () + "A dated-log entry still carrying an active SCHEDULED is flagged: org renders +it on the agenda forever despite the missing keyword." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 trip booked\nSCHEDULED: <2026-06-18 Thu>\nBody.\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (= 0 (plist-get out :fixes))) ; judgment-only, never auto-fixed + (should (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments))))) + +(ert-deftest lo-dated-log-active-deadline-is-flagged () + "An active DEADLINE on a dated-log entry is flagged too." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 shipped\nDEADLINE: <2026-06-25 Thu>\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments))))) + +(ert-deftest lo-dated-log-clean-entry-not-flagged () + "A dated-log entry with no active planning timestamp is correct — not flagged." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 done cleanly\nBody only.\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments))))) + +(ert-deftest lo-dated-log-inactive-timestamp-not-flagged () + "An inactive [..] timestamp doesn't render on the agenda, so it isn't flagged — +only active <..> planning timestamps are the defect." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 recorded\nSCHEDULED: [2026-06-18 Thu]\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments))))) + +(ert-deftest lo-dated-log-active-scheduled-on-live-todo-not-flagged () + "A live TODO (keyword present) that legitimately carries an active SCHEDULED is +not a dated-log heading, so this checker leaves it alone." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent\n*** TODO [#C] real upcoming task\nSCHEDULED: <2026-06-18 Thu>\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments))))) + ;;; --------------------------------------------------------------------------- ;;; structural heading checks (org-lint gaps) @@ -817,3 +967,134 @@ heading, so it is not flagged — only two-or-more indented stars are." (provide 'test-lint-org) ;;; test-lint-org.el ends here + +;;; --------------------------------------------------------------------------- +;;; task-missing-last-reviewed (claude-rules/todo-format.md) + +(ert-deftest lo-task-without-last-reviewed-is-judgment () + "An open level-2 task with no :LAST_REVIEWED: is flagged." + (let* ((out (lo-test--run "* Open Work\n** TODO [#B] A task :feature:\nBody.\n")) + (js (lo-test--judgments (plist-get out :issues)))) + (should (memq 'task-missing-last-reviewed (lo-test--checkers js))))) + +(ert-deftest lo-task-with-last-reviewed-is-clean () + "A task carrying the property is not flagged." + (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] A task :feature:\n" + ":PROPERTIES:\n:LAST_REVIEWED: 2026-07-23\n:END:\n" + "Body.\n"))) + (js (lo-test--judgments (plist-get out :issues)))) + (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js))))) + +(ert-deftest lo-task-last-reviewed-accepts-org-timestamp () + "The org-native [YYYY-MM-DD Day] form counts, matching the staleness script." + (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] A task :feature:\n" + ":PROPERTIES:\n:LAST_REVIEWED: [2026-07-23 Thu]\n:END:\n"))) + (js (lo-test--judgments (plist-get out :issues)))) + (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js))))) + +(ert-deftest lo-done-task-without-last-reviewed-is-clean () + "Completed tasks leave the review pool, so they are never flagged." + (let* ((out (lo-test--run (concat "* Open Work\n** DONE [#B] A task :feature:\n" + "CLOSED: [2026-07-23 Thu]\n"))) + (js (lo-test--judgments (plist-get out :issues)))) + (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js))))) + +(ert-deftest lo-subtask-without-last-reviewed-is-clean () + "Only level-2 tasks are in the review pool; deeper headings are not." + (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] Parent :feature:\n" + ":PROPERTIES:\n:LAST_REVIEWED: 2026-07-23\n:END:\n" + "*** TODO A sub-task\n"))) + (js (lo-test--judgments (plist-get out :issues)))) + (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js))))) + +(ert-deftest lo-cookieless-task-without-last-reviewed-is-clean () + "The staleness script selects on a priority cookie, so match that scope." + (let* ((out (lo-test--run "* Open Work\n** TODO Manual testing and validation\n")) + (js (lo-test--judgments (plist-get out :issues)))) + (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js))))) + +(ert-deftest lo-verify-task-without-last-reviewed-is-judgment () + "VERIFY is in the review pool too." + (let* ((out (lo-test--run "* Open Work\n** VERIFY [#B] Waiting on Craig\n")) + (js (lo-test--judgments (plist-get out :issues)))) + (should (memq 'task-missing-last-reviewed (lo-test--checkers js))))) + +;;; --------------------------------------------------------------------------- +;;; todo-format checkers skip docs/specs/ files (claude-rules/todo-format.md) +;; +;; The four todo-format-family checkers encode todo.org completion conventions. +;; A spec legitimately uses ** DONE <decision> with no CLOSED cookie and +;; ** <dated> — <who> review-history headings, so those checkers misfire on +;; every spec. They must skip any file under a docs/specs/ path segment. + +(defun lo-test--run-at (relpath content) + "Write CONTENT to <tmpdir>/RELPATH, run lint on it, return :issues. +RELPATH is a relative path (may contain slashes) so a docs/specs/ segment +can be exercised — the checkers key on the file's path, not just its name." + (let* ((root (make-temp-file "lo-test-root-" t)) + (file (expand-file-name relpath root))) + (make-directory (file-name-directory file) t) + (unwind-protect + (progn + (with-temp-file file (insert content)) + (lo-test--reset) + (lo-process-file file) + (prog1 (list :issues lo-issues) + (lo-test--drop-buffer file))) + (delete-directory root t)))) + +(defconst lo-test--spec-decisions + "* Decisions [1/1]\n** DONE Some decision\n- Context: x\n" + "A spec Decisions section: a level-2 DONE with no CLOSED cookie.") + +(defconst lo-test--spec-history + "* Review history\n** 2026-07-14 Tue @ 02:03:28 -0500 — Claude — responder\n- What: x\n" + "A spec review-history section: a level-2 dated header.") + +(ert-deftest lo-todo-checkers-fire-on-a-normal-org-file () + "Baseline: the checkers DO fire on a non-spec path (the bug is scope, not silence)." + (let* ((out (lo-test--run-at "todo.org" lo-test--spec-decisions)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should (memq 'level2-done-without-closed cs)))) + +(ert-deftest lo-level2-done-without-closed-skips-specs () + (let* ((out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" lo-test--spec-decisions)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should-not (memq 'level2-done-without-closed cs)))) + +(ert-deftest lo-level2-dated-header-skips-specs () + (let* ((out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" lo-test--spec-history)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should-not (memq 'level-2-dated-header cs)))) + +(ert-deftest lo-dated-log-active-timestamp-skips-specs () + (let* ((c "* History\n** 2026-07-14 Tue @ 02:03:28 -0500 — did a thing\nSCHEDULED: <2026-07-20 Mon>\n") + (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should-not (memq 'dated-log-heading-active-timestamp cs)))) + +(ert-deftest lo-subtask-done-not-dated-skips-specs () + (let* ((c "* Work\n** TODO Parent\n*** DONE A sub-decision\n") + (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should-not (memq 'subtask-done-not-dated cs)))) + +(ert-deftest lo-link-checks-still-fire-on-specs () + "Only the todo-format family is scoped out; a broken link in a spec still flags." + (let* ((c "* X\n[[file:does-not-exist-xyz.org][link]]\n") + (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should (memq 'link-to-local-file cs)))) + +(ert-deftest lo-task-missing-last-reviewed-skips-specs () + "The fifth todo-format checker (added 2026-07-23) skips specs too — a spec's +phases section may carry ** TODO [#x] items that aren't backlog tasks." + (let* ((c "* Implementation phases\n** TODO [#B] Phase one\nBody.\n") + (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should-not (memq 'task-missing-last-reviewed cs))) + ;; And still fires on a normal file. + (let* ((c "* Work\n** TODO [#B] Real backlog task\nBody.\n") + (out (lo-test--run-at "todo.org" c)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should (memq 'task-missing-last-reviewed cs)))) diff --git a/.ai/scripts/tests/test-todo-cleanup.el b/.ai/scripts/tests/test-todo-cleanup.el index ffbf2fb..1e964b3 100644 --- a/.ai/scripts/tests/test-todo-cleanup.el +++ b/.ai/scripts/tests/test-todo-cleanup.el @@ -31,6 +31,7 @@ (defun tc-test--reset (&optional check) (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil + tc-sealed 0 tc-seal nil tc-convert-subtasks nil tc-check-only (and check t) tc-archive-done t tc-sync-child-priority nil tc-current-file nil @@ -40,6 +41,7 @@ (defun tc-test--reset-sync (&optional check) (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil + tc-sealed 0 tc-seal nil tc-check-only (and check t) tc-archive-done nil tc-sync-child-priority t tc-current-file nil @@ -514,6 +516,12 @@ gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive pat .gitignore contents or nil), :archive-ignored (whether git ignores the archive), :archive-exists." (let* ((root (make-temp-file "tc-git-" t)) + ;; Private backup dir: this helper writes a file literally named + ;; todo.org and runs a real (non-check) pass, so without this its + ;; backup lands in the shared temp dir under the exact production + ;; name and is indistinguishable from a real one. + (temporary-file-directory + (file-name-as-directory (make-temp-file "tc-git-bk-" t))) (todo (expand-file-name "todo.org" root)) (archive (expand-file-name "archive/task-archive.org" root)) (gi (expand-file-name ".gitignore" root))) @@ -534,7 +542,8 @@ gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive pat :archive-ignored (eq 0 (call-process "git" nil nil nil "check-ignore" "-q" archive)) :archive-exists (file-readable-p archive))) - (delete-directory root t)))) + (delete-directory root t) + (delete-directory temporary-file-directory t)))) (ert-deftest tc-age-self-protect-gitignores-archive-when-todo-ignored () "When the todo file is gitignored, the aged-out archive is added to .gitignore @@ -578,6 +587,95 @@ entry is added for it." (should (> (plist-get out :archived) 0))))) ;;; --------------------------------------------------------------------------- +;;; --archive-done retention default + +(ert-deftest tc-archive-retain-default-is-one-month () + "The shipped retention default is one month (31 days), not the legacy 7. +The defvar initializes from this defconst; the live var itself is mutated by +other tests, so the immutable defconst is the stable contract to pin." + (should (= 31 tc-archive-retain-days-default))) + +;;; --------------------------------------------------------------------------- +;;; --seal: rename the working archive to resolved-YYYY-MM-DD.org + +(defun tc-test--seal (&optional opts) + "Run `--seal' against a temp todo file with a temp archive dir. +OPTS is a plist: :archive-content (seed task-archive.org with this; nil = no +working archive), :ref (YEAR MONTH DAY seal date; default (2026 7 18)), +:check, :presealed (also create resolved-<ref>.org first, to test collision). +Returns a plist: :sealed count, :issues, :working-exists, :sealed-exists, +:sealed-name, :report." + (let* ((ref (or (plist-get opts :ref) '(2026 7 18))) + (check (plist-get opts :check)) + (archive-content (plist-get opts :archive-content)) + (todo (make-temp-file "tc-seal-todo-" nil ".org")) + (adir (make-temp-file "tc-seal-arch-" t)) + (afile (expand-file-name "task-archive.org" adir)) + (sealed-name (format "resolved-%04d-%02d-%02d.org" + (nth 0 ref) (nth 1 ref) (nth 2 ref))) + (sealed (expand-file-name sealed-name adir))) + (unwind-protect + (progn + (with-temp-file todo (insert "* Open Work\n** TODO [#A] live\n")) + (when archive-content (with-temp-file afile (insert archive-content))) + (when (plist-get opts :presealed) + (with-temp-file sealed (insert "pre-existing seal\n"))) + (tc-test--reset check) + ;; Set every mode flag explicitly: tc-test--reset leaves + ;; tc-convert-subtasks untouched, so a convert test running earlier in + ;; the suite would otherwise still own the dispatch and run convert. + (setq tc-archive-done nil tc-sync-child-priority nil + tc-convert-subtasks nil tc-seal t tc-sealed 0 + tc-archive-reference-date ref + tc-archive-file afile) + (let ((report (with-output-to-string (tc-process-file todo) (tc-emit-report)))) + (tc-test--drop-buffer todo) + (list :sealed tc-sealed + :issues tc-issues + :working-exists (file-readable-p afile) + :sealed-exists (file-readable-p sealed) + :sealed-name sealed-name + :report report))) + (tc-test--drop-buffer todo) + (delete-file todo) + (delete-directory adir t)))) + +(ert-deftest tc-seal-renames-working-archive-to-dated-file () + "Normal: --seal renames task-archive.org to resolved-<seal-date>.org." + (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n** DONE old\n" + :ref (2026 7 18))))) + (should (= 1 (plist-get out :sealed))) + (should-not (plist-get out :working-exists)) + (should (plist-get out :sealed-exists)) + (should (equal "resolved-2026-07-18.org" (plist-get out :sealed-name))) + (should (tc-test--has (plist-get out :report) "sealed task-archive.org → resolved-2026-07-18.org")))) + +(ert-deftest tc-seal-nothing-to-seal-is-a-reported-noop () + "Boundary: no working archive present — reported no-op, nothing created." + (let ((out (tc-test--seal '(:ref (2026 7 18))))) + (should (= 0 (plist-get out :sealed))) + (should-not (plist-get out :sealed-exists)) + (should (tc-test--has (plist-get out :report) "no working archive to seal")))) + +(ert-deftest tc-seal-check-mode-previews-without-renaming () + "Boundary: --check reports the seal but leaves the working archive in place." + (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n" + :ref (2026 7 18) :check t)))) + (should (= 1 (plist-get out :sealed))) + (should (plist-get out :working-exists)) + (should-not (plist-get out :sealed-exists)) + (should (tc-test--has (plist-get out :report) "would seal")))) + +(ert-deftest tc-seal-refuses-to-clobber-existing-sealed-file () + "Error: resolved-<today>.org already exists — refuse, leave both files intact." + (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n" + :ref (2026 7 18) :presealed t)))) + (should (= 0 (plist-get out :sealed))) + (should (plist-get out :working-exists)) + (should (plist-get out :sealed-exists)) + (should (tc-test--has (plist-get out :report) "already exists")))) + +;;; --------------------------------------------------------------------------- ;;; Sync-child-priority harness + fixtures (defun tc-test--sync (content &optional runs check) @@ -773,7 +871,7 @@ in ISSUES, in document order." (defun tc-test--reset-convert (&optional check) (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-converted 0 tc-archived-to-file 0 - tc-issues nil + tc-issues nil tc-sealed 0 tc-seal nil tc-check-only (and check t) tc-archive-done nil tc-sync-child-priority nil tc-convert-subtasks t tc-current-file nil @@ -927,8 +1025,9 @@ CLOSED: [2026-06-27 Sat 12:50] DEADLINE: <2026-06-30 Tue> Body line. ") -(ert-deftest tc-convert-preserves-deadline-on-shared-planning-line-boundary () - "Boundary: removing the CLOSED cookie keeps a DEADLINE sharing its planning line." +(ert-deftest tc-convert-strips-deadline-sharing-the-planning-line-boundary () + "Boundary: a DEADLINE sharing the CLOSED planning line goes too — a dated-log +entry carries no active planning timestamp (todo-format.md). Body survives." (let* ((out (tc-test--convert tc-test--convert-closed-with-deadline)) (res (plist-get out :result))) (should (= 1 (plist-get out :converted))) @@ -936,8 +1035,142 @@ Body line. "^\\*\\*\\* 2026-06-27 Sat @ 12:50:00 [-+][0-9]\\{4\\} Ship the panel$" res)) (should-not (string-match-p "CLOSED:" res)) - (should (string-match-p "^DEADLINE: <2026-06-30 Tue>$" res)) + (should-not (string-match-p "DEADLINE:" res)) + (should (string-match-p "^Body line\\.$" res)))) + +(defconst tc-test--convert-closed-and-scheduled-separate-lines + "* Project Open Work +** TODO [#B] Parent task +*** DONE [#C] Book the venue :feature: +CLOSED: [2026-06-27 Sat 12:50] +SCHEDULED: <2026-06-20 Sat> +Body line. +") + +(ert-deftest tc-convert-strips-scheduled-on-its-own-line () + "Normal (the home bug): a SCHEDULED planning line on its own — the completion +rewrite dropped keyword/priority/tags but left the SCHEDULED, pinning the dated +entry to the agenda as weeks-overdue. Both planning lines go; body survives." + (let* ((out (tc-test--convert tc-test--convert-closed-and-scheduled-separate-lines)) + (res (plist-get out :result))) + (should (= 1 (plist-get out :converted))) + (should (string-match-p + "^\\*\\*\\* 2026-06-27 Sat @ 12:50:00 [-+][0-9]\\{4\\} Book the venue$" + res)) + (should-not (string-match-p "CLOSED:" res)) + (should-not (string-match-p "SCHEDULED:" res)) (should (string-match-p "^Body line\\.$" res)))) +(defconst tc-test--convert-scheduled-in-body-prose + "* Project Open Work +** TODO [#B] Parent task +*** DONE [#C] Note the mechanism :feature: +CLOSED: [2026-06-27 Sat 12:50] +An active SCHEDULED: <2026-06-20 Sat> in prose must survive. +") + +(ert-deftest tc-convert-leaves-planning-shaped-body-prose-alone () + "Boundary: a planning-shaped token inside body prose (not a canonical planning +line) is left untouched — the strip stops at the first non-planning line." + (let* ((out (tc-test--convert tc-test--convert-scheduled-in-body-prose)) + (res (plist-get out :result))) + (should (= 1 (plist-get out :converted))) + (should-not (string-match-p "CLOSED:" res)) + (should (string-match-p "An active SCHEDULED: <2026-06-20 Sat> in prose must survive\\." res)))) + (provide 'test-todo-cleanup) ;;; test-todo-cleanup.el ends here + +;;; --------------------------------------------------------------------------- +;;; Backup before mutating (parity with lint-org.el / wrap-org-table.el) +;; +;; todo-cleanup rewrites todo.org in place and left no copy behind, while both +;; sibling org-mutators back up to /tmp first. It is also the one that runs most +;; often (every wrap, every sentry cycle). Emacs's own backup does not fire under +;; --batch -q, so there was genuinely no undo short of git. + +(ert-deftest tc-backup-written-before-a-real-mutation () + "A real (non-check) run leaves a copy holding the pre-edit content. + +`temporary-file-directory' is rebound to a private dir for the duration: the +backup name derives from the *file's* basename, and the real todo.org shares +that basename, so a live sentry run writing /tmp/todo.org.before-todo-cleanup.* +would otherwise be indistinguishable from this test's own artifact. The first +version of this test globbed the shared /tmp and passed only until a real run +created one (2026-07-24)." + (let* ((dir (make-temp-file "tc-backup-" t)) + (bdir (file-name-as-directory (make-temp-file "tc-bk-" t))) + (file (expand-file-name "todo.org" dir)) + (before "* P Open Work\n** TODO [#B] parent\n*** DONE a subtask\nCLOSED: [2026-07-01 Tue]\n")) + (unwind-protect + (progn + (with-temp-file file (insert before)) + (let ((tc-check-only nil) + (tc-convert-subtasks t) + (temporary-file-directory bdir)) + (tc-process-file file)) + (let ((backups (file-expand-wildcards + (concat bdir "todo.org.before-todo-cleanup.*")))) + (should backups) + (should (string-match-p + "a subtask" + (with-temp-buffer (insert-file-contents (car backups)) + (buffer-string)))))) + (delete-directory dir t) + (delete-directory bdir t)))) + +(ert-deftest tc-no-backup-in-check-mode () + "--check writes nothing, so it must not leave a backup either. +Uses a private `temporary-file-directory' for the same isolation reason." + (let* ((dir (make-temp-file "tc-backup-" t)) + (bdir (file-name-as-directory (make-temp-file "tc-bk-" t))) + (file (expand-file-name "todo.org" dir))) + (unwind-protect + (progn + (with-temp-file file + (insert "* P Open Work\n** TODO [#B] parent\n*** DONE sub\nCLOSED: [2026-07-01 Tue]\n")) + (let ((tc-check-only t) + (tc-convert-subtasks t) + (temporary-file-directory bdir)) + (tc-process-file file)) + (should-not (file-expand-wildcards + (concat bdir "todo.org.before-todo-cleanup.*")))) + (delete-directory dir t) + (delete-directory bdir t)))) + +(ert-deftest tc-backup-never-overwrites-an-earlier-one () + "Two invocations in the same second must not collapse to one backup. + +open-tasks.org runs --convert-subtasks then --archive-done back to back, each +a sub-second batch run. With a second-resolution stamp and copy-file's +OK-IF-ALREADY-EXISTS, the second invocation overwrote the first's backup with +already-mutated content, so the true pre-session original was unrecoverable — +the exact state the backup exists to preserve (found 2026-07-24 in review)." + (let* ((dir (make-temp-file "tc-collide-" t)) + (bdir (file-name-as-directory (make-temp-file "tc-cbk-" t))) + (file (expand-file-name "todo.org" dir)) + (original (concat "* P Open Work\n** TODO [#B] parent\n*** DONE sub\n" + "CLOSED: [2026-07-01 Tue]\n" + "* P Resolved\n** DONE [#C] old\nCLOSED: [2025-01-01 Wed]\n"))) + (unwind-protect + (progn + (with-temp-file file (insert original)) + ;; Two back-to-back invocations, as the shipped workflow does. + (let ((temporary-file-directory bdir)) + (let ((tc-check-only nil) (tc-convert-subtasks t)) + (tc-process-file file)) + (let ((tc-check-only nil) (tc-convert-subtasks nil) (tc-archive-done t) + (tc-archive-retain-days nil)) + (tc-process-file file))) + (let ((backups (file-expand-wildcards + (concat bdir "todo.org.before-todo-cleanup.*")))) + ;; Both invocations kept their own backup. + (should (= (length backups) 2)) + ;; And one of them still holds the true original. + (should (cl-some (lambda (b) + (string= original + (with-temp-buffer (insert-file-contents b) + (buffer-string)))) + backups)))) + (delete-directory dir t) + (delete-directory bdir t)))) diff --git a/.ai/scripts/tests/test_apkg_to_orgdrill.py b/.ai/scripts/tests/test_apkg_to_orgdrill.py new file mode 100644 index 0000000..6a95ea4 --- /dev/null +++ b/.ai/scripts/tests/test_apkg_to_orgdrill.py @@ -0,0 +1,301 @@ +"""Tests for apkg-to-orgdrill.py — the inverse of flashcard-to-anki.py. + +The converter reads an Anki .apkg (a zip holding collection.anki2 / .anki21 +sqlite) and emits an org-drill .org in the house canonical shape. It is +stdlib-only (zipfile + sqlite3), so it imports directly — no genanki stub. + +The apkg schema these tests build by hand mirrors what genanki actually +writes, confirmed against a real apkg generated from flashcard-to-anki.py: + - col.decks : JSON {did: {"name": ...}}, always including id-1 "Default" + - col.models : JSON {mid: {"name": ..., "flds": [{"name": "Front"}, ...]}} + - notes.flds : fields joined by \x1f; tags space-padded (" tag ") + - cards : nid -> did (the Default deck carries no cards) + +The round-trip test closes the loop through flashcard-to-anki.py's own +parse(): original org -> forward parse tuples -> apkg fixture -> converter +-> recovered org -> forward parse -> assert the (front, back, tag) tuples +match. Only the apkg materialization is hand-built (the genanki boundary); +everything else is the real code on both sides. +""" +from __future__ import annotations + +import importlib.util +import json +import sqlite3 +import sys +import types +import zipfile +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] +CONVERTER = SCRIPTS / "apkg-to-orgdrill.py" +FORWARD = SCRIPTS / "flashcard-to-anki.py" + + +def _load(path: Path, name: str, stub_genanki: bool = False): + if stub_genanki: + sys.modules.setdefault("genanki", types.ModuleType("genanki")) + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + # Register before exec: @dataclass resolves cls.__module__ via sys.modules + # (Python 3.14), which is None for an unregistered importlib module. + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def conv(): + return _load(CONVERTER, "apkg_to_orgdrill") + + +@pytest.fixture(scope="module") +def forward(): + return _load(FORWARD, "flashcard_to_anki", stub_genanki=True) + + +# --- fixture builder: write a genanki-shaped apkg by hand ------------------ + +def _make_apkg( + path: Path, + decks: dict[int, str], + models: dict[int, list[str]], + notes: list[tuple[int, int, list[str], str]], # (nid, mid, fields, tag) + cards: list[tuple[int, int]], # (nid, did) + *, + media: str = "{}", +) -> None: + """Materialize a minimal apkg matching genanki's collection.anki2 shape.""" + col_dir = path.parent / f"{path.stem}-build" + col_dir.mkdir(parents=True, exist_ok=True) + db = col_dir / "collection.anki2" + if db.exists(): + db.unlink() + con = sqlite3.connect(db) + con.execute("CREATE TABLE col (id INTEGER, decks TEXT, models TEXT)") + decks_json = {"1": {"name": "Default"}} + decks_json.update({str(did): {"name": name} for did, name in decks.items()}) + models_json = { + str(mid): {"name": f"{decks.get(list(decks)[0], 'M')} model", + "flds": [{"name": n, "ord": i} for i, n in enumerate(flds)]} + for mid, flds in models.items() + } + con.execute("INSERT INTO col (id, decks, models) VALUES (1, ?, ?)", + (json.dumps(decks_json), json.dumps(models_json))) + con.execute("CREATE TABLE notes (id INTEGER, mid INTEGER, flds TEXT, tags TEXT)") + for nid, mid, fields, tag in notes: + con.execute("INSERT INTO notes (id, mid, flds, tags) VALUES (?, ?, ?, ?)", + (nid, mid, "\x1f".join(fields), f" {tag} " if tag else " ")) + con.execute("CREATE TABLE cards (id INTEGER, nid INTEGER, did INTEGER)") + for i, (nid, did) in enumerate(cards): + con.execute("INSERT INTO cards (id, nid, did) VALUES (?, ?, ?)", (1000 + i, nid, did)) + con.commit() + con.close() + with zipfile.ZipFile(path, "w") as z: + z.write(db, "collection.anki2") + z.writestr("media", media) + + +# --- html_to_org_body ------------------------------------------------------ + +def test_html_to_org_splits_br_into_lines(conv): + assert conv.html_to_org_body("one<br>two<br>three") == ["one", "two", "three"] + + +def test_html_to_org_handles_br_variants(conv): + assert conv.html_to_org_body("a<br/>b<br />c<BR>d") == ["a", "b", "c", "d"] + + +def test_html_to_org_unescapes_entities_amp_last(conv): + # Inverts escape_html (which escapes & first): < > & -> < > &. + assert conv.html_to_org_body("x <tag> & y") == ["x <tag> & y"] + + +def test_html_to_org_preserves_a_literal_escaped_entity(conv): + # Forward-escaping the literal "<" yields "&lt;"; the inverse must + # recover "<", not "<". + assert conv.html_to_org_body("&lt;") == ["<"] + + +def test_html_to_org_strips_answer_hr(conv): + assert conv.html_to_org_body('front<hr id="answer">back') == ["front", "back"] + + +def test_html_to_org_empty_back_is_empty(conv): + assert conv.html_to_org_body("") == [] + + +# --- read_apkg ------------------------------------------------------------- + +def test_read_apkg_single_deck_recovers_front_back_tag_deck(conv, tmp_path): + apkg = tmp_path / "d.apkg" + _make_apkg( + apkg, + decks={20: "My Deck"}, + models={9: ["Front", "Back"]}, + notes=[(100, 9, ["Q1?", "A1.<br>line2"], "sec-one")], + cards=[(100, 20)], + ) + recovered = conv.read_apkg(apkg) + assert len(recovered) == 1 + note = recovered[0] + assert note.deck == "My Deck" + assert note.front == "Q1?" + assert note.back_html == "A1.<br>line2" + assert note.tag == "sec-one" + + +def test_read_apkg_multiple_decks_grouped(conv, tmp_path): + apkg = tmp_path / "multi.apkg" + _make_apkg( + apkg, + decks={20: "Deck A", 21: "Deck B"}, + models={9: ["Front", "Back"]}, + notes=[(100, 9, ["QA?", "AA"], "ta"), (101, 9, ["QB?", "AB"], "tb")], + cards=[(100, 20), (101, 21)], + ) + decks = {n.deck for n in conv.read_apkg(apkg)} + assert decks == {"Deck A", "Deck B"} + + +def test_read_apkg_skips_default_deck_without_cards(conv, tmp_path): + apkg = tmp_path / "def.apkg" + _make_apkg( + apkg, + decks={20: "Real Deck"}, + models={9: ["Front", "Back"]}, + notes=[(100, 9, ["Q?", "A"], "t")], + cards=[(100, 20)], + ) + assert {n.deck for n in conv.read_apkg(apkg)} == {"Real Deck"} + + +def test_read_apkg_warns_and_skips_non_basic_model(conv, tmp_path, capsys): + apkg = tmp_path / "cloze.apkg" + _make_apkg( + apkg, + decks={20: "Cloze Deck"}, + models={9: ["Text", "Extra"]}, # not Front/Back + notes=[(100, 9, ["some {{c1::text}}", "extra"], "t")], + cards=[(100, 20)], + ) + recovered = conv.read_apkg(apkg) + assert recovered == [] + assert "skip" in capsys.readouterr().err.lower() + + +def test_read_apkg_reads_anki21_collection_name(conv, tmp_path): + # A .anki21 collection filename must be read the same as .anki2. + apkg = tmp_path / "new.apkg" + _make_apkg( + apkg, + decks={20: "Deck"}, + models={9: ["Front", "Back"]}, + notes=[(100, 9, ["Q?", "A"], "t")], + cards=[(100, 20)], + ) + # Rewrite the zip renaming the collection member to .anki21. + with zipfile.ZipFile(apkg) as z: + data = z.read("collection.anki2") + media = z.read("media") + with zipfile.ZipFile(apkg, "w") as z: + z.writestr("collection.anki21", data) + z.writestr("media", media) + assert conv.read_apkg(apkg)[0].front == "Q?" + + +def test_read_apkg_flags_media_reference(conv, tmp_path, capsys): + apkg = tmp_path / "media.apkg" + _make_apkg( + apkg, + decks={20: "Deck"}, + models={9: ["Front", "Back"]}, + notes=[(100, 9, ["Q?", 'see <img src="x.png">'], "t")], + cards=[(100, 20)], + ) + conv.read_apkg(apkg) + assert "media" in capsys.readouterr().err.lower() + + +# --- notes_to_org ---------------------------------------------------------- + +def test_notes_to_org_emits_canonical_shape(conv): + Note = conv.Note + notes = [ + Note(deck="My Deck", front="Q1?", back_html="A1.", tag="alpha"), + Note(deck="My Deck", front="Q2?", back_html="A2.", tag="alpha"), + ] + ids = iter(["id-1", "id-2"]) + org = conv.notes_to_org(notes, "My Deck", new_id=lambda: next(ids)) + assert "#+TITLE: My Deck" in org + assert "* alpha" in org + assert "** Q1? :drill:" in org + assert ":ID: id-1" in org + assert ":ID: id-2" in org + assert org.count("* alpha") == 1 # both cards share one section + + +def test_notes_to_org_distinct_tags_get_distinct_sections(conv): + Note = conv.Note + notes = [ + Note(deck="D", front="Qa?", back_html="a", tag="alpha"), + Note(deck="D", front="Qb?", back_html="b", tag="beta"), + ] + org = conv.notes_to_org(notes, "D", new_id=lambda: "x") + assert "* alpha" in org and "* beta" in org + + +# --- round-trip through the real forward parse() --------------------------- + +def test_round_trip_matches_forward_parse_tuples(conv, forward, tmp_path): + original = ( + "#+TITLE: RT Deck\n" + "\n" + "* First Section\n" + "** What is 2+2? :drill:\n" + ":PROPERTIES:\n:ID: aaaa\n:END:\n" + "Four.\n" + "Second line with <angle> & amp.\n" + "\n" + "* Second Section\n" + "** Capital of France? :drill:\n" + "Paris.\n" + ) + tuples = forward.parse(original) # [(front, back_html, anki_tags), ...] + assert len(tuples) == 2 + + apkg = tmp_path / "rt.apkg" + _make_apkg( + apkg, + decks={20: "RT Deck"}, + models={9: ["Front", "Back"]}, + # anki_tags is a list; the apkg tags field is space-joined. + notes=[(100 + i, 9, [f, b], " ".join(tags)) + for i, (f, b, tags) in enumerate(tuples)], + cards=[(100 + i, 20) for i in range(len(tuples))], + ) + + by_deck = conv.convert(apkg) + assert set(by_deck) == {"RT Deck"} + recovered_tuples = forward.parse(by_deck["RT Deck"]) + assert recovered_tuples == tuples + + +# --- errors ---------------------------------------------------------------- + +def test_read_apkg_missing_collection_errors(conv, tmp_path): + bad = tmp_path / "bad.apkg" + with zipfile.ZipFile(bad, "w") as z: + z.writestr("media", "{}") + with pytest.raises(Exception): + conv.read_apkg(bad) + + +def test_read_apkg_not_a_zip_errors(conv, tmp_path): + notzip = tmp_path / "plain.apkg" + notzip.write_text("not a zip") + with pytest.raises(Exception): + conv.read_apkg(notzip) diff --git a/.ai/scripts/tests/test_cj_remove_block.py b/.ai/scripts/tests/test_cj_remove_block.py index 2c8dade..3cdee46 100644 --- a/.ai/scripts/tests/test_cj_remove_block.py +++ b/.ai/scripts/tests/test_cj_remove_block.py @@ -14,6 +14,34 @@ import pytest SCRIPT = Path(__file__).parent.parent / "cj-remove-block.py" +@pytest.fixture(autouse=True) +def isolated_tmpdir(tmp_path, monkeypatch): + """Give every test in this module a private TMPDIR. + + The script backs up to the system temp dir under a name derived from the + edited file's BASENAME. The real todo.org shares that basename, so any test + operating on a fixture named todo.org writes something indistinguishable + from a production backup — and an earlier version of this file globbed the + shared /tmp and unlinked every match, so a routine `make test` destroyed + Craig's real backups (found in review, 2026-07-24). + + Isolating at module scope rather than per-test is deliberate: the same bug + was fixed once in the elisp sibling and left here, so relying on each new + test to remember is exactly how it recurred. Autouse makes it structural. + """ + d = tmp_path / "_tmpdir" + d.mkdir() + # TMPDIR covers subprocess invocations of the script. + monkeypatch.setenv("TMPDIR", str(d)) + # tempfile.gettempdir() caches its answer on first call, so a test that + # loads the module in-process would keep writing to the real /tmp no matter + # what TMPDIR says. Override the cache too — this is the gap that made the + # env-var-only version still leak one backup per suite run. + import tempfile as _tempfile + monkeypatch.setattr(_tempfile, "tempdir", str(d)) + return d + + @pytest.fixture def run_remove(tmp_path): """Write content to a temp org file, run cj-remove-block, return new contents.""" @@ -155,3 +183,142 @@ class TestCjRemoveBlockSafety: err, post_content = run_remove_expecting_failure(original, start=4, end=2) assert err.returncode != 0 assert post_content == original + + +class TestMultiBlockRangeRefused: + """The validation exists to catch a drifted range, but it only checked the + first and last lines of that range. A span from one block's opening fence to + a LATER block's closing fence passed, and the removal silently deleted every + line between — real prose, headings, whole tasks — with a zero exit. Drift is + the skill's normal operating mode (respond-to-cj-comments edits the file as it + processes, and a file under cj review usually holds several blocks), so this + is the exact scenario the check was written for. Reproduced 2026-07-24.""" + + TWO_BLOCKS = ( + "* Alpha\n" + "#+begin_src cj:\n" + "note A\n" + "#+end_src\n" + "KEEP THIS LINE\n" + "* Beta\n" + "#+begin_src cj:\n" + "note B\n" + "#+end_src\n" + ) + + def test_range_spanning_two_blocks_is_refused(self, run_remove_expecting_failure): + # Lines 2..9: block one's opener through block two's closer. + err, content = run_remove_expecting_failure(self.TWO_BLOCKS, 2, 9) + assert err.returncode == 1 + assert "KEEP THIS LINE" in content, "content between the blocks was destroyed" + assert "* Beta" in content, "a heading between the blocks was destroyed" + + def test_refusal_names_the_reason(self, run_remove_expecting_failure): + err, _ = run_remove_expecting_failure(self.TWO_BLOCKS, 2, 9) + assert "more than one" in err.stderr.decode().lower() + + def test_a_correct_single_block_range_still_removes(self, run_remove): + # The fix must not over-tighten: the legitimate range still works. + out = run_remove(self.TWO_BLOCKS, 2, 4) + assert "note A" not in out + assert "KEEP THIS LINE" in out + assert "note B" in out, "the second block must be untouched" + + def test_a_nested_end_src_inside_the_range_is_refused(self, run_remove_expecting_failure): + # Any #+end_src before the final line means the range covers >1 block. + content = ( + "#+begin_src cj:\n" + "a\n" + "#+end_src\n" + "middle\n" + "#+begin_src cj:\n" + "b\n" + "#+end_src\n" + ) + err, after = run_remove_expecting_failure(content, 1, 7) + assert err.returncode == 1 + assert "middle" in after + + +class TestSafeMutation: + """The script rewrites Craig's org files (todo.org, notes.org). It wrote with + a bare write_text, which truncates the target on open, and took no backup — + so a mid-write failure left the file truncated with no copy to recover from. + lint-org.el, the other tool that mutates these files, backs up to a temp dir + first. Match that, and make the write atomic. + + Every test here redirects TMPDIR to a private directory. The backup name + derives from the file's basename, and the real todo.org shares it, so a test + globbing the shared temp dir cannot tell its own artifact from a genuine + backup — and an earlier version of this class globbed /tmp and unlinked every + match, so a routine `make test` destroyed real backups (found in review, + 2026-07-24). Never glob or delete across the shared temp dir.""" + + ONE_BLOCK = "* T\n#+begin_src cj:\nnote\n#+end_src\nkeep\n" + + def test_a_backup_is_written_before_mutating(self, tmp_path): + import subprocess, glob, os + bdir = tmp_path / "bk" + bdir.mkdir() + f = tmp_path / "todo.org" + f.write_text(self.ONE_BLOCK) + subprocess.run( + ["python3", str(SCRIPT), "--file", str(f), "--start", "2", "--end", "4"], + check=True, capture_output=True, + env={**os.environ, "TMPDIR": str(bdir)}, + ) + backups = glob.glob(str(bdir / "todo.org.before-cj-remove.*")) + assert backups, "no backup was written before mutating the org file" + assert "note" in Path(max(backups)).read_text() + + def test_no_partial_file_when_the_write_fails(self, tmp_path, monkeypatch): + import importlib.util + spec = importlib.util.spec_from_file_location("crb", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + bdir = tmp_path / "bk" + bdir.mkdir() + monkeypatch.setenv("TMPDIR", str(bdir)) + f = tmp_path / "todo.org" + f.write_text(self.ONE_BLOCK) + def boom(*a, **k): + raise OSError("disk full") + monkeypatch.setattr(mod.os, "replace", boom) + with pytest.raises(OSError): + mod.remove_range(f, 2, 4) + # The original survives intact — no truncation, no partial. + assert f.read_text() == self.ONE_BLOCK + + +class TestBackupNeverOverwrites: + """Same defect class as todo-cleanup's, and more reachable here: the + respond-to-cj-comments skill removes several annotations in quick + succession, so a second-resolution stamp collides and the later backup + overwrote the earlier one with already-mutated content.""" + + TWO_BLOCKS = ( + "* A\n#+begin_src cj:\nfirst\n#+end_src\n" + "* B\n#+begin_src cj:\nsecond\n#+end_src\n" + ) + + def test_consecutive_removals_each_keep_a_backup(self, tmp_path, monkeypatch): + import subprocess, glob + bdir = tmp_path / "bk" + bdir.mkdir() + monkeypatch.setenv("TMPDIR", str(bdir)) + f = tmp_path / "todo.org" + f.write_text(self.TWO_BLOCKS) + original = f.read_text() + # Remove the second block, then the first — back to back, same second. + subprocess.run(["python3", str(SCRIPT), "--file", str(f), + "--start", "6", "--end", "8"], + check=True, capture_output=True, + env={**__import__("os").environ, "TMPDIR": str(bdir)}) + subprocess.run(["python3", str(SCRIPT), "--file", str(f), + "--start", "2", "--end", "4"], + check=True, capture_output=True, + env={**__import__("os").environ, "TMPDIR": str(bdir)}) + backups = glob.glob(str(bdir / "todo.org.before-cj-remove.*")) + assert len(backups) == 2, f"expected 2 backups, got {len(backups)}" + contents = [Path(b).read_text() for b in backups] + assert original in contents, "no backup holds the true original" diff --git a/.ai/scripts/tests/test_flashcard_stats.py b/.ai/scripts/tests/test_flashcard_stats.py index 606f7c1..46deccc 100644 --- a/.ai/scripts/tests/test_flashcard_stats.py +++ b/.ai/scripts/tests/test_flashcard_stats.py @@ -217,6 +217,31 @@ def test_parse_cards_captures_body_without_drawer_planning_or_answer_header(stat assert c["body"] == "the real answer" +def test_parse_cards_counts_a_multitag_heading_as_a_card(stats): + """A card multi-tagged :fundamental:drill: still counts; the front is clean.""" + text = "* Sec\n** Q multi? :fundamental:drill:\nthe answer\n" + cards, _ = stats.parse_cards(text.splitlines()) + assert len(cards) == 1 + assert cards[0]["heading"] == "Q multi?" + assert cards[0]["body"] == "the answer" + + +def test_parse_cards_ignores_a_tagged_heading_without_drill(stats): + """A tagged heading missing :drill: is not a drill card.""" + text = "* Sec\n** Just a note :note:\nbody\n" + cards, _ = stats.parse_cards(text.splitlines()) + assert cards == [] + + +def test_parse_cards_body_stops_at_next_multitag_card(stats): + """The body scan ends at the next L2 card even when it is multi-tagged.""" + text = "** Q1? :a:drill:\nbody1\n** Q2? :drill:\nbody2\n" + cards, _ = stats.parse_cards(text.splitlines()) + assert len(cards) == 2 + assert cards[0]["body"] == "body1" + assert cards[1]["body"] == "body2" + + def test_find_duplicate_fronts_matches_normalized_headings(stats): cards = [ {"heading": "What is LEO?"}, diff --git a/.ai/scripts/tests/test_flashcard_to_anki.py b/.ai/scripts/tests/test_flashcard_to_anki.py index 87008a8..fa38b64 100644 --- a/.ai/scripts/tests/test_flashcard_to_anki.py +++ b/.ai/scripts/tests/test_flashcard_to_anki.py @@ -158,17 +158,18 @@ 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") + # The section becomes the sole Anki tag (as a one-element list). + 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")] + 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")] + assert drill.parse(text) == [("Q?", "The answer.", ["drill"])] def test_parse_trims_leading_and_trailing_blank_body_lines(drill): @@ -178,7 +179,59 @@ def test_parse_trims_leading_and_trailing_blank_body_lines(drill): 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")] + assert drill.parse(text) == [("Q?", "", ["drill"])] + + +# --- multi-tag headings, --tag-filter, --guid-salt ------------------------- + +MULTITAG = """* Fundamentals +** What is LEO? :fundamental:drill: +Low Earth Orbit. +** What is GEO? :drill: +Geostationary Earth Orbit. +""" + + +def test_parse_multitag_heading_is_a_card_when_drill_is_present(drill): + """A heading with a second org tag still parses when drill is among them.""" + cards = drill.parse(MULTITAG) + assert len(cards) == 2 + assert cards[0][0] == "What is LEO?" + + +def test_parse_multitag_tags_ride_along_next_to_the_section_tag(drill): + """Non-drill org tags become Anki tags alongside the section tag.""" + cards = drill.parse(MULTITAG) + assert cards[0][2] == ["fundamentals", "fundamental"] # section slug + org tag + assert cards[1][2] == ["fundamentals"] # drill-only -> section only + + +def test_parse_heading_without_drill_tag_is_not_a_card(drill): + """A tagged heading missing :drill: is not a card (e.g. :note:).""" + assert drill.parse("* S\n** Just a note :note:\nbody\n") == [] + + +def test_parse_tag_filter_returns_only_cards_with_that_org_tag(drill): + """--tag-filter narrows to cards carrying the given org tag.""" + cards = drill.parse(MULTITAG, tag_filter="fundamental") + assert len(cards) == 1 + assert cards[0][0] == "What is LEO?" + + +def test_parse_body_bounded_by_any_l1_or_l2_heading(drill): + """A card body stops at the next L1/L2 heading, multi-tagged or not.""" + text = "** Q1? :a:drill:\nbody1\n** Q2? :drill:\nbody2\n" + cards = drill.parse(text) + assert cards[0][1] == "body1" + assert cards[1][1] == "body2" + + +def test_card_guid_salt_changes_the_guid(drill, monkeypatch): + """--guid-salt gives a subset deck its own GUID space; no salt is unchanged.""" + monkeypatch.setattr(drill.genanki, "guid_for", lambda *a: ":".join(a), raising=False) + assert drill.card_guid("front", None) == "front" + assert drill.card_guid("front", "fundamentals") == "fundamentals:front" + assert drill.card_guid("front", None) != drill.card_guid("front", "fundamentals") def test_parse_joins_multiline_body_with_br(drill): diff --git a/.ai/scripts/tests/test_inbox_send.py b/.ai/scripts/tests/test_inbox_send.py index f75d7a1..9b0a8c6 100644 --- a/.ai/scripts/tests/test_inbox_send.py +++ b/.ai/scripts/tests/test_inbox_send.py @@ -476,3 +476,117 @@ class TestFilenameCollisions: assert len(files) == 2 bodies = "".join(f.read_text() for f in files) assert "message one" in bodies and "message two" in bodies + + +class TestAtomicWrite: + """A send wrote straight to the destination path in another project's + inbox/, and write_text truncates on open, so any mid-write failure left a + zero-byte .org there. inbox-status counts that phantom as a pending + handoff, blocking a turn in the receiving project over a file with no + content (2026-07-23). The write must be atomic: the inbox sees a complete + file or nothing.""" + + def test_send_text_writes_utf8(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 23, 4, 36, 0) + # An em dash and an accented char — both non-ASCII. + dest = mod.send_text(inbox, "accent café and dash — here", "src", None, now) + # Reading as utf-8 must round-trip; a locale-encoded write would raise + # under a C locale, and reading back proves the bytes are utf-8. + assert "—" in dest.read_text(encoding="utf-8") + + def test_send_text_no_partial_on_write_failure(self, tmp_path, monkeypatch): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 23, 4, 36, 0) + # Force the atomic finalize to fail after the temp file is written. + def boom(*a, **k): + raise OSError("disk full") + monkeypatch.setattr(mod.os, "replace", boom) + with pytest.raises(OSError): + mod.send_text(inbox, "a message that should never half-land", "src", None, now) + # No phantom, no leftover temp: the inbox is empty. + assert list(inbox.iterdir()) == [] + + def test_send_text_leaves_no_temp_on_success(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 23, 4, 36, 0) + dest = mod.send_text(inbox, "clean send", "src", None, now) + assert list(inbox.iterdir()) == [dest] + + def test_send_file_no_partial_on_write_failure(self, tmp_path, monkeypatch): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + src = tmp_path / "note.org" + src.write_text("body") + now = datetime(2026, 7, 23, 4, 36, 0) + def boom(*a, **k): + raise OSError("disk full") + monkeypatch.setattr(mod.os, "replace", boom) + with pytest.raises(OSError): + mod.send_file(inbox, src, "src", None, now) + assert list(inbox.iterdir()) == [] + + def test_send_file_leaves_no_temp_on_success(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + src = tmp_path / "note.org" + src.write_text("payload") + now = datetime(2026, 7, 23, 4, 36, 0) + dest = mod.send_file(inbox, src, "src", None, now) + assert list(inbox.iterdir()) == [dest] + assert dest.read_text() == "payload" + + +class TestSmallerDefects: + """Two low-severity defects found reading inbox-send during the 2026-07-23 + sweep: an unreadable source raised an uncaught traceback instead of the + clean error every other failure path produces, and a roots config naming + both a parent and one of its children listed the same project twice.""" + + def test_unreadable_source_gives_clean_error_not_traceback( + self, project_root, run_script, tmp_path + ): + project_root("sender") + project_root("receiver") + roots = [tmp_path / "projects"] + src = tmp_path / "secret.bin" + src.write_text("x") + src.chmod(0o000) + try: + result = run_script( + ["receiver", "--file", str(src)], + cwd=tmp_path / "projects" / "sender", + roots=roots, + expect_failure=True, + ) + finally: + src.chmod(0o644) + assert result.returncode == 1 + # The clean "inbox-send: <message>" shape, not a Python traceback. + assert result.stderr.startswith("inbox-send:") + assert "Traceback" not in result.stderr + + def test_discover_projects_dedupes_parent_and_child_root(self, tmp_path): + mod = _load_module() + # A project directory, reachable both as a child of its parent root and + # as a root in its own right. + parent = tmp_path / "projects" + proj = parent / "app" + (proj / ".ai").mkdir(parents=True) + (proj / "inbox").mkdir() + found = mod.discover_projects([parent, proj]) + resolved = [p.resolve() for p in found] + assert resolved.count(proj.resolve()) == 1 diff --git a/.ai/scripts/tests/test_route_recommend.py b/.ai/scripts/tests/test_route_recommend.py index acc4755..2ec900a 100644 --- a/.ai/scripts/tests/test_route_recommend.py +++ b/.ai/scripts/tests/test_route_recommend.py @@ -122,3 +122,31 @@ def test_cli_exclude_drops_current_project(tmp_path): r = _run(["--exclude", "foo"], roots=[tmp_path / "projects"], item="fix the foo widget") assert r.returncode == 0 assert r.stdout.strip() == "none" + + +# ---------------------------------------------------------------------- +# Duplicate candidate names +# +# Projects are collapsed to bare basenames, so two projects sharing a basename +# across roots (~/code/notes and ~/projects/notes) appear twice in the candidate +# list. Both literal-match, recommend read len(strong) > 1 as an ambiguous tie, +# and a correct strong match was downgraded to weak. Latent when discovered +# 2026-07-24 (27 projects, 27 distinct basenames) but real. +# ---------------------------------------------------------------------- + +def test_duplicate_candidate_name_keeps_strong_confidence(): + assert rr.recommend("fix the notes thing", ["notes", "other"]) == ("notes", "strong") + # The same name twice must not read as a tie. + assert rr.recommend("fix the notes thing", ["notes", "notes", "other"]) == ("notes", "strong") + + +def test_genuine_ambiguity_still_downgrades(): + # Two DIFFERENT projects both matching is a real tie and stays weak — the + # dedupe must collapse identical names only, never real ambiguity. + dest, conf = rr.recommend("notes and other both", ["notes", "other"]) + assert conf == "weak" + + +def test_duplicates_do_not_change_the_chosen_destination(): + dest, _ = rr.recommend("fix the notes thing", ["notes", "notes"]) + assert dest == "notes" |
