diff options
Diffstat (limited to '.ai/scripts/tests/test-todo-cleanup.el')
| -rw-r--r-- | .ai/scripts/tests/test-todo-cleanup.el | 610 |
1 files changed, 606 insertions, 4 deletions
diff --git a/.ai/scripts/tests/test-todo-cleanup.el b/.ai/scripts/tests/test-todo-cleanup.el index ad9260b..a92d238 100644 --- a/.ai/scripts/tests/test-todo-cleanup.el +++ b/.ai/scripts/tests/test-todo-cleanup.el @@ -30,16 +30,22 @@ ;;; Harness (defun tc-test--reset (&optional check) - (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil + (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)) + tc-current-file nil + ;; Aging step OFF by default so the in-file-move tests are unaffected by + ;; the wall clock; the aging harness re-enables it with fixed params. + tc-archive-retain-days nil tc-archive-reference-date nil tc-archive-file nil)) (defun tc-test--reset-sync (&optional check) - (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil + (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)) + tc-current-file nil + tc-archive-retain-days nil tc-archive-reference-date nil tc-archive-file nil)) (defun tc-test--drop-buffer (file) (let ((buf (find-buffer-visiting file))) @@ -355,6 +361,207 @@ from the heading line through (not including) the next level-1 heading or EOF." (should (tc-test--has (plist-get out :report) "skipped")))) ;;; --------------------------------------------------------------------------- +;;; --archive-done file-aging: keep last week in-file, move older to task-archive + +(defun tc-test--age (content &optional opts) + "Run `--archive-done' with the file-aging step enabled. +OPTS is a plist: :retain (days; default 7, may be nil to disable), :ref +\(YEAR MONTH DAY reference date), :runs (default 1), :check. Writes CONTENT to a +temp todo file and points `tc-archive-file' at a not-yet-existing temp archive. +Returns a plist: :result (todo contents), :archive (archive-file contents or +nil), :archived (in-file move count), :to-file (aged count), :issues — all from +the last run." + (let* ((retain (if (plist-member opts :retain) (plist-get opts :retain) 7)) + (ref (plist-get opts :ref)) + (runs (or (plist-get opts :runs) 1)) + (check (plist-get opts :check)) + (todo (make-temp-file "tc-age-todo-" nil ".org")) + (adir (make-temp-file "tc-age-arch-" t)) + (afile (expand-file-name "task-archive.org" adir)) + last) + (unwind-protect + (progn + (with-temp-file todo (insert content)) + (dotimes (_ runs) + (tc-test--reset check) + (setq tc-archive-retain-days retain + tc-archive-reference-date ref + tc-archive-file afile) + (tc-process-file todo) + (setq last (list :archived tc-archived :to-file tc-archived-to-file + :issues tc-issues)) + (tc-test--drop-buffer todo)) + (append + last + (list :result (with-temp-buffer (insert-file-contents todo) (buffer-string)) + :archive (and (file-readable-p afile) + (with-temp-buffer (insert-file-contents afile) + (buffer-string)))))) + (tc-test--drop-buffer todo) + (delete-file todo) + (delete-directory adir t)))) + +;; Reference "today" for these fixtures is 2026-06-29; with retain 7 the cutoff +;; is 2026-06-22, so a task closed on or after 2026-06-22 stays in-file. +(defconst tc-test--age-resolved "\ +* Age Open Work +** TODO [#A] still open +* Age Resolved +** DONE [#B] recent within window +CLOSED: [2026-06-25 Thu] +recent body +** DONE [#C] old beyond window +CLOSED: [2026-05-01 Fri] +old body line +** CANCELLED [#C] old cancelled too +CLOSED: [2026-04-15 Wed] +** DONE [#B] exactly at cutoff stays +CLOSED: [2026-06-22 Sun] +** DONE [#C] undated no-date archived +no closed date in this body +") + +(defconst tc-test--age-straggler "\ +* Age Open Work +** TODO [#A] still open +** DONE [#C] old straggler +CLOSED: [2026-03-01 Sun] +straggler body +* Age Resolved +** DONE [#B] recent stays +CLOSED: [2026-06-26 Fri] +") + +(ert-deftest tc-age-moves-old-and-undated-resolved () + "Normal: closed-beyond-window AND undated subtrees leave the file; only those +closed within the window (cutoff inclusive) stay." + (let* ((out (tc-test--age tc-test--age-resolved '(:ref (2026 6 29)))) + (resolved (tc-test--section (plist-get out :result) "Age Resolved")) + (arch (plist-get out :archive))) + (should (= 3 (plist-get out :to-file))) + (should-not (tc-test--has resolved "old beyond window")) + (should-not (tc-test--has resolved "old cancelled too")) + (should-not (tc-test--has resolved "undated no-date archived")) + (should (tc-test--has resolved "recent within window")) + (should (tc-test--has resolved "exactly at cutoff stays")) + (should arch) + (should (tc-test--has arch "Resolved (archived)")) + (should (tc-test--has arch "old beyond window")) + (should (tc-test--has arch "old body line")) + (should (tc-test--has arch "old cancelled too")) + (should (tc-test--has arch "undated no-date archived")) + (should-not (tc-test--has arch "recent within window")))) + +(ert-deftest tc-age-disabled-when-retain-nil () + "Boundary: nil retain disables the aging step entirely (legacy behavior)." + (let ((out (tc-test--age tc-test--age-resolved '(:retain nil :ref (2026 6 29))))) + (should (= 0 (plist-get out :to-file))) + (should (equal tc-test--age-resolved (plist-get out :result))) + (should-not (plist-get out :archive)))) + +(ert-deftest tc-age-is-idempotent () + "Boundary: a second run finds nothing new to age; the todo file is stable." + (let ((once (tc-test--age tc-test--age-resolved '(:ref (2026 6 29) :runs 1))) + (twice (tc-test--age tc-test--age-resolved '(:ref (2026 6 29) :runs 2)))) + (should (equal (plist-get once :result) (plist-get twice :result))) + (should (= 0 (plist-get twice :to-file))))) + +(ert-deftest tc-age-check-mode-previews-without-writing () + "Boundary: --check reports the aged count but writes neither file." + (let ((out (tc-test--age tc-test--age-resolved '(:ref (2026 6 29) :check t)))) + (should (= 3 (plist-get out :to-file))) + (should (equal tc-test--age-resolved (plist-get out :result))) + (should-not (plist-get out :archive)))) + +(ert-deftest tc-age-straggler-moves-through-to-archive () + "Normal: an old-dated DONE in Open Work moves to Resolved then ages out in one run." + (let* ((out (tc-test--age tc-test--age-straggler '(:ref (2026 6 29)))) + (open (tc-test--section (plist-get out :result) "Age Open Work")) + (resolved (tc-test--section (plist-get out :result) "Age Resolved")) + (arch (plist-get out :archive))) + (should-not (tc-test--has open "old straggler")) + (should-not (tc-test--has resolved "old straggler")) + (should (tc-test--has arch "old straggler")) + (should (tc-test--has arch "straggler body")) + (should (tc-test--has resolved "recent stays")) + (should (= 1 (plist-get out :archived))) + (should (= 1 (plist-get out :to-file))))) + +(ert-deftest tc-age-append-preserves-existing-archive () + "Error/edge: appending to a populated archive keeps prior entries and one scaffold." + (let* ((adir (make-temp-file "tc-arch-" t)) + (afile (expand-file-name "task-archive.org" adir))) + (unwind-protect + (progn + (tc--append-subtrees-to-archive-file afile (list "** DONE one\n")) + (tc--append-subtrees-to-archive-file afile (list "** DONE two\n")) + (let ((content (with-temp-buffer (insert-file-contents afile) + (buffer-string))) + (n 0) (start 0)) + (should (tc-test--has content "** DONE one")) + (should (tc-test--has content "** DONE two")) + (should (tc-test--before-p content "** DONE one" "** DONE two")) + (while (string-match "\\* Resolved (archived)" content start) + (setq n (1+ n) start (match-end 0))) + (should (= 1 n)))) + (delete-directory adir t)))) + +;;; --------------------------------------------------------------------------- +;;; --archive-done aging: the archive follows the todo file's gitignore status + +(defun tc-test--age-in-git-repo (gitignore-todo) + "Init a temp git repo, write todo.org with an old Resolved entry, optionally +gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive path +(archive/task-archive.org beside the todo file). Return a plist: :gitignore (final +.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))) + (unwind-protect + (let ((default-directory root)) + (call-process "git" nil nil nil "init" "-q") + (with-temp-file todo (insert tc-test--age-resolved)) + (when gitignore-todo (with-temp-file gi (insert "/todo.org\n"))) + (tc-test--reset nil) + (setq tc-archive-retain-days 7 + tc-archive-reference-date '(2026 6 29) + tc-archive-file nil) ; default path, beside the todo file + (tc-process-file todo) + (tc-test--drop-buffer todo) + (list :gitignore (and (file-readable-p gi) + (with-temp-buffer (insert-file-contents gi) + (buffer-string))) + :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 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 +so it inherits the same privacy." + (let ((out (tc-test--age-in-git-repo t))) + (should (plist-get out :archive-exists)) + (should (string-match-p "task-archive" (or (plist-get out :gitignore) ""))) + (should (plist-get out :archive-ignored)))) + +(ert-deftest tc-age-self-protect-leaves-tracked-todo-archive-tracked () + "When the todo file is tracked, the archive is not gitignored — no .gitignore +entry is added for it." + (let ((out (tc-test--age-in-git-repo nil))) + (should (plist-get out :archive-exists)) + (should-not (plist-get out :archive-ignored)) + (should-not (string-match-p "task-archive" (or (plist-get out :gitignore) ""))))) + +;;; --------------------------------------------------------------------------- ;;; Realistic synthetic sample (committed under fixtures/) (defun tc-test--sample-file () @@ -380,6 +587,95 @@ from the heading line through (not including) the next level-1 heading or EOF." (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) @@ -570,5 +866,311 @@ in ISSUES, in document order." (should (= 2 (plist-get once :bumped))) (should (= 2 (plist-get twice :bumped))))) +;;; --------------------------------------------------------------------------- +;;; --convert-subtasks harness + tests + +(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-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 + tc-archive-retain-days nil tc-archive-reference-date nil tc-archive-file nil)) + +(defun tc-test--convert (content &optional runs check) + "Write CONTENT to a temp .org file, run `--convert-subtasks' RUNS times (default 1). +Return a plist: :result final file contents, :converted count from the last run, +:issues from the last run. CHECK non-nil ⇒ --check (preview, no writes)." + (let ((file (make-temp-file "tc-test-" nil ".org")) + last-converted last-issues) + (unwind-protect + (progn + (with-temp-file file (insert content)) + (dotimes (_ (or runs 1)) + (tc-test--reset-convert check) + (tc-process-file file) + (setq last-converted tc-converted last-issues tc-issues) + (tc-test--drop-buffer file)) + (list :result (with-temp-buffer (insert-file-contents file) + (buffer-string)) + :converted last-converted + :issues last-issues)) + (tc-test--drop-buffer file) + (delete-file file)))) + +;; The UTC offset in a converted header is the test machine's local offset for +;; that date, so assertions match it as `[-+]NNNN' rather than a fixed value — +;; the mode's job is to emit a well-formed offset, not to run in one timezone. + +(defconst tc-test--convert-timed + "* Project Open Work +** TODO [#B] Parent task +*** DONE [#C] F12 opens the terminal :feature:quick: +CLOSED: [2026-06-27 Sat 12:50] +Verified live: docks, toggles, colors clean. +") + +(ert-deftest tc-convert-timed-subtask-normal () + "Normal: a timed CLOSED close becomes a dated header, keyword/priority/tags/CLOSED gone." + (let* ((out (tc-test--convert tc-test--convert-timed)) + (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\\} F12 opens the terminal$" + res)) + (should-not (string-match-p "CLOSED:" res)) + (should-not (string-match-p "DONE" res)) + (should (string-match-p "Verified live: docks, toggles, colors clean\\." res)) + (should (string-match-p "^\\*\\* TODO \\[#B\\] Parent task$" res)))) + +(defconst tc-test--convert-dateonly + "* Project Open Work +** PROJECT [#B] Parent +**** DONE [#B] Write full spec :refactor: +CLOSED: [2026-05-04 Mon] +Body. +") + +(ert-deftest tc-convert-dateonly-boundary-midnight () + "Boundary: a date-only CLOSED (no time) yields 00:00:00, at level 4." + (let ((res (plist-get (tc-test--convert tc-test--convert-dateonly) :result))) + (should (string-match-p + "^\\*\\*\\*\\* 2026-05-04 Mon @ 00:00:00 [-+][0-9]\\{4\\} Write full spec$" + res)) + (should-not (string-match-p "CLOSED:" res)))) + +(defconst tc-test--convert-level2 + "* Project Open Work +** DONE [#B] Top-level task +CLOSED: [2026-06-01 Mon 09:00] +Body. +") + +(ert-deftest tc-convert-leaves-level-2-alone-boundary () + "Boundary: a level-2 DONE task is a top-level task, not a sub-task — untouched." + (let ((out (tc-test--convert tc-test--convert-level2))) + (should (= 0 (plist-get out :converted))) + (should (equal tc-test--convert-level2 (plist-get out :result))))) + +(ert-deftest tc-convert-idempotent-boundary () + "Boundary: a second run over an already-dated entry converts nothing new." + (let ((once (tc-test--convert tc-test--convert-timed 1)) + (twice (tc-test--convert tc-test--convert-timed 2))) + (should (equal (plist-get once :result) (plist-get twice :result))) + (should (= 0 (plist-get twice :converted))))) + +(defconst tc-test--convert-nested + "* Project Open Work +** TODO [#B] Parent +*** DONE Outer sub :feature: +CLOSED: [2026-06-10 Wed 08:15] +**** DONE Inner sub +CLOSED: [2026-06-09 Tue 07:00] +Inner body. +") + +(ert-deftest tc-convert-nested-done-subtasks-boundary () + "Boundary: a done sub-task nested under a done sub-task — both convert." + (let* ((out (tc-test--convert tc-test--convert-nested)) + (res (plist-get out :result))) + (should (= 2 (plist-get out :converted))) + (should (string-match-p + "^\\*\\*\\* 2026-06-10 Wed @ 08:15:00 [-+][0-9]\\{4\\} Outer sub$" res)) + (should (string-match-p + "^\\*\\*\\*\\* 2026-06-09 Tue @ 07:00:00 [-+][0-9]\\{4\\} Inner sub$" res)) + (should-not (string-match-p "CLOSED:" res)))) + +(defconst tc-test--convert-cancelled + "* Project Open Work +** TODO [#B] Parent +*** CANCELLED [#C] Abandoned idea :feature: +CLOSED: [2026-06-15 Mon 10:00] +") + +(ert-deftest tc-convert-cancelled-subtask-boundary () + "Boundary: a CANCELLED sub-task converts too (terminal state)." + (let ((res (plist-get (tc-test--convert tc-test--convert-cancelled) :result))) + (should (string-match-p + "^\\*\\*\\* 2026-06-15 Mon @ 10:00:00 [-+][0-9]\\{4\\} Abandoned idea$" res)) + (should-not (string-match-p "CANCELLED" res)))) + +(defconst tc-test--convert-noclosed + "* Project Open Work +** TODO [#B] Parent +*** DONE Orphan with no closed date +Body only. +") + +(ert-deftest tc-convert-skips-subtask-without-closed-error () + "Error: a done sub-task with no parseable CLOSED is flagged and left unchanged." + (let ((out (tc-test--convert tc-test--convert-noclosed))) + (should (= 0 (plist-get out :converted))) + (should (equal tc-test--convert-noclosed (plist-get out :result))) + (should (cl-some (lambda (i) (eq (plist-get i :kind) 'convert-skip)) + (plist-get out :issues))))) + +(ert-deftest tc-convert-check-mode-previews-without-writing () + "Check mode reports the conversion but writes nothing." + (let ((out (tc-test--convert tc-test--convert-timed 1 t))) + (should (= 1 (plist-get out :converted))) + (should (equal tc-test--convert-timed (plist-get out :result))) + (should (cl-some (lambda (i) (eq (plist-get i :kind) 'convert-would)) + (plist-get out :issues))))) + +(defconst tc-test--convert-closed-with-deadline + "* Project Open Work +** TODO [#B] Parent task +*** DONE [#C] Ship the panel :feature: +CLOSED: [2026-06-27 Sat 12:50] DEADLINE: <2026-06-30 Tue> +Body 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))) + (should (string-match-p + "^\\*\\*\\* 2026-06-27 Sat @ 12:50:00 [-+][0-9]\\{4\\} Ship the panel$" + res)) + (should-not (string-match-p "CLOSED:" 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 fire). 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)))) |
