tasks-toolkit 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scripts/__init__.py +0 -0
- scripts/agents_sync.py +61 -0
- scripts/apply-plan.sh +51 -0
- scripts/build.sh +7 -0
- scripts/codex-task.sh +198 -0
- scripts/install-hooks.sh +9 -0
- scripts/junie-status.sh +52 -0
- scripts/junie-task.sh +183 -0
- scripts/kilo-task.sh +226 -0
- scripts/needs-dotnet-checks.sh +24 -0
- scripts/plan-sprint.sh +92 -0
- scripts/run-task.sh +79 -0
- scripts/run.sh +7 -0
- scripts/run_stats.py +141 -0
- scripts/run_summary.py +94 -0
- scripts/spec-lint.sh +139 -0
- scripts/sprint-auto.sh +79 -0
- scripts/sprint_auto_execute.py +160 -0
- scripts/sprint_auto_preflight.py +309 -0
- scripts/sprint_auto_prepare.py +271 -0
- scripts/sprint_auto_resume.py +153 -0
- scripts/sprint_auto_state.py +191 -0
- scripts/sprint_plan.py +238 -0
- scripts/task-report.sh +45 -0
- scripts/tasks.py +1813 -0
- scripts/tasks_cli.py +14 -0
- scripts/tasks_commands.py +11 -0
- scripts/tasks_init.py +140 -0
- scripts/tasks_mcp.py +309 -0
- scripts/tasks_model.py +173 -0
- scripts/tasks_plan_import.py +269 -0
- scripts/tasks_render.py +148 -0
- scripts/tasks_storage.py +373 -0
- scripts/tasks_watch.py +159 -0
- scripts/test.sh +43 -0
- scripts/test_agent_config.py +97 -0
- scripts/test_plan_sprint.sh +172 -0
- scripts/test_python_fast.sh +26 -0
- scripts/test_python_lint.sh +22 -0
- scripts/test_python_slow.sh +14 -0
- scripts/test_run_stats.py +64 -0
- scripts/test_runners.sh +588 -0
- scripts/test_spec_lint.sh +438 -0
- scripts/test_sprint_auto.py +50 -0
- scripts/test_sprint_auto_execute.py +112 -0
- scripts/test_sprint_auto_preflight.py +143 -0
- scripts/test_sprint_auto_prepare.py +150 -0
- scripts/test_sprint_auto_resume.py +104 -0
- scripts/test_sprint_auto_state.py +157 -0
- scripts/test_tasks.py +1678 -0
- scripts/test_tasks_mcp.py +233 -0
- scripts/test_tasks_plan_import.py +129 -0
- scripts/test_tasks_storage.py +67 -0
- scripts/test_tasks_watch.py +239 -0
- scripts/test_worktree.sh +2112 -0
- scripts/worktree.sh +992 -0
- scripts/worktree_mr.py +133 -0
- scripts/worktree_path.py +70 -0
- scripts/worktree_sprint.py +70 -0
- tasks_toolkit-0.1.3.dist-info/METADATA +121 -0
- tasks_toolkit-0.1.3.dist-info/RECORD +65 -0
- tasks_toolkit-0.1.3.dist-info/WHEEL +5 -0
- tasks_toolkit-0.1.3.dist-info/entry_points.txt +4 -0
- tasks_toolkit-0.1.3.dist-info/licenses/LICENSE +21 -0
- tasks_toolkit-0.1.3.dist-info/top_level.txt +1 -0
scripts/test_tasks.py
ADDED
|
@@ -0,0 +1,1678 @@
|
|
|
1
|
+
"""Tests for scripts/tasks.py. Run: python3 -m unittest scripts/test_tasks.py
|
|
2
|
+
|
|
3
|
+
RULE: Every module-level path constant in tasks.py that points to a real file must be
|
|
4
|
+
redirected to a temp directory in setUp and restored in tearDown. The constants to audit
|
|
5
|
+
are: DATA_FILE, VIEW_DIR, README_FILE, VIEW_FILE, LOCK_FILE, SNAPSHOT_FILE, RECOVERY_DIR — and any new ones added later.
|
|
6
|
+
If a constant is missed, tests will silently overwrite the real tracker files in the
|
|
7
|
+
main checkout, because tasks.py resolves ROOT to the main checkout from inside worktrees.
|
|
8
|
+
The env var VIBE_TASKS_TESTING=1 (set below) provides a safety net in tasks.py that
|
|
9
|
+
refuses to write outside a temp dir when testing.
|
|
10
|
+
"""
|
|
11
|
+
import contextlib
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import tempfile
|
|
19
|
+
import unittest
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from unittest import mock
|
|
22
|
+
|
|
23
|
+
# Safety net: tasks.py checks this flag before any write operation.
|
|
24
|
+
os.environ["VIBE_TASKS_TESTING"] = "1"
|
|
25
|
+
|
|
26
|
+
import tasks
|
|
27
|
+
import tasks_storage
|
|
28
|
+
import tasks_render
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TasksTestCase(unittest.TestCase):
|
|
32
|
+
def setUp(self):
|
|
33
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
34
|
+
root = Path(self.tmp.name)
|
|
35
|
+
self.saved = (tasks.DATA_FILE, tasks.VIEW_DIR, tasks.README_FILE, tasks.VIEW_FILE,
|
|
36
|
+
tasks.LOCK_FILE, tasks.SNAPSHOT_FILE, tasks.RECOVERY_DIR, tasks.BACKUP_DIR)
|
|
37
|
+
tasks.DATA_FILE = root / "tasks" / "tasks.db"
|
|
38
|
+
tasks.VIEW_DIR = root / "tasks"
|
|
39
|
+
tasks.README_FILE = tasks.VIEW_DIR / "README.md"
|
|
40
|
+
tasks.VIEW_FILE = tasks.README_FILE
|
|
41
|
+
tasks.LOCK_FILE = root / "tasks" / ".lock"
|
|
42
|
+
tasks.SNAPSHOT_FILE = root / "tasks" / "tasks.snapshot.json"
|
|
43
|
+
tasks.RECOVERY_DIR = root / "tasks" / "removed"
|
|
44
|
+
tasks.BACKUP_DIR = root / "backup.d"
|
|
45
|
+
tasks_storage.DATA_FILE = tasks.DATA_FILE
|
|
46
|
+
tasks_storage.VIEW_DIR = tasks.VIEW_DIR
|
|
47
|
+
tasks_storage.README_FILE = tasks.README_FILE
|
|
48
|
+
tasks_storage.VIEW_FILE = tasks.VIEW_FILE
|
|
49
|
+
tasks_storage.LOCK_FILE = tasks.LOCK_FILE
|
|
50
|
+
tasks_storage.SNAPSHOT_FILE = tasks.SNAPSHOT_FILE
|
|
51
|
+
tasks_storage.RECOVERY_DIR = tasks.RECOVERY_DIR
|
|
52
|
+
tasks_storage.BACKUP_DIR = tasks.BACKUP_DIR
|
|
53
|
+
tasks_render.VIEW_DIR = tasks.VIEW_DIR
|
|
54
|
+
tasks_render.README_FILE = tasks.README_FILE
|
|
55
|
+
(root / "tasks").mkdir(parents=True, exist_ok=True)
|
|
56
|
+
|
|
57
|
+
def tearDown(self):
|
|
58
|
+
(tasks.DATA_FILE, tasks.VIEW_DIR, tasks.README_FILE, tasks.VIEW_FILE,
|
|
59
|
+
tasks.LOCK_FILE, tasks.SNAPSHOT_FILE, tasks.RECOVERY_DIR, tasks.BACKUP_DIR) = self.saved
|
|
60
|
+
(tasks_storage.DATA_FILE, tasks_storage.VIEW_DIR, tasks_storage.README_FILE, tasks_storage.VIEW_FILE,
|
|
61
|
+
tasks_storage.LOCK_FILE, tasks_storage.SNAPSHOT_FILE, tasks_storage.RECOVERY_DIR, tasks_storage.BACKUP_DIR) = self.saved
|
|
62
|
+
(tasks_render.VIEW_DIR, tasks_render.README_FILE) = (tasks.VIEW_DIR, tasks.README_FILE)
|
|
63
|
+
self.tmp.cleanup()
|
|
64
|
+
|
|
65
|
+
def run_cli(self, *argv):
|
|
66
|
+
out, err = io.StringIO(), io.StringIO()
|
|
67
|
+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
68
|
+
code = tasks.main(list(argv))
|
|
69
|
+
return code, out.getvalue(), err.getvalue()
|
|
70
|
+
|
|
71
|
+
def ok(self, *argv):
|
|
72
|
+
code, out, err = self.run_cli(*argv)
|
|
73
|
+
self.assertEqual(code, 0, err)
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
def status(self, node_id):
|
|
77
|
+
return tasks.effective(tasks.find(tasks.load(), node_id))
|
|
78
|
+
|
|
79
|
+
def build(self):
|
|
80
|
+
self.ok("add-sprint", "Sprint one", "--goal", "g")
|
|
81
|
+
self.ok("add", "1", "Task A")
|
|
82
|
+
self.ok("add", "1.1", "Sub 1")
|
|
83
|
+
self.ok("add", "1.1", "Sub 2")
|
|
84
|
+
self.ok("add", "1", "Task B")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CascadeTests(TasksTestCase):
|
|
88
|
+
def test_ids_are_hierarchical(self):
|
|
89
|
+
self.build()
|
|
90
|
+
ids = [n["id"] for n in tasks.walk(tasks.load()["sprints"])]
|
|
91
|
+
self.assertEqual(ids, ["1", "1.1", "1.1.1", "1.1.2", "1.2"])
|
|
92
|
+
|
|
93
|
+
def test_everything_starts_as_none(self):
|
|
94
|
+
self.build()
|
|
95
|
+
self.assertEqual({self.status(i) for i in ("1", "1.1", "1.2")}, {"none"})
|
|
96
|
+
|
|
97
|
+
def test_started_subtask_makes_task_and_sprint_in_progress(self):
|
|
98
|
+
self.build()
|
|
99
|
+
self.ok("start", "1.1.1")
|
|
100
|
+
self.assertEqual(self.status("1.1"), "in_progress")
|
|
101
|
+
self.assertEqual(self.status("1"), "in_progress")
|
|
102
|
+
self.assertEqual(self.status("1.2"), "none")
|
|
103
|
+
|
|
104
|
+
def test_one_done_subtask_is_in_progress_for_parents(self):
|
|
105
|
+
self.build()
|
|
106
|
+
self.ok("done", "1.1.1")
|
|
107
|
+
self.assertEqual(self.status("1.1"), "in_progress")
|
|
108
|
+
|
|
109
|
+
def test_all_subtasks_done_completes_task_but_not_sprint(self):
|
|
110
|
+
self.build()
|
|
111
|
+
self.ok("done", "1.1.1")
|
|
112
|
+
self.ok("done", "1.1.2")
|
|
113
|
+
self.assertEqual(self.status("1.1"), "done")
|
|
114
|
+
self.assertEqual(self.status("1"), "in_progress")
|
|
115
|
+
self.ok("done", "1.2")
|
|
116
|
+
self.assertEqual(self.status("1"), "done")
|
|
117
|
+
|
|
118
|
+
def test_blocked_wins_over_in_progress_and_done(self):
|
|
119
|
+
self.build()
|
|
120
|
+
self.ok("done", "1.1.1")
|
|
121
|
+
self.ok("start", "1.2")
|
|
122
|
+
self.ok("block", "1.1.2", "--reason", "waiting for API key")
|
|
123
|
+
self.assertEqual(self.status("1.1"), "blocked")
|
|
124
|
+
self.assertEqual(self.status("1"), "blocked")
|
|
125
|
+
|
|
126
|
+
def test_unblocking_restores_derived_status(self):
|
|
127
|
+
self.build()
|
|
128
|
+
self.ok("block", "1.1.1", "--reason", "x")
|
|
129
|
+
self.ok("start", "1.1.1")
|
|
130
|
+
self.assertEqual(self.status("1"), "in_progress")
|
|
131
|
+
self.assertEqual(tasks.find(tasks.load(), "1.1.1")["reason"], "")
|
|
132
|
+
|
|
133
|
+
def test_reopening_a_done_subtask_reopens_parents(self):
|
|
134
|
+
self.build()
|
|
135
|
+
self.ok("done", "1.1", "--all")
|
|
136
|
+
self.assertEqual(self.status("1.1"), "done")
|
|
137
|
+
self.ok("reset", "1.1.2")
|
|
138
|
+
self.assertEqual(self.status("1.1"), "in_progress")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class RuleTests(TasksTestCase):
|
|
142
|
+
def test_blocking_requires_reason(self):
|
|
143
|
+
self.build()
|
|
144
|
+
code, _, err = self.run_cli("status", "1.2", "blocked")
|
|
145
|
+
self.assertEqual(code, 1)
|
|
146
|
+
self.assertIn("--reason", err)
|
|
147
|
+
|
|
148
|
+
def test_parent_status_cannot_be_set_directly(self):
|
|
149
|
+
self.build()
|
|
150
|
+
code, _, err = self.run_cli("status", "1.1", "done")
|
|
151
|
+
self.assertEqual(code, 1)
|
|
152
|
+
self.assertIn("derived", err)
|
|
153
|
+
|
|
154
|
+
def test_polish_aliases(self):
|
|
155
|
+
self.build()
|
|
156
|
+
self.ok("status", "1.2", "w-trakcie")
|
|
157
|
+
self.assertEqual(self.status("1.2"), "in_progress")
|
|
158
|
+
self.ok("status", "1.2", "gotowe")
|
|
159
|
+
self.assertEqual(self.status("1.2"), "done")
|
|
160
|
+
self.ok("status", "1.2", "brak")
|
|
161
|
+
self.assertEqual(self.status("1.2"), "none")
|
|
162
|
+
|
|
163
|
+
def test_subtasks_cannot_have_children(self):
|
|
164
|
+
self.build()
|
|
165
|
+
code, _, err = self.run_cli("add", "1.1.1", "too deep")
|
|
166
|
+
self.assertEqual(code, 1)
|
|
167
|
+
self.assertIn("subtask", err)
|
|
168
|
+
|
|
169
|
+
def test_dependency_cycle_is_rejected(self):
|
|
170
|
+
self.build()
|
|
171
|
+
self.ok("edit", "1.1", "--after", "1.2")
|
|
172
|
+
code, _, err = self.run_cli("edit", "1.2", "--after", "1.1")
|
|
173
|
+
self.assertEqual(code, 1)
|
|
174
|
+
self.assertIn("cycle", err)
|
|
175
|
+
|
|
176
|
+
def test_unknown_dependency_is_rejected(self):
|
|
177
|
+
self.build()
|
|
178
|
+
code, _, _ = self.run_cli("add", "1", "x", "--after", "9.9")
|
|
179
|
+
self.assertEqual(code, 1)
|
|
180
|
+
|
|
181
|
+
def test_removed_dependency_target_is_protected(self):
|
|
182
|
+
self.build()
|
|
183
|
+
self.ok("edit", "1.2", "--after", "1.1")
|
|
184
|
+
code, _, err = self.run_cli("rm", "1.1", "--yes")
|
|
185
|
+
self.assertEqual(code, 1)
|
|
186
|
+
self.assertIn("depends on", err)
|
|
187
|
+
|
|
188
|
+
def test_new_id_is_one_after_the_highest_sibling(self):
|
|
189
|
+
self.build()
|
|
190
|
+
self.ok("rm", "1.2", "--yes")
|
|
191
|
+
self.ok("add", "1", "Task C")
|
|
192
|
+
self.assertEqual(tasks.find(tasks.load(), "1.2")["title"], "Task C")
|
|
193
|
+
self.ok("rm", "1.1", "--yes")
|
|
194
|
+
self.ok("add", "1", "Task D")
|
|
195
|
+
self.assertEqual(tasks.find(tasks.load(), "1.3")["title"], "Task D")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class WorkflowTests(TasksTestCase):
|
|
199
|
+
def test_next_skips_unmet_dependencies_and_finished_work(self):
|
|
200
|
+
self.build()
|
|
201
|
+
self.ok("edit", "1.2", "--after", "1.1")
|
|
202
|
+
self.assertIn("1.1.1", self.ok("next"))
|
|
203
|
+
self.ok("done", "1.1", "--all")
|
|
204
|
+
self.assertIn("1.2", self.ok("next"))
|
|
205
|
+
self.ok("done", "1.2")
|
|
206
|
+
self.assertIn("nothing ready", self.ok("next"))
|
|
207
|
+
|
|
208
|
+
def test_next_filters_by_assignee(self):
|
|
209
|
+
self.build()
|
|
210
|
+
self.ok("edit", "1.2", "--assignee", "kilo")
|
|
211
|
+
self.assertIn("1.2", self.ok("next", "--assignee", "kilo"))
|
|
212
|
+
|
|
213
|
+
def test_view_is_rendered_after_every_change(self):
|
|
214
|
+
self.build()
|
|
215
|
+
self.ok("start", "1.1.1")
|
|
216
|
+
detail = next(tasks.VIEW_DIR.glob("sprint-1-*.md")).read_text(encoding="utf-8")
|
|
217
|
+
self.assertIn("**1.1.1** Sub 1 — 🔄 in progress", detail)
|
|
218
|
+
self.assertIn("**1.1** Task A — 🔄 in progress (0/2)", detail)
|
|
219
|
+
self.ok("block", "1.2", "--reason", "needs decision")
|
|
220
|
+
detail = next(tasks.VIEW_DIR.glob("sprint-1-*.md")).read_text(encoding="utf-8")
|
|
221
|
+
self.assertIn("needs decision", detail)
|
|
222
|
+
readme = tasks.README_FILE.read_text(encoding="utf-8")
|
|
223
|
+
self.assertIn("## ⛔ Blockers", readme)
|
|
224
|
+
self.assertIn("needs decision", readme)
|
|
225
|
+
|
|
226
|
+
def test_description_bullets_render_without_double_dash(self):
|
|
227
|
+
"""Description lines already starting with a list marker are not prefixed twice, and a blank
|
|
228
|
+
description line does not render as a lone "-"."""
|
|
229
|
+
self.build()
|
|
230
|
+
self.ok("edit", "1.2", "-d", "Intro line\n\n- item one\n- item two\n - nested")
|
|
231
|
+
text = next(tasks.VIEW_DIR.glob("sprint-1-*.md")).read_text(encoding="utf-8")
|
|
232
|
+
self.assertNotIn("- - ", text)
|
|
233
|
+
self.assertIn("- item one", text)
|
|
234
|
+
self.assertIn("- item two", text)
|
|
235
|
+
self.assertIn("nested", text)
|
|
236
|
+
self.assertNotIn("\n -\n", text)
|
|
237
|
+
self.assertNotIn("\n - \n", text)
|
|
238
|
+
|
|
239
|
+
def test_check_detects_stale_view(self):
|
|
240
|
+
self.build()
|
|
241
|
+
self.ok("check")
|
|
242
|
+
tasks.VIEW_FILE.write_text("stale", encoding="utf-8")
|
|
243
|
+
code, _, err = self.run_cli("check")
|
|
244
|
+
self.assertEqual(code, 1)
|
|
245
|
+
self.assertIn("out of date", err)
|
|
246
|
+
self.ok("render")
|
|
247
|
+
self.ok("check")
|
|
248
|
+
|
|
249
|
+
def test_notes_and_timestamps(self):
|
|
250
|
+
self.build()
|
|
251
|
+
self.ok("start", "1.2")
|
|
252
|
+
self.ok("note", "1.2", "picked up by kilo")
|
|
253
|
+
self.ok("done", "1.2")
|
|
254
|
+
node = tasks.find(tasks.load(), "1.2")
|
|
255
|
+
self.assertTrue(node["started"] and node["finished"])
|
|
256
|
+
self.assertIn("picked up by kilo", node["notes"][0])
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class DatabaseTests(TasksTestCase):
|
|
260
|
+
def test_failed_change_is_rolled_back(self):
|
|
261
|
+
self.build()
|
|
262
|
+
self.ok("edit", "1.1", "--after", "1.2")
|
|
263
|
+
before = tasks.load()
|
|
264
|
+
code, _, _ = self.run_cli("edit", "1.2", "--after", "1.1") # would create a cycle
|
|
265
|
+
self.assertEqual(code, 1)
|
|
266
|
+
self.assertEqual(tasks.load(), before)
|
|
267
|
+
|
|
268
|
+
def test_state_survives_reopening_the_database(self):
|
|
269
|
+
self.build()
|
|
270
|
+
self.ok("block", "1.2", "--reason", "x")
|
|
271
|
+
self.assertEqual(self.status("1.2"), "blocked")
|
|
272
|
+
self.assertTrue(tasks.DATA_FILE.exists())
|
|
273
|
+
|
|
274
|
+
def test_removing_a_task_cascades_to_children_notes_and_deps(self):
|
|
275
|
+
self.build()
|
|
276
|
+
self.ok("note", "1.1.1", "n")
|
|
277
|
+
self.ok("edit", "1.1.2", "--after", "1.1.1")
|
|
278
|
+
self.ok("rm", "1.1", "--yes")
|
|
279
|
+
conn = tasks.connect()
|
|
280
|
+
try:
|
|
281
|
+
for table in ("notes", "deps"):
|
|
282
|
+
self.assertEqual(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0], 0)
|
|
283
|
+
self.assertEqual(conn.execute("SELECT COUNT(*) FROM nodes").fetchone()[0], 2)
|
|
284
|
+
finally:
|
|
285
|
+
conn.close()
|
|
286
|
+
|
|
287
|
+
def test_export_import_roundtrip(self):
|
|
288
|
+
self.build()
|
|
289
|
+
self.ok("edit", "1.2", "--after", "1.1", "--assignee", "kilo")
|
|
290
|
+
self.ok("note", "1.2", "hello")
|
|
291
|
+
self.ok("block", "1.1.1", "--reason", "r")
|
|
292
|
+
before = tasks.load()
|
|
293
|
+
dump = Path(self.tmp.name) / "dump.json"
|
|
294
|
+
self.ok("export-json", "--file", str(dump))
|
|
295
|
+
self.ok("rm", "1", "--yes")
|
|
296
|
+
self.assertEqual(tasks.load()["sprints"], [])
|
|
297
|
+
self.ok("import-json", str(dump))
|
|
298
|
+
self.assertEqual(tasks.load(), before)
|
|
299
|
+
|
|
300
|
+
def test_list_filters(self):
|
|
301
|
+
self.build()
|
|
302
|
+
self.ok("edit", "1.2", "--assignee", "kilo", "-d", "needle text")
|
|
303
|
+
self.ok("start", "1.2")
|
|
304
|
+
self.assertEqual(self.ok("list", "--assignee", "kilo").count("\n"), 1)
|
|
305
|
+
self.assertIn("1.2", self.ok("list", "--text", "NEEDLE"))
|
|
306
|
+
self.assertNotIn("1.1 ", self.ok("list", "--status", "in_progress", "--leaves"))
|
|
307
|
+
|
|
308
|
+
def test_many_tasks(self):
|
|
309
|
+
self.ok("add-sprint", "Big")
|
|
310
|
+
for i in range(300):
|
|
311
|
+
self.ok("add", "1", f"Task {i}")
|
|
312
|
+
self.ok("done", "1", "--all")
|
|
313
|
+
self.assertEqual(self.status("1"), "done")
|
|
314
|
+
self.assertIn("done: 300", self.ok("stats"))
|
|
315
|
+
self.assertEqual(self.ok("next", "-n", "3"), "nothing ready\n")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class InitTests(TasksTestCase):
|
|
319
|
+
def test_missing_database_is_created_empty_on_first_change(self):
|
|
320
|
+
self.assertFalse(tasks.DATA_FILE.exists())
|
|
321
|
+
code, out, err = self.run_cli("add-sprint", "First")
|
|
322
|
+
self.assertEqual(code, 0)
|
|
323
|
+
self.assertIn("created a new empty task database", err)
|
|
324
|
+
self.assertTrue(tasks.DATA_FILE.exists())
|
|
325
|
+
self.assertEqual(tasks.load()["sprints"][0]["title"], "First")
|
|
326
|
+
|
|
327
|
+
def test_reading_never_creates_the_database(self):
|
|
328
|
+
for argv in (["list"], ["stats"], ["next"], ["check"]):
|
|
329
|
+
self.ok(*argv)
|
|
330
|
+
self.assertFalse(tasks.DATA_FILE.exists())
|
|
331
|
+
|
|
332
|
+
def test_render_refuses_without_a_database(self):
|
|
333
|
+
tasks.VIEW_FILE.write_text("committed view", encoding="utf-8")
|
|
334
|
+
code, _, err = self.run_cli("render")
|
|
335
|
+
self.assertEqual(code, 1)
|
|
336
|
+
self.assertIn("tasks_init.py", err)
|
|
337
|
+
self.assertEqual(tasks.VIEW_FILE.read_text(encoding="utf-8"), "committed view")
|
|
338
|
+
|
|
339
|
+
def test_init_script_creates_schema_and_does_not_overwrite(self):
|
|
340
|
+
import tasks_init
|
|
341
|
+
target = Path(self.tmp.name) / "fresh" / "x.db"
|
|
342
|
+
self.assertEqual(tasks_init.main(["--path", str(target), "--project", "P"]), 0)
|
|
343
|
+
self.assertEqual(tasks_init.main(["--path", str(target)]), 1) # exists, no --force
|
|
344
|
+
conn = tasks.sqlite3.connect(target)
|
|
345
|
+
names = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
346
|
+
conn.close()
|
|
347
|
+
self.assertTrue({"meta", "nodes", "deps", "notes"} <= names)
|
|
348
|
+
self.assertEqual(tasks_init.main(["--path", str(target), "--force"]), 0)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class SnapshotTests(TasksTestCase):
|
|
352
|
+
# SNAPSHOT_FILE is already redirected by the base TasksTestCase.setUp.
|
|
353
|
+
# This class no longer needs its own setUp/tearDown for the snapshot path.
|
|
354
|
+
|
|
355
|
+
def test_snapshot_written_on_change(self):
|
|
356
|
+
"""A change command writes the snapshot file alongside TASKS.md."""
|
|
357
|
+
self.ok("add-sprint", "Sprint one")
|
|
358
|
+
self.assertTrue(tasks.SNAPSHOT_FILE.exists())
|
|
359
|
+
data = tasks.load()
|
|
360
|
+
self.assertEqual(tasks.SNAPSHOT_FILE.read_text(encoding="utf-8"), tasks.snapshot_json(data))
|
|
361
|
+
|
|
362
|
+
def test_snapshot_is_deterministic(self):
|
|
363
|
+
"""Two consecutive snapshots of the same state are byte-identical."""
|
|
364
|
+
self.ok("add-sprint", "S")
|
|
365
|
+
self.ok("add", "1", "Task A")
|
|
366
|
+
self.ok("done", "1.1")
|
|
367
|
+
snap1 = tasks.SNAPSHOT_FILE.read_text(encoding="utf-8")
|
|
368
|
+
self.ok("snapshot")
|
|
369
|
+
snap2 = tasks.SNAPSHOT_FILE.read_text(encoding="utf-8")
|
|
370
|
+
self.assertEqual(snap1, snap2)
|
|
371
|
+
|
|
372
|
+
def test_snapshot_format_has_sorted_keys(self):
|
|
373
|
+
"""Snapshot JSON uses sort_keys=True so output is deterministic."""
|
|
374
|
+
self.ok("add-sprint", "S")
|
|
375
|
+
text = tasks.SNAPSHOT_FILE.read_text(encoding="utf-8")
|
|
376
|
+
# "project" should come before "sprints" alphabetically
|
|
377
|
+
project_pos = text.index('"project"')
|
|
378
|
+
sprints_pos = text.index('"sprints"')
|
|
379
|
+
self.assertLess(project_pos, sprints_pos)
|
|
380
|
+
|
|
381
|
+
def test_rollback_leaves_snapshot_untouched(self):
|
|
382
|
+
"""If a transaction rolls back, the snapshot file is not modified."""
|
|
383
|
+
self.ok("add-sprint", "S")
|
|
384
|
+
self.ok("add", "1", "Task A")
|
|
385
|
+
self.ok("add", "1", "Task B")
|
|
386
|
+
self.ok("edit", "1.1", "--after", "1.2")
|
|
387
|
+
before = tasks.SNAPSHOT_FILE.read_text(encoding="utf-8")
|
|
388
|
+
# This should fail (cycle) and roll back
|
|
389
|
+
code, _, _ = self.run_cli("edit", "1.2", "--after", "1.1")
|
|
390
|
+
self.assertEqual(code, 1)
|
|
391
|
+
after = tasks.SNAPSHOT_FILE.read_text(encoding="utf-8")
|
|
392
|
+
self.assertEqual(before, after)
|
|
393
|
+
|
|
394
|
+
def test_check_fails_when_snapshot_missing(self):
|
|
395
|
+
"""check fails if the DB exists but the snapshot file is missing."""
|
|
396
|
+
self.ok("add-sprint", "S")
|
|
397
|
+
tasks.SNAPSHOT_FILE.unlink()
|
|
398
|
+
code, _, err = self.run_cli("check")
|
|
399
|
+
self.assertEqual(code, 1)
|
|
400
|
+
self.assertIn("snapshot", err)
|
|
401
|
+
|
|
402
|
+
def test_check_fails_when_snapshot_stale(self):
|
|
403
|
+
"""check fails if the snapshot content does not match the DB."""
|
|
404
|
+
self.ok("add-sprint", "S")
|
|
405
|
+
tasks.SNAPSHOT_FILE.write_text('{"version": 2, "project": "old", "sprints": []}\n', encoding="utf-8")
|
|
406
|
+
code, _, err = self.run_cli("check")
|
|
407
|
+
self.assertEqual(code, 1)
|
|
408
|
+
self.assertIn("out of date", err)
|
|
409
|
+
|
|
410
|
+
def test_check_passes_after_change(self):
|
|
411
|
+
"""check passes after a change command that re-writes the snapshot."""
|
|
412
|
+
self.ok("add-sprint", "S")
|
|
413
|
+
self.ok("add", "1", "Task A")
|
|
414
|
+
self.ok("start", "1.1")
|
|
415
|
+
self.ok("check")
|
|
416
|
+
|
|
417
|
+
def test_snapshot_command(self):
|
|
418
|
+
"""Explicit snapshot command writes the file."""
|
|
419
|
+
self.ok("add-sprint", "S")
|
|
420
|
+
tasks.SNAPSHOT_FILE.unlink()
|
|
421
|
+
out = self.ok("snapshot")
|
|
422
|
+
self.assertIn("wrote", out)
|
|
423
|
+
self.assertTrue(tasks.SNAPSHOT_FILE.exists())
|
|
424
|
+
|
|
425
|
+
def test_snapshot_command_no_database(self):
|
|
426
|
+
"""snapshot command fails when there is no database."""
|
|
427
|
+
code, _, err = self.run_cli("snapshot")
|
|
428
|
+
self.assertEqual(code, 1)
|
|
429
|
+
self.assertIn("no database", err)
|
|
430
|
+
|
|
431
|
+
def test_import_json_roundtrip_via_snapshot(self):
|
|
432
|
+
"""Export to snapshot, wipe DB, import from snapshot — state is restored."""
|
|
433
|
+
self.ok("add-sprint", "Sprint one", "--goal", "g")
|
|
434
|
+
self.ok("add", "1", "Task A")
|
|
435
|
+
self.ok("add", "1.1", "Sub 1")
|
|
436
|
+
self.ok("add", "1", "Task B")
|
|
437
|
+
self.ok("done", "1.1.1")
|
|
438
|
+
self.ok("note", "1", "a note")
|
|
439
|
+
self.ok("edit", "1.2", "--after", "1.1")
|
|
440
|
+
before = tasks.load()
|
|
441
|
+
# Save the snapshot content before we wipe the DB (rm would overwrite it)
|
|
442
|
+
backup = Path(self.tmp.name) / "backup.json"
|
|
443
|
+
backup.write_text(tasks.SNAPSHOT_FILE.read_text(encoding="utf-8"), encoding="utf-8")
|
|
444
|
+
# Wipe the database
|
|
445
|
+
self.ok("rm", "1", "--yes")
|
|
446
|
+
self.assertEqual(tasks.load()["sprints"], [])
|
|
447
|
+
# Import from the saved snapshot backup
|
|
448
|
+
self.ok("import-json", str(backup))
|
|
449
|
+
after = tasks.load()
|
|
450
|
+
self.assertEqual(after, before)
|
|
451
|
+
|
|
452
|
+
def test_check_skips_snapshot_when_no_db(self):
|
|
453
|
+
"""check skips snapshot comparison when the DB does not exist (fresh clone)."""
|
|
454
|
+
# No DB created yet — read-only check should pass
|
|
455
|
+
code, out, err = self.run_cli("check")
|
|
456
|
+
self.assertEqual(code, 0)
|
|
457
|
+
self.assertIn("no database", out)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class MoveTests(TasksTestCase):
|
|
461
|
+
"""Tests for the mv command: moving items between parents with subtree renumbering."""
|
|
462
|
+
|
|
463
|
+
def _setup_two_sprints(self):
|
|
464
|
+
"""Create two sprints with tasks and subtasks."""
|
|
465
|
+
self.ok("add-sprint", "Sprint A")
|
|
466
|
+
self.ok("add", "1", "Task A1")
|
|
467
|
+
self.ok("add", "1.1", "Sub A1.1")
|
|
468
|
+
self.ok("add", "1.1", "Sub A1.2")
|
|
469
|
+
self.ok("add", "1", "Task A2")
|
|
470
|
+
self.ok("add-sprint", "Sprint B")
|
|
471
|
+
self.ok("add", "2", "Task B1")
|
|
472
|
+
|
|
473
|
+
def test_move_leaf_across_tasks(self):
|
|
474
|
+
"""Move a leaf subtask from one task to another."""
|
|
475
|
+
self._setup_two_sprints()
|
|
476
|
+
out = self.ok("mv", "1.1.1", "1.2")
|
|
477
|
+
self.assertIn("moved 1.1.1 -> 1.2.1", out)
|
|
478
|
+
data = tasks.load()
|
|
479
|
+
# 1.2 should now have child 1.2.1 (formerly 1.1.1)
|
|
480
|
+
self.assertEqual(tasks.find(data, "1.2.1")["title"], "Sub A1.1")
|
|
481
|
+
# 1.1 should only have 1.1.2
|
|
482
|
+
self.assertEqual(len(tasks.find(data, "1.1")["children"]), 1)
|
|
483
|
+
self.assertEqual(tasks.find(data, "1.1")["children"][0]["id"], "1.1.2")
|
|
484
|
+
|
|
485
|
+
def test_move_task_across_sprints(self):
|
|
486
|
+
"""Move a task with children from one sprint to another."""
|
|
487
|
+
self._setup_two_sprints()
|
|
488
|
+
out = self.ok("mv", "1.1", "2")
|
|
489
|
+
self.assertIn("moved 1.1 -> 2.2", out)
|
|
490
|
+
self.assertIn("1.1.1 -> 2.2.1", out)
|
|
491
|
+
self.assertIn("1.1.2 -> 2.2.2", out)
|
|
492
|
+
data = tasks.load()
|
|
493
|
+
# Sprint 2 should have Task B1 (2.1) and the moved task (2.2)
|
|
494
|
+
sprint_b = tasks.find(data, "2")
|
|
495
|
+
self.assertEqual(len(sprint_b["children"]), 2)
|
|
496
|
+
self.assertEqual(sprint_b["children"][1]["id"], "2.2")
|
|
497
|
+
self.assertEqual(sprint_b["children"][1]["title"], "Task A1")
|
|
498
|
+
# Children should be renumbered
|
|
499
|
+
self.assertEqual(tasks.find(data, "2.2.1")["title"], "Sub A1.1")
|
|
500
|
+
self.assertEqual(tasks.find(data, "2.2.2")["title"], "Sub A1.2")
|
|
501
|
+
# Sprint 1 should only have Task A2
|
|
502
|
+
sprint_a = tasks.find(data, "1")
|
|
503
|
+
self.assertEqual(len(sprint_a["children"]), 1)
|
|
504
|
+
self.assertEqual(sprint_a["children"][0]["id"], "1.2")
|
|
505
|
+
|
|
506
|
+
def test_move_preserves_status(self):
|
|
507
|
+
"""Moving a leaf keeps its status."""
|
|
508
|
+
self._setup_two_sprints()
|
|
509
|
+
self.ok("done", "1.1.1")
|
|
510
|
+
self.ok("mv", "1.1.1", "1.2")
|
|
511
|
+
self.assertEqual(self.status("1.2.1"), "done")
|
|
512
|
+
|
|
513
|
+
def test_move_preserves_notes(self):
|
|
514
|
+
"""Notes follow the moved item."""
|
|
515
|
+
self._setup_two_sprints()
|
|
516
|
+
self.ok("note", "1.1.1", "important note")
|
|
517
|
+
self.ok("mv", "1.1.1", "1.2")
|
|
518
|
+
node = tasks.find(tasks.load(), "1.2.1")
|
|
519
|
+
self.assertIn("important note", node["notes"][0])
|
|
520
|
+
|
|
521
|
+
def test_move_rewrites_dependencies(self):
|
|
522
|
+
"""Dependencies referencing moved items are rewritten."""
|
|
523
|
+
self._setup_two_sprints()
|
|
524
|
+
# External dep: 1.2 depends on 1.1.1
|
|
525
|
+
self.ok("edit", "1.2", "--after", "1.1.1")
|
|
526
|
+
# Internal dep: 1.1.2 depends on 1.1.1 (both in subtree of 1.1)
|
|
527
|
+
self.ok("edit", "1.1.2", "--after", "1.1.1")
|
|
528
|
+
self.ok("mv", "1.1", "2")
|
|
529
|
+
data = tasks.load()
|
|
530
|
+
# 1.1 -> 2.2, 1.1.1 -> 2.2.1, 1.1.2 -> 2.2.2
|
|
531
|
+
# 1.2's dep on 1.1.1 -> 2.2.1 (external dep rewritten)
|
|
532
|
+
self.assertEqual(tasks.find(data, "1.2")["depends_on"], ["2.2.1"])
|
|
533
|
+
# 2.2.2's dep on 2.2.1 (internal dep rewritten)
|
|
534
|
+
self.assertEqual(tasks.find(data, "2.2.2")["depends_on"], ["2.2.1"])
|
|
535
|
+
|
|
536
|
+
def test_move_with_deps_on_both_sides(self):
|
|
537
|
+
"""Move a task with children where deps exist on both sides."""
|
|
538
|
+
self._setup_two_sprints()
|
|
539
|
+
# B1 depends on Sub A1.1
|
|
540
|
+
self.ok("edit", "2.1", "--after", "1.1.1")
|
|
541
|
+
# Sub A1.2 depends on B1
|
|
542
|
+
self.ok("edit", "1.1.2", "--after", "2.1")
|
|
543
|
+
self.ok("mv", "1.1", "2")
|
|
544
|
+
data = tasks.load()
|
|
545
|
+
# 2.1 depends on 2.2.1 (was 1.1.1)
|
|
546
|
+
self.assertEqual(tasks.find(data, "2.1")["depends_on"], ["2.2.1"])
|
|
547
|
+
# 2.2.2 depends on 2.1 (was 1.1.2 depends on 2.1)
|
|
548
|
+
self.assertEqual(tasks.find(data, "2.2.2")["depends_on"], ["2.1"])
|
|
549
|
+
|
|
550
|
+
def test_mv_pos_ordering(self):
|
|
551
|
+
"""--pos places the moved item at the specified position."""
|
|
552
|
+
self._setup_two_sprints()
|
|
553
|
+
self.ok("mv", "1.1.1", "1.2", "--pos", "1")
|
|
554
|
+
data = tasks.load()
|
|
555
|
+
# 1.2.1 should be the moved item
|
|
556
|
+
self.assertEqual(tasks.find(data, "1.2.1")["title"], "Sub A1.1")
|
|
557
|
+
|
|
558
|
+
def test_mv_cannot_move_sprint(self):
|
|
559
|
+
"""Moving a sprint (level 1) is rejected."""
|
|
560
|
+
self._setup_two_sprints()
|
|
561
|
+
code, _, err = self.run_cli("mv", "1", "2")
|
|
562
|
+
self.assertEqual(code, 1)
|
|
563
|
+
self.assertIn("cannot move a sprint", err)
|
|
564
|
+
|
|
565
|
+
def test_mv_cannot_move_into_own_subtree(self):
|
|
566
|
+
"""Moving a node into its own descendant is rejected."""
|
|
567
|
+
self._setup_two_sprints()
|
|
568
|
+
code, _, err = self.run_cli("mv", "1.1", "1.1.1")
|
|
569
|
+
self.assertEqual(code, 1)
|
|
570
|
+
# The error could be "own subtree" or "subtask" depending on which check fires first
|
|
571
|
+
self.assertTrue("own subtree" in err or "subtask" in err, err)
|
|
572
|
+
|
|
573
|
+
def test_mv_cannot_exceed_max_depth(self):
|
|
574
|
+
"""Moving a task with subtasks under another task is rejected (would exceed depth 3)."""
|
|
575
|
+
self._setup_two_sprints()
|
|
576
|
+
code, _, err = self.run_cli("mv", "1.1", "2.1")
|
|
577
|
+
self.assertEqual(code, 1)
|
|
578
|
+
self.assertIn("max depth", err)
|
|
579
|
+
|
|
580
|
+
def test_mv_same_parent_without_pos(self):
|
|
581
|
+
"""Moving to the same parent without --pos is rejected."""
|
|
582
|
+
self._setup_two_sprints()
|
|
583
|
+
code, _, err = self.run_cli("mv", "1.1.1", "1.1")
|
|
584
|
+
self.assertEqual(code, 1)
|
|
585
|
+
self.assertIn("already under", err)
|
|
586
|
+
|
|
587
|
+
def test_mv_same_parent_with_pos_is_refused_and_loses_nothing(self):
|
|
588
|
+
"""Reordering siblings is refused (ids encode the position); the subtree, notes and db stay intact.
|
|
589
|
+
|
|
590
|
+
Regression: it used to DELETE the node (cascading to its subtree) and re-insert only the root.
|
|
591
|
+
"""
|
|
592
|
+
self._setup_two_sprints()
|
|
593
|
+
self.ok("add", "1.1", "sibling-with-children")
|
|
594
|
+
self.ok("add", "1.2", "child of 1.2")
|
|
595
|
+
self.ok("note", "1.2.1", "keep me")
|
|
596
|
+
before = tasks.export_data() if hasattr(tasks, "export_data") else tasks.load()
|
|
597
|
+
code, _, err = self.run_cli("mv", "1.2", "1", "--pos", "1")
|
|
598
|
+
self.assertEqual(code, 1)
|
|
599
|
+
self.assertIn("reordering siblings is not supported", err)
|
|
600
|
+
after = tasks.export_data() if hasattr(tasks, "export_data") else tasks.load()
|
|
601
|
+
self.assertEqual(before, after)
|
|
602
|
+
data = tasks.load()
|
|
603
|
+
self.assertEqual(tasks.find(data, "1.2")["children"][0]["id"], "1.2.1")
|
|
604
|
+
self.assertIn("keep me", tasks.find(data, "1.2.1")["notes"][0])
|
|
605
|
+
|
|
606
|
+
def test_mv_error_leaves_db_unchanged(self):
|
|
607
|
+
"""Every error case leaves the database byte-identical."""
|
|
608
|
+
self._setup_two_sprints()
|
|
609
|
+
self.ok("done", "1.1.1")
|
|
610
|
+
before = tasks.load()
|
|
611
|
+
code, _, _ = self.run_cli("mv", "1", "2") # can't move sprint
|
|
612
|
+
self.assertEqual(code, 1)
|
|
613
|
+
after = tasks.load()
|
|
614
|
+
self.assertEqual(after, before)
|
|
615
|
+
|
|
616
|
+
def test_mv_unknown_source_id(self):
|
|
617
|
+
"""Moving an unknown id is rejected."""
|
|
618
|
+
self._setup_two_sprints()
|
|
619
|
+
code, _, err = self.run_cli("mv", "9.9", "1")
|
|
620
|
+
self.assertEqual(code, 1)
|
|
621
|
+
self.assertIn("no such id", err)
|
|
622
|
+
|
|
623
|
+
def test_mv_unknown_dest_id(self):
|
|
624
|
+
"""Moving to an unknown parent is rejected."""
|
|
625
|
+
self._setup_two_sprints()
|
|
626
|
+
code, _, err = self.run_cli("mv", "1.1", "9")
|
|
627
|
+
self.assertEqual(code, 1)
|
|
628
|
+
self.assertIn("no such id", err)
|
|
629
|
+
|
|
630
|
+
def test_mv_task_to_sprint_root(self):
|
|
631
|
+
"""Move a leaf task from one sprint to another sprint."""
|
|
632
|
+
self._setup_two_sprints()
|
|
633
|
+
out = self.ok("mv", "1.2", "2")
|
|
634
|
+
self.assertIn("moved 1.2 -> 2.2", out)
|
|
635
|
+
data = tasks.load()
|
|
636
|
+
self.assertEqual(tasks.find(data, "2.2")["title"], "Task A2")
|
|
637
|
+
|
|
638
|
+
def test_mv_recomputes_parent_status(self):
|
|
639
|
+
"""Parent derived statuses are recomputed after move."""
|
|
640
|
+
self._setup_two_sprints()
|
|
641
|
+
self.ok("done", "1.1.1")
|
|
642
|
+
self.ok("done", "1.1.2")
|
|
643
|
+
self.assertEqual(self.status("1.1"), "done")
|
|
644
|
+
self.ok("mv", "1.1", "2")
|
|
645
|
+
# Sprint 1 has no children now -> none
|
|
646
|
+
# Sprint 2 has 2.1 (none) and 2.2 (done) -> in_progress
|
|
647
|
+
self.assertEqual(self.status("2"), "in_progress")
|
|
648
|
+
|
|
649
|
+
def test_mv_snapshot_and_view_in_sync(self):
|
|
650
|
+
"""After mv, TASKS.md and snapshot are in sync."""
|
|
651
|
+
self._setup_two_sprints()
|
|
652
|
+
self.ok("mv", "1.1", "2")
|
|
653
|
+
self.ok("check")
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
class BulkEditTests(TasksTestCase):
|
|
657
|
+
"""Tests for bulk edit and multi-id status commands."""
|
|
658
|
+
|
|
659
|
+
def test_bulk_status_all_done(self):
|
|
660
|
+
"""Setting status on multiple ids in one transaction."""
|
|
661
|
+
self.build()
|
|
662
|
+
out = self.ok("done", "1.1.1", "1.1.2", "1.2")
|
|
663
|
+
self.assertIn("1.1.1", out)
|
|
664
|
+
self.assertIn("1.2", out)
|
|
665
|
+
self.assertEqual(self.status("1"), "done")
|
|
666
|
+
|
|
667
|
+
def test_bulk_status_one_bad_id(self):
|
|
668
|
+
"""If one id is bad, nothing changes (all-or-nothing)."""
|
|
669
|
+
self.build()
|
|
670
|
+
self.ok("start", "1.1.1")
|
|
671
|
+
before = tasks.load()
|
|
672
|
+
code, _, _ = self.run_cli("done", "1.1.2", "9.9")
|
|
673
|
+
self.assertEqual(code, 1)
|
|
674
|
+
after = tasks.load()
|
|
675
|
+
self.assertEqual(after, before)
|
|
676
|
+
|
|
677
|
+
def test_bulk_edit_assignee(self):
|
|
678
|
+
"""Edit assignee on multiple items."""
|
|
679
|
+
self.build()
|
|
680
|
+
out = self.ok("edit", "1.1", "1.2", "-a", "junie")
|
|
681
|
+
self.assertIn("1.1", out)
|
|
682
|
+
self.assertIn("1.2", out)
|
|
683
|
+
data = tasks.load()
|
|
684
|
+
self.assertEqual(tasks.find(data, "1.1")["assignee"], "junie")
|
|
685
|
+
self.assertEqual(tasks.find(data, "1.2")["assignee"], "junie")
|
|
686
|
+
|
|
687
|
+
def test_bulk_edit_title_rejected(self):
|
|
688
|
+
"""--title with multiple ids is rejected."""
|
|
689
|
+
self.build()
|
|
690
|
+
code, _, err = self.run_cli("edit", "1.1", "1.2", "--title", "X")
|
|
691
|
+
self.assertEqual(code, 1)
|
|
692
|
+
self.assertIn("--title", err)
|
|
693
|
+
|
|
694
|
+
def test_bulk_edit_one_bad_id(self):
|
|
695
|
+
"""Bulk edit with one bad id leaves nothing changed."""
|
|
696
|
+
self.build()
|
|
697
|
+
before = tasks.load()
|
|
698
|
+
code, _, _ = self.run_cli("edit", "1.1", "9.9", "-a", "junie")
|
|
699
|
+
self.assertEqual(code, 1)
|
|
700
|
+
after = tasks.load()
|
|
701
|
+
self.assertEqual(after, before)
|
|
702
|
+
|
|
703
|
+
def test_bulk_start(self):
|
|
704
|
+
"""Start multiple items at once."""
|
|
705
|
+
self.build()
|
|
706
|
+
self.ok("start", "1.1.1", "1.2")
|
|
707
|
+
self.assertEqual(self.status("1.1.1"), "in_progress")
|
|
708
|
+
self.assertEqual(self.status("1.2"), "in_progress")
|
|
709
|
+
|
|
710
|
+
def test_bulk_reset(self):
|
|
711
|
+
"""Reset multiple items at once."""
|
|
712
|
+
self.build()
|
|
713
|
+
self.ok("done", "1.1.1", "1.2")
|
|
714
|
+
self.ok("reset", "1.1.1", "1.2")
|
|
715
|
+
self.assertEqual(self.status("1.1.1"), "none")
|
|
716
|
+
self.assertEqual(self.status("1.2"), "none")
|
|
717
|
+
|
|
718
|
+
def test_bulk_status_command(self):
|
|
719
|
+
"""The status command with multiple ids — status is the last positional."""
|
|
720
|
+
self.build()
|
|
721
|
+
self.ok("status", "1.1.1", "1.2", "in_progress")
|
|
722
|
+
self.assertEqual(self.status("1.1.1"), "in_progress")
|
|
723
|
+
self.assertEqual(self.status("1.2"), "in_progress")
|
|
724
|
+
|
|
725
|
+
def test_single_id_behavior_unchanged(self):
|
|
726
|
+
"""Single-id status/edit still works as before."""
|
|
727
|
+
self.build()
|
|
728
|
+
self.ok("start", "1.1.1")
|
|
729
|
+
self.ok("edit", "1.2", "-a", "kilo")
|
|
730
|
+
self.assertEqual(self.status("1.1.1"), "in_progress")
|
|
731
|
+
self.assertEqual(tasks.find(tasks.load(), "1.2")["assignee"], "kilo")
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
class OriginalSyntaxRegressionTests(TasksTestCase):
|
|
735
|
+
"""Regression tests that run the original documented one-liners through tasks.main()."""
|
|
736
|
+
|
|
737
|
+
def test_status_positional_single_id(self):
|
|
738
|
+
"""status 1.1 in_progress — original positional syntax."""
|
|
739
|
+
self.build()
|
|
740
|
+
self.ok("status", "1.2", "in_progress")
|
|
741
|
+
self.assertEqual(self.status("1.2"), "in_progress")
|
|
742
|
+
|
|
743
|
+
def test_status_positional_with_cascade(self):
|
|
744
|
+
"""status 1.1 done --all — cascade down with positional syntax."""
|
|
745
|
+
self.build()
|
|
746
|
+
self.ok("status", "1.1", "done", "--all")
|
|
747
|
+
self.assertEqual(self.status("1.1"), "done")
|
|
748
|
+
self.assertEqual(self.status("1.1.1"), "done")
|
|
749
|
+
self.assertEqual(self.status("1.1.2"), "done")
|
|
750
|
+
|
|
751
|
+
def test_status_positional_blocked_with_reason(self):
|
|
752
|
+
"""status 1.2 blocked --reason x — blocked with reason."""
|
|
753
|
+
self.build()
|
|
754
|
+
self.ok("status", "1.2", "blocked", "--reason", "x")
|
|
755
|
+
self.assertEqual(self.status("1.2"), "blocked")
|
|
756
|
+
|
|
757
|
+
def test_start_shortcut(self):
|
|
758
|
+
"""start 1.1 — original start shortcut."""
|
|
759
|
+
self.build()
|
|
760
|
+
self.ok("start", "1.2")
|
|
761
|
+
self.assertEqual(self.status("1.2"), "in_progress")
|
|
762
|
+
|
|
763
|
+
def test_done_shortcut(self):
|
|
764
|
+
"""done 1.1 — original done shortcut."""
|
|
765
|
+
self.build()
|
|
766
|
+
self.ok("done", "1.2")
|
|
767
|
+
self.assertEqual(self.status("1.2"), "done")
|
|
768
|
+
|
|
769
|
+
def test_status_too_few_positionals(self):
|
|
770
|
+
"""status with fewer than 2 positionals is a usage error."""
|
|
771
|
+
self.build()
|
|
772
|
+
code, _, err = self.run_cli("status", "1.2")
|
|
773
|
+
self.assertEqual(code, 1)
|
|
774
|
+
self.assertIn("usage", err)
|
|
775
|
+
|
|
776
|
+
def test_status_no_positionals(self):
|
|
777
|
+
"""status with no positionals is a usage error."""
|
|
778
|
+
self.build()
|
|
779
|
+
code, _, err = self.run_cli("status")
|
|
780
|
+
self.assertEqual(code, 1)
|
|
781
|
+
self.assertIn("usage", err)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
class RootResolutionTests(unittest.TestCase):
|
|
785
|
+
def setUp(self):
|
|
786
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
787
|
+
self.main = Path(self.tmp.name).resolve() / "main"
|
|
788
|
+
self.main.mkdir()
|
|
789
|
+
self.addCleanup(self.tmp.cleanup)
|
|
790
|
+
|
|
791
|
+
def git(self, *args, cwd=None):
|
|
792
|
+
import subprocess
|
|
793
|
+
subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], cwd=cwd or self.main,
|
|
794
|
+
check=True, capture_output=True)
|
|
795
|
+
|
|
796
|
+
def test_git_common_dir_finds_main_from_any_worktree_depth(self):
|
|
797
|
+
import shutil
|
|
798
|
+
self.git("init", "-q")
|
|
799
|
+
(self.main / "README").write_text("x")
|
|
800
|
+
self.git("add", "-A")
|
|
801
|
+
self.git("commit", "-qm", "init")
|
|
802
|
+
deep = self.main / "tmp" / "a" / "b" / "wt"
|
|
803
|
+
self.git("worktree", "add", "-q", "-b", "agent/deep", str(deep))
|
|
804
|
+
shutil.copytree(Path(tasks.__file__).parent, deep / "scripts",
|
|
805
|
+
ignore=shutil.ignore_patterns("__pycache__"))
|
|
806
|
+
self.assertEqual(tasks.tasks_init.resolve_root(deep / "scripts"), self.main)
|
|
807
|
+
self.assertEqual(tasks.tasks_init.resolve_root(deep), self.main)
|
|
808
|
+
self.assertEqual(tasks.tasks_init.resolve_root(self.main), self.main)
|
|
809
|
+
import subprocess
|
|
810
|
+
import sys
|
|
811
|
+
run = subprocess.run([sys.executable, str(deep / "scripts" / "tasks.py"), "add-sprint", "From worktree"],
|
|
812
|
+
capture_output=True, text=True, env={"PATH": __import__("os").environ["PATH"]})
|
|
813
|
+
self.assertEqual(run.returncode, 0, run.stderr)
|
|
814
|
+
self.assertTrue((self.main / "tasks" / "tasks.db").exists())
|
|
815
|
+
self.assertTrue((self.main / "tasks" / "README.md").exists())
|
|
816
|
+
self.assertFalse((deep / "tasks").exists())
|
|
817
|
+
|
|
818
|
+
def test_fallback_without_git_uses_nearest_tmp_ancestor(self):
|
|
819
|
+
(self.main / ".git").mkdir()
|
|
820
|
+
nested = self.main / "tmp" / "worktrees" / "x" / "sub"
|
|
821
|
+
nested.mkdir(parents=True)
|
|
822
|
+
self.assertEqual(tasks.tasks_init.resolve_root(nested, use_git=False), self.main)
|
|
823
|
+
|
|
824
|
+
def test_plain_checkout_resolves_to_itself(self):
|
|
825
|
+
self.assertEqual(tasks.tasks_init.resolve_root(self.main, use_git=False), self.main)
|
|
826
|
+
|
|
827
|
+
def test_environment_override_wins(self):
|
|
828
|
+
import os
|
|
829
|
+
os.environ["VIBE_TASKS_ROOT"] = str(self.main)
|
|
830
|
+
self.addCleanup(os.environ.pop, "VIBE_TASKS_ROOT", None)
|
|
831
|
+
self.assertEqual(tasks.tasks_init.resolve_root(Path("/")), self.main)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
class RealFileProtectionTests(unittest.TestCase):
|
|
835
|
+
"""Regression tests: the test suite must not overwrite real tracker files.
|
|
836
|
+
|
|
837
|
+
These tests verify that setUp/tearDown redirects ALL module-level path constants
|
|
838
|
+
(DATA_FILE, VIEW_FILE, LOCK_FILE, SNAPSHOT_FILE) so no test data leaks into the
|
|
839
|
+
main checkout. See the RULE comment at the top of this file.
|
|
840
|
+
"""
|
|
841
|
+
|
|
842
|
+
def setUp(self):
|
|
843
|
+
# Simulate the full setUp pattern used by TasksTestCase
|
|
844
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
845
|
+
root = Path(self.tmp.name)
|
|
846
|
+
self.saved = (tasks.DATA_FILE, tasks.VIEW_DIR, tasks.README_FILE, tasks.VIEW_FILE,
|
|
847
|
+
tasks.LOCK_FILE, tasks.SNAPSHOT_FILE, tasks.RECOVERY_DIR, tasks.BACKUP_DIR)
|
|
848
|
+
tasks.DATA_FILE = root / "tasks" / "tasks.db"
|
|
849
|
+
tasks.VIEW_DIR = root / "tasks"
|
|
850
|
+
tasks.README_FILE = tasks.VIEW_DIR / "README.md"
|
|
851
|
+
tasks.VIEW_FILE = tasks.README_FILE
|
|
852
|
+
tasks.LOCK_FILE = root / "tasks" / ".lock"
|
|
853
|
+
tasks.SNAPSHOT_FILE = root / "tasks" / "tasks.snapshot.json"
|
|
854
|
+
tasks.RECOVERY_DIR = root / "tasks" / "removed"
|
|
855
|
+
tasks.BACKUP_DIR = root / "backup.d"
|
|
856
|
+
tasks_storage.DATA_FILE = tasks.DATA_FILE
|
|
857
|
+
tasks_storage.VIEW_DIR = tasks.VIEW_DIR
|
|
858
|
+
tasks_storage.README_FILE = tasks.README_FILE
|
|
859
|
+
tasks_storage.VIEW_FILE = tasks.VIEW_FILE
|
|
860
|
+
tasks_storage.LOCK_FILE = tasks.LOCK_FILE
|
|
861
|
+
tasks_storage.SNAPSHOT_FILE = tasks.SNAPSHOT_FILE
|
|
862
|
+
tasks_storage.RECOVERY_DIR = tasks.RECOVERY_DIR
|
|
863
|
+
tasks_storage.BACKUP_DIR = tasks.BACKUP_DIR
|
|
864
|
+
tasks_render.VIEW_DIR = tasks.VIEW_DIR
|
|
865
|
+
tasks_render.README_FILE = tasks.README_FILE
|
|
866
|
+
(root / "tasks").mkdir(parents=True, exist_ok=True)
|
|
867
|
+
|
|
868
|
+
def tearDown(self):
|
|
869
|
+
(tasks.DATA_FILE, tasks.VIEW_DIR, tasks.README_FILE, tasks.VIEW_FILE,
|
|
870
|
+
tasks.LOCK_FILE, tasks.SNAPSHOT_FILE, tasks.RECOVERY_DIR, tasks.BACKUP_DIR) = self.saved
|
|
871
|
+
(tasks_storage.DATA_FILE, tasks_storage.VIEW_DIR, tasks_storage.README_FILE, tasks_storage.VIEW_FILE,
|
|
872
|
+
tasks_storage.LOCK_FILE, tasks_storage.SNAPSHOT_FILE, tasks_storage.RECOVERY_DIR, tasks_storage.BACKUP_DIR) = self.saved
|
|
873
|
+
(tasks_render.VIEW_DIR, tasks_render.README_FILE) = (tasks.VIEW_DIR, tasks.README_FILE)
|
|
874
|
+
self.tmp.cleanup()
|
|
875
|
+
|
|
876
|
+
def _real_file_stats(self, path):
|
|
877
|
+
try:
|
|
878
|
+
return (path.stat().st_mtime_ns, path.stat().st_size, path.read_bytes())
|
|
879
|
+
except FileNotFoundError:
|
|
880
|
+
return None
|
|
881
|
+
|
|
882
|
+
def test_real_snapshot_unchanged_after_mutating_test(self):
|
|
883
|
+
"""Running a full build() cycle must not change the real snapshot file."""
|
|
884
|
+
real_snapshot = Path(tasks.ROOT) / "tasks" / "tasks.snapshot.json"
|
|
885
|
+
before = self._real_file_stats(real_snapshot)
|
|
886
|
+
|
|
887
|
+
# Simulate a mutating test
|
|
888
|
+
out, err = io.StringIO(), io.StringIO()
|
|
889
|
+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
890
|
+
tasks.main(["add-sprint", "Test sprint"])
|
|
891
|
+
|
|
892
|
+
after = self._real_file_stats(real_snapshot)
|
|
893
|
+
if before is not None:
|
|
894
|
+
self.assertIsNotNone(after, "real snapshot was deleted by test")
|
|
895
|
+
self.assertEqual(before[0], after[0], "real snapshot mtime changed")
|
|
896
|
+
self.assertEqual(before[1], after[1], "real snapshot size changed")
|
|
897
|
+
self.assertEqual(before[2], after[2], "real snapshot content changed")
|
|
898
|
+
|
|
899
|
+
def test_real_tasks_md_unchanged_after_mutating_test(self):
|
|
900
|
+
"""Running a full build() cycle must not change the real TASKS.md."""
|
|
901
|
+
real_view = Path(tasks.ROOT) / "tasks" / "README.md"
|
|
902
|
+
before = self._real_file_stats(real_view)
|
|
903
|
+
|
|
904
|
+
out, err = io.StringIO(), io.StringIO()
|
|
905
|
+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
906
|
+
tasks.main(["add-sprint", "Test sprint"])
|
|
907
|
+
|
|
908
|
+
after = self._real_file_stats(real_view)
|
|
909
|
+
if before is not None:
|
|
910
|
+
self.assertIsNotNone(after, "real TASKS.md was deleted by test")
|
|
911
|
+
self.assertEqual(before[0], after[0], "real TASKS.md mtime changed")
|
|
912
|
+
self.assertEqual(before[1], after[1], "real TASKS.md size changed")
|
|
913
|
+
|
|
914
|
+
def test_real_db_unchanged_after_mutating_test(self):
|
|
915
|
+
"""Running a full build() cycle must not change the real tasks.db."""
|
|
916
|
+
real_db = Path(tasks.ROOT) / "tasks" / "tasks.db"
|
|
917
|
+
before = self._real_file_stats(real_db)
|
|
918
|
+
|
|
919
|
+
out, err = io.StringIO(), io.StringIO()
|
|
920
|
+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
921
|
+
tasks.main(["add-sprint", "Test sprint"])
|
|
922
|
+
|
|
923
|
+
after = self._real_file_stats(real_db)
|
|
924
|
+
if before is not None:
|
|
925
|
+
self.assertIsNotNone(after, "real tasks.db was deleted by test")
|
|
926
|
+
self.assertEqual(before[0], after[0], "real tasks.db mtime changed")
|
|
927
|
+
self.assertEqual(before[1], after[1], "real tasks.db size changed")
|
|
928
|
+
|
|
929
|
+
def test_real_removed_dir_unchanged_after_rm(self):
|
|
930
|
+
"""Running rm --yes must not write into the real tasks/removed directory."""
|
|
931
|
+
real_dir = Path(tasks.ROOT) / "tasks" / "removed"
|
|
932
|
+
before = sorted((p.name, p.read_bytes()) for p in real_dir.glob("*")) if real_dir.exists() else None
|
|
933
|
+
|
|
934
|
+
out, err = io.StringIO(), io.StringIO()
|
|
935
|
+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
936
|
+
tasks.main(["add-sprint", "S"])
|
|
937
|
+
tasks.main(["add", "1", "T"])
|
|
938
|
+
tasks.main(["rm", "1.1", "--yes"])
|
|
939
|
+
|
|
940
|
+
after = sorted((p.name, p.read_bytes()) for p in real_dir.glob("*")) if real_dir.exists() else None
|
|
941
|
+
if before is None:
|
|
942
|
+
self.assertIsNone(after, "real tasks/removed was created by test")
|
|
943
|
+
else:
|
|
944
|
+
self.assertEqual(after, before, "real tasks/removed changed by test")
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
class CancelledStatusTests(TasksTestCase):
|
|
948
|
+
"""Task 3.17: 'cancelled' status, its cascade, and the migration from an older schema."""
|
|
949
|
+
|
|
950
|
+
def test_cancelled_round_trip(self):
|
|
951
|
+
self.build()
|
|
952
|
+
self.ok("status", "1.1.1", "cancelled")
|
|
953
|
+
self.assertEqual(self.status("1.1.1"), "cancelled")
|
|
954
|
+
self.assertIn("🚫", tasks.render(tasks.load()))
|
|
955
|
+
|
|
956
|
+
def test_cancelled_does_not_block_parent_completion(self):
|
|
957
|
+
self.build()
|
|
958
|
+
self.ok("done", "1.1.1")
|
|
959
|
+
self.ok("status", "1.1.2", "cancelled")
|
|
960
|
+
self.ok("done", "1.2")
|
|
961
|
+
self.assertEqual(self.status("1.1"), "done")
|
|
962
|
+
self.assertEqual(self.status("1"), "done")
|
|
963
|
+
|
|
964
|
+
def test_all_cancelled_parent_is_cancelled(self):
|
|
965
|
+
self.build()
|
|
966
|
+
self.ok("status", "1.1.1", "cancelled")
|
|
967
|
+
self.ok("status", "1.1.2", "cancelled")
|
|
968
|
+
self.assertEqual(self.status("1.1"), "cancelled")
|
|
969
|
+
|
|
970
|
+
def test_migration_from_old_schema(self):
|
|
971
|
+
"""A database whose nodes CHECK predates 'cancelled' is rebuilt without losing rows."""
|
|
972
|
+
import sqlite3
|
|
973
|
+
|
|
974
|
+
old = """
|
|
975
|
+
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
976
|
+
CREATE TABLE nodes (
|
|
977
|
+
id TEXT PRIMARY KEY, parent_id TEXT REFERENCES nodes(id) ON DELETE CASCADE,
|
|
978
|
+
position INTEGER NOT NULL, level INTEGER NOT NULL, title TEXT NOT NULL,
|
|
979
|
+
description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'none'
|
|
980
|
+
CHECK (status IN ('none','in_progress','blocked','done')),
|
|
981
|
+
reason TEXT NOT NULL DEFAULT '', assignee TEXT NOT NULL DEFAULT '', goal TEXT NOT NULL DEFAULT '',
|
|
982
|
+
start TEXT NOT NULL DEFAULT '', end TEXT NOT NULL DEFAULT '', created TEXT NOT NULL,
|
|
983
|
+
updated TEXT NOT NULL, started TEXT NOT NULL DEFAULT '', finished TEXT NOT NULL DEFAULT '',
|
|
984
|
+
UNIQUE (parent_id, position));
|
|
985
|
+
CREATE TABLE deps (node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
|
986
|
+
depends_on TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, PRIMARY KEY (node_id, depends_on));
|
|
987
|
+
CREATE TABLE notes (seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
988
|
+
node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, text TEXT NOT NULL);
|
|
989
|
+
"""
|
|
990
|
+
tasks.DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
991
|
+
c = sqlite3.connect(tasks.DATA_FILE)
|
|
992
|
+
c.executescript(old)
|
|
993
|
+
c.execute("INSERT INTO meta (key,value) VALUES ('project','Test')")
|
|
994
|
+
c.execute("INSERT INTO nodes (id,parent_id,position,level,title,created,updated) "
|
|
995
|
+
"VALUES ('1',NULL,1,1,'Sprint','2026-01-01','2026-01-01')")
|
|
996
|
+
c.execute("INSERT INTO nodes (id,parent_id,position,level,title,created,updated) "
|
|
997
|
+
"VALUES ('1.1','1',1,2,'Task','2026-01-01','2026-01-01')")
|
|
998
|
+
c.commit()
|
|
999
|
+
c.close()
|
|
1000
|
+
|
|
1001
|
+
self.ok("status", "1.1", "cancelled")
|
|
1002
|
+
self.assertEqual(self.status("1.1"), "cancelled")
|
|
1003
|
+
self.assertEqual(tasks.find(tasks.load(), "1")["title"], "Sprint")
|
|
1004
|
+
self.assertTrue(tasks.find(tasks.load(), "1.1")["uid"].startswith("T-"))
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
class AtomicWriteTests(TasksTestCase):
|
|
1008
|
+
"""Task 3.18: TASKS.md and the snapshot are written atomically (temp file + os.replace)."""
|
|
1009
|
+
|
|
1010
|
+
def test_write_leaves_no_temp_files(self):
|
|
1011
|
+
self.ok("add-sprint", "S")
|
|
1012
|
+
self.assertEqual(list(tasks.VIEW_FILE.parent.glob("*.tmp")), [])
|
|
1013
|
+
self.assertEqual(list(tasks.SNAPSHOT_FILE.parent.glob("*.tmp")), [])
|
|
1014
|
+
|
|
1015
|
+
def test_failed_replace_keeps_previous_content(self):
|
|
1016
|
+
self.ok("add-sprint", "S")
|
|
1017
|
+
before = tasks.VIEW_FILE.read_text(encoding="utf-8")
|
|
1018
|
+
with mock.patch.object(tasks.os, "replace", side_effect=OSError("boom")):
|
|
1019
|
+
with self.assertRaises(OSError):
|
|
1020
|
+
tasks.write_view(tasks.load())
|
|
1021
|
+
self.assertEqual(tasks.VIEW_FILE.read_text(encoding="utf-8"), before)
|
|
1022
|
+
self.assertEqual(list(tasks.VIEW_FILE.parent.glob("*.tmp")), [])
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
class ViewSplitTests(TasksTestCase):
|
|
1026
|
+
"""Task 8.1: the view is a directory (README index + one file per sprint)."""
|
|
1027
|
+
|
|
1028
|
+
def test_readme_has_links_and_no_inline_sprint(self):
|
|
1029
|
+
self.build()
|
|
1030
|
+
readme = tasks.README_FILE.read_text(encoding="utf-8")
|
|
1031
|
+
self.assertIn("## Sprints", readme)
|
|
1032
|
+
self.assertNotIn("**1.1.1**", readme) # task detail lives in the per-sprint file, not the README
|
|
1033
|
+
self.assertNotIn("## Sprint 1 —", readme)
|
|
1034
|
+
links = re.findall(r"\]\((sprint-[^)]+\.md)\)", readme)
|
|
1035
|
+
self.assertTrue(links, "README has no per-sprint links")
|
|
1036
|
+
for link in links:
|
|
1037
|
+
self.assertTrue((tasks.VIEW_DIR / link).is_file(), f"dangling link {link}")
|
|
1038
|
+
|
|
1039
|
+
def test_sprint_file_holds_the_detail(self):
|
|
1040
|
+
self.build()
|
|
1041
|
+
files = sorted(p.name for p in tasks.VIEW_DIR.glob("sprint-*.md"))
|
|
1042
|
+
self.assertEqual(len(files), 1)
|
|
1043
|
+
self.assertIn("sprint-1-", files[0])
|
|
1044
|
+
detail = (tasks.VIEW_DIR / files[0]).read_text(encoding="utf-8")
|
|
1045
|
+
self.assertIn("**1.1.1** Sub 1", detail)
|
|
1046
|
+
|
|
1047
|
+
def test_stale_sprint_file_is_removed_on_render(self):
|
|
1048
|
+
self.build()
|
|
1049
|
+
stale = tasks.VIEW_DIR / "sprint-99-gone.md"
|
|
1050
|
+
stale.write_text("old\n", encoding="utf-8")
|
|
1051
|
+
self.ok("render")
|
|
1052
|
+
self.assertFalse(stale.exists())
|
|
1053
|
+
|
|
1054
|
+
def test_check_detects_a_stale_sprint_file(self):
|
|
1055
|
+
self.build()
|
|
1056
|
+
victim = next(tasks.VIEW_DIR.glob("sprint-*.md"))
|
|
1057
|
+
victim.write_text("stale\n", encoding="utf-8")
|
|
1058
|
+
code, _, err = self.run_cli("check")
|
|
1059
|
+
self.assertEqual(code, 1)
|
|
1060
|
+
self.assertIn("out of date", err)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
class ReadCommandsTests(TasksTestCase):
|
|
1064
|
+
"""Task 4.1: cheap single-command reads (list subtree + --desc/--notes, show multi/--brief)."""
|
|
1065
|
+
|
|
1066
|
+
def test_list_subtree_with_desc_and_notes(self):
|
|
1067
|
+
self.build()
|
|
1068
|
+
self.ok("edit", "1.2", "-d", "needle description")
|
|
1069
|
+
self.ok("note", "1.2", "a note")
|
|
1070
|
+
out = self.ok("list", "1.2", "--desc", "--notes")
|
|
1071
|
+
self.assertIn("needle description", out)
|
|
1072
|
+
self.assertIn("a note", out)
|
|
1073
|
+
self.assertNotIn("1.1.1", out) # only the requested subtree
|
|
1074
|
+
|
|
1075
|
+
def test_list_without_id_still_prints_everything(self):
|
|
1076
|
+
self.build()
|
|
1077
|
+
self.assertIn("1.1.1", self.ok("list"))
|
|
1078
|
+
|
|
1079
|
+
def test_show_multi_and_brief(self):
|
|
1080
|
+
self.build()
|
|
1081
|
+
brief = self.ok("show", "1.1", "1.2", "--brief")
|
|
1082
|
+
self.assertIn("1.1", brief)
|
|
1083
|
+
self.assertIn("1.2", brief)
|
|
1084
|
+
self.assertNotIn("{", brief) # brief is one line per item, not JSON
|
|
1085
|
+
full = self.ok("show", "1.2")
|
|
1086
|
+
self.assertIn('"title"', full)
|
|
1087
|
+
|
|
1088
|
+
def test_sprint_table(self):
|
|
1089
|
+
self.build()
|
|
1090
|
+
out = self.ok("sprint-table", "1")
|
|
1091
|
+
self.assertIn("| 1.1 |", out)
|
|
1092
|
+
self.assertIn("Task A", out)
|
|
1093
|
+
code, _, err = self.run_cli("sprint-table", "1.1")
|
|
1094
|
+
self.assertEqual(code, 1)
|
|
1095
|
+
self.assertIn("not a sprint", err)
|
|
1096
|
+
|
|
1097
|
+
def test_overview(self):
|
|
1098
|
+
self.build()
|
|
1099
|
+
out = self.ok("overview")
|
|
1100
|
+
self.assertIn("Sprint one", out)
|
|
1101
|
+
self.assertIn("done 0/", out)
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
class ReviewStatusTests(TasksTestCase):
|
|
1105
|
+
"""Task 4.8: the 'review' status (awaiting merge)."""
|
|
1106
|
+
|
|
1107
|
+
def test_review_round_trip(self):
|
|
1108
|
+
self.build()
|
|
1109
|
+
self.ok("status", "1.1.1", "review")
|
|
1110
|
+
self.assertEqual(self.status("1.1.1"), "review")
|
|
1111
|
+
self.assertIn("🔎", tasks.render(tasks.load()))
|
|
1112
|
+
|
|
1113
|
+
def test_review_counts_as_in_progress_for_the_parent(self):
|
|
1114
|
+
self.build()
|
|
1115
|
+
self.ok("status", "1.1.1", "review")
|
|
1116
|
+
self.assertEqual(self.status("1.1"), "in_progress")
|
|
1117
|
+
|
|
1118
|
+
def test_next_never_offers_review(self):
|
|
1119
|
+
self.build()
|
|
1120
|
+
self.ok("status", "1.1.1", "review")
|
|
1121
|
+
self.assertNotIn("1.1.1", self.ok("next", "-n", "5"))
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
class UidLinkTests(TasksTestCase):
|
|
1125
|
+
"""Task 4.2: stable uid + structured links."""
|
|
1126
|
+
|
|
1127
|
+
def test_every_node_gets_a_unique_uid(self):
|
|
1128
|
+
self.build()
|
|
1129
|
+
uids = [n["uid"] for n in tasks.walk(tasks.load()["sprints"])]
|
|
1130
|
+
self.assertTrue(all(u.startswith("T-") and len(u) == 6 for u in uids), uids)
|
|
1131
|
+
self.assertEqual(len(uids), len(set(uids)))
|
|
1132
|
+
|
|
1133
|
+
def test_find_and_show_accept_a_uid(self):
|
|
1134
|
+
self.build()
|
|
1135
|
+
uid = tasks.find(tasks.load(), "1.1")["uid"]
|
|
1136
|
+
self.assertIn("Task A", self.ok("show", uid))
|
|
1137
|
+
|
|
1138
|
+
def test_link_and_unlink_round_trip(self):
|
|
1139
|
+
self.build()
|
|
1140
|
+
self.ok("link", "1.2", "spec", "tmp/tasks/x.md")
|
|
1141
|
+
links = tasks.find(tasks.load(), "1.2")["links"]
|
|
1142
|
+
self.assertEqual([(link["kind"], link["value"]) for link in links], [("spec", "tmp/tasks/x.md")])
|
|
1143
|
+
self.ok("unlink", "1.2", "spec", "tmp/tasks/x.md")
|
|
1144
|
+
self.assertEqual(tasks.find(tasks.load(), "1.2")["links"], [])
|
|
1145
|
+
|
|
1146
|
+
def test_unknown_link_kind_is_rejected(self):
|
|
1147
|
+
self.build()
|
|
1148
|
+
code, _, err = self.run_cli("link", "1.2", "bogus", "x")
|
|
1149
|
+
self.assertEqual(code, 1)
|
|
1150
|
+
self.assertIn("unknown link kind", err)
|
|
1151
|
+
|
|
1152
|
+
def test_uid_survives_mv(self):
|
|
1153
|
+
self.build()
|
|
1154
|
+
uid = tasks.find(tasks.load(), "1.1.1")["uid"]
|
|
1155
|
+
self.ok("mv", "1.1.1", "1.2")
|
|
1156
|
+
self.assertEqual(tasks.find(tasks.load(), "1.2.1")["uid"], uid)
|
|
1157
|
+
|
|
1158
|
+
def test_snapshot_round_trips_uid_and_links(self):
|
|
1159
|
+
self.build()
|
|
1160
|
+
self.ok("link", "1.2", "commit", "abc123")
|
|
1161
|
+
tasks.SNAPSHOT_FILE.write_text(tasks.snapshot_json(tasks.load()), encoding="utf-8")
|
|
1162
|
+
self.ok("import-json", str(tasks.SNAPSHOT_FILE))
|
|
1163
|
+
self.assertEqual(tasks.find(tasks.load(), "1.2")["links"][0]["value"], "abc123")
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
class BriefTests(TasksTestCase):
|
|
1167
|
+
"""Task 4.9: tasks.py brief — the context bundle for one item."""
|
|
1168
|
+
|
|
1169
|
+
def test_brief_shows_deps_and_spec(self):
|
|
1170
|
+
self.build()
|
|
1171
|
+
self.ok("edit", "1.2", "-d", "do the thing")
|
|
1172
|
+
self.ok("edit", "1.2", "--after", "1.1")
|
|
1173
|
+
self.ok("link", "1.2", "spec", "tmp/tasks/1.2.md")
|
|
1174
|
+
self.ok("link", "1.1", "commit", "cafe1234")
|
|
1175
|
+
out = self.ok("brief", "1.2")
|
|
1176
|
+
self.assertIn("do the thing", out)
|
|
1177
|
+
self.assertIn("Depends on", out)
|
|
1178
|
+
self.assertIn("cafe1234", out)
|
|
1179
|
+
self.assertIn("tmp/tasks/1.2.md", out)
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
class AuditLogTests(TasksTestCase):
|
|
1183
|
+
"""Task 4.3: events table + tasks.py log + actor."""
|
|
1184
|
+
|
|
1185
|
+
def test_status_change_writes_an_event(self):
|
|
1186
|
+
self.build()
|
|
1187
|
+
self.ok("start", "1.1.1")
|
|
1188
|
+
out = self.ok("log", "1.1.1")
|
|
1189
|
+
self.assertIn("status", out)
|
|
1190
|
+
self.assertIn("in_progress", out)
|
|
1191
|
+
|
|
1192
|
+
def test_add_and_remove_are_recorded(self):
|
|
1193
|
+
self.build()
|
|
1194
|
+
self.ok("add", "1", "New task")
|
|
1195
|
+
self.assertIn("add", self.ok("log"))
|
|
1196
|
+
|
|
1197
|
+
def test_log_since_filters(self):
|
|
1198
|
+
self.build()
|
|
1199
|
+
self.ok("start", "1.1.1")
|
|
1200
|
+
self.assertIn("no events", self.ok("log", "--since", "2999-01-01"))
|
|
1201
|
+
|
|
1202
|
+
def test_link_is_logged(self):
|
|
1203
|
+
self.build()
|
|
1204
|
+
self.ok("link", "1.2", "commit", "abc")
|
|
1205
|
+
self.assertIn("commit", self.ok("log", "1.2"))
|
|
1206
|
+
|
|
1207
|
+
def test_vibe_actor_is_recorded(self):
|
|
1208
|
+
os.environ["VIBE_ACTOR"] = "tester"
|
|
1209
|
+
self.addCleanup(os.environ.pop, "VIBE_ACTOR", None)
|
|
1210
|
+
self.build()
|
|
1211
|
+
self.ok("start", "1.1.1")
|
|
1212
|
+
self.assertIn("tester", self.ok("log", "1.1.1"))
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
class PlanBatchTests(TasksTestCase):
|
|
1216
|
+
"""Task 4.10: plan-batch picks ready tasks with disjoint scope links."""
|
|
1217
|
+
|
|
1218
|
+
def test_disjoint_scopes_are_batched(self):
|
|
1219
|
+
self.build()
|
|
1220
|
+
self.ok("link", "1.1.1", "scope", "src/a")
|
|
1221
|
+
self.ok("link", "1.1.2", "scope", "src/b")
|
|
1222
|
+
out = self.ok("plan-batch")
|
|
1223
|
+
self.assertIn("1.1.1", out)
|
|
1224
|
+
self.assertIn("1.1.2", out)
|
|
1225
|
+
|
|
1226
|
+
def test_overlapping_scopes_pick_only_one(self):
|
|
1227
|
+
self.build()
|
|
1228
|
+
self.ok("link", "1.1.1", "scope", "src/shared")
|
|
1229
|
+
self.ok("link", "1.1.2", "scope", "src/shared")
|
|
1230
|
+
out = self.ok("plan-batch")
|
|
1231
|
+
self.assertNotEqual("1.1.1" in out, "1.1.2" in out)
|
|
1232
|
+
|
|
1233
|
+
def test_tasks_without_scopes_are_skipped(self):
|
|
1234
|
+
self.build()
|
|
1235
|
+
self.assertIn("no disjoint ready tasks", self.ok("plan-batch"))
|
|
1236
|
+
|
|
1237
|
+
def test_skipped_task_without_scope_is_warned_on_stderr(self):
|
|
1238
|
+
self.build()
|
|
1239
|
+
code, _, err = self.run_cli("plan-batch")
|
|
1240
|
+
self.assertEqual(code, 0)
|
|
1241
|
+
self.assertIn("skipped: no scope link", err)
|
|
1242
|
+
|
|
1243
|
+
def test_nested_scopes_overlap(self):
|
|
1244
|
+
self.build()
|
|
1245
|
+
self.ok("link", "1.1.1", "scope", "src/a")
|
|
1246
|
+
self.ok("link", "1.1.2", "scope", "src/a/b")
|
|
1247
|
+
out = self.ok("plan-batch")
|
|
1248
|
+
self.assertNotEqual("1.1.1" in out, "1.1.2" in out)
|
|
1249
|
+
|
|
1250
|
+
def test_similar_prefix_is_not_overlap(self):
|
|
1251
|
+
self.build()
|
|
1252
|
+
self.ok("link", "1.1.1", "scope", "src/a")
|
|
1253
|
+
self.ok("link", "1.1.2", "scope", "src/ab")
|
|
1254
|
+
out = self.ok("plan-batch")
|
|
1255
|
+
self.assertIn("1.1.1", out)
|
|
1256
|
+
self.assertIn("1.1.2", out)
|
|
1257
|
+
|
|
1258
|
+
def test_batch_prefers_less_conflicted_tasks(self):
|
|
1259
|
+
self.build()
|
|
1260
|
+
self.ok("link", "1.1.1", "scope", "src/a")
|
|
1261
|
+
self.ok("link", "1.1.1", "scope", "src/b")
|
|
1262
|
+
self.ok("link", "1.1.2", "scope", "src/a")
|
|
1263
|
+
self.ok("link", "1.2", "scope", "src/b")
|
|
1264
|
+
|
|
1265
|
+
out = self.ok("plan-batch")
|
|
1266
|
+
|
|
1267
|
+
self.assertNotIn("1.1.1", out)
|
|
1268
|
+
self.assertIn("1.1.2", out)
|
|
1269
|
+
self.assertIn("1.2", out)
|
|
1270
|
+
|
|
1271
|
+
def test_tasks_with_incomplete_dependencies_are_not_batched(self):
|
|
1272
|
+
self.build()
|
|
1273
|
+
self.ok("link", "1.1.1", "scope", "src/a")
|
|
1274
|
+
self.ok("link", "1.1.2", "scope", "src/b")
|
|
1275
|
+
self.ok("edit", "1.1.2", "--after", "1.1.1")
|
|
1276
|
+
|
|
1277
|
+
out = self.ok("plan-batch")
|
|
1278
|
+
|
|
1279
|
+
self.assertIn("1.1.1", out)
|
|
1280
|
+
self.assertNotIn("1.1.2", out)
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
class BackupTests(TasksTestCase):
|
|
1284
|
+
"""Task 4.4: backup clone with history (local bare repo as remote), explicit push/restore."""
|
|
1285
|
+
|
|
1286
|
+
def setUp(self):
|
|
1287
|
+
super().setUp()
|
|
1288
|
+
self.remote = Path(self.tmp.name) / "remote.git"
|
|
1289
|
+
self.backup = Path(self.tmp.name) / "backup"
|
|
1290
|
+
subprocess.run(["git", "init", "--bare", "-q", str(self.remote)], check=True)
|
|
1291
|
+
subprocess.run(["git", "init", "-q", str(self.backup)], check=True)
|
|
1292
|
+
subprocess.run(["git", "-C", str(self.backup), "remote", "add", "origin", str(self.remote)], check=True)
|
|
1293
|
+
tasks.BACKUP_DIR = self.backup
|
|
1294
|
+
|
|
1295
|
+
def test_backup_writes_snapshot_and_commits(self):
|
|
1296
|
+
self.build()
|
|
1297
|
+
self.ok("backup")
|
|
1298
|
+
self.assertTrue((self.backup / "snapshot.json").exists())
|
|
1299
|
+
self.assertTrue((self.backup / "README.md").exists())
|
|
1300
|
+
log = subprocess.run(["git", "-C", str(self.backup), "log", "--oneline"],
|
|
1301
|
+
capture_output=True, text=True).stdout
|
|
1302
|
+
self.assertTrue(log.strip(), "backup made no commit")
|
|
1303
|
+
|
|
1304
|
+
def test_backup_push_reaches_the_remote(self):
|
|
1305
|
+
self.build()
|
|
1306
|
+
self.ok("backup", "--push")
|
|
1307
|
+
remote_log = subprocess.run(["git", "-C", str(self.remote), "log", "--oneline"],
|
|
1308
|
+
capture_output=True, text=True).stdout
|
|
1309
|
+
self.assertTrue(remote_log.strip(), "remote has no commits")
|
|
1310
|
+
|
|
1311
|
+
def test_failed_push_warns_on_stderr(self):
|
|
1312
|
+
self.build()
|
|
1313
|
+
shutil.rmtree(self.remote)
|
|
1314
|
+
code, _, err = self.run_cli("backup", "--push")
|
|
1315
|
+
self.assertEqual(code, 0)
|
|
1316
|
+
self.assertIn("warning: backup: git push failed", err)
|
|
1317
|
+
|
|
1318
|
+
def test_restore_refuses_stale_snapshot_when_pull_fails(self):
|
|
1319
|
+
self.build()
|
|
1320
|
+
self.ok("backup")
|
|
1321
|
+
old_snapshot = (self.backup / "snapshot.json").read_text(encoding="utf-8")
|
|
1322
|
+
self.ok("add-sprint", "Newer sprint")
|
|
1323
|
+
before = tasks.load()
|
|
1324
|
+
(self.backup / "snapshot.json").write_text(old_snapshot, encoding="utf-8")
|
|
1325
|
+
shutil.rmtree(self.remote)
|
|
1326
|
+
|
|
1327
|
+
code, _, err = self.run_cli("restore", "--pull")
|
|
1328
|
+
|
|
1329
|
+
self.assertEqual(code, 1)
|
|
1330
|
+
self.assertIn("warning: backup: git pull failed", err)
|
|
1331
|
+
self.assertIn("refusing to restore", err)
|
|
1332
|
+
self.assertEqual(tasks.load(), before)
|
|
1333
|
+
|
|
1334
|
+
def test_restore_replaces_the_database(self):
|
|
1335
|
+
self.build()
|
|
1336
|
+
self.ok("backup")
|
|
1337
|
+
# Hand-written older snapshot (a single empty sprint) to prove restore replaces the DB.
|
|
1338
|
+
snapshot = {"version": 2, "project": "T", "sprints": [
|
|
1339
|
+
{"id": "1", "uid": "T-zzzz", "title": "Only sprint", "children": []}]}
|
|
1340
|
+
(self.backup / "snapshot.json").write_text(json.dumps(snapshot), encoding="utf-8")
|
|
1341
|
+
self.ok("restore")
|
|
1342
|
+
out = self.ok("list")
|
|
1343
|
+
self.assertIn("Only sprint", out)
|
|
1344
|
+
self.assertNotIn("Task A", out)
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
class Task3_9Tests(TasksTestCase):
|
|
1348
|
+
"""Tests for Task 3.9: TASKS.md is git-ignored; check regenerates it when missing."""
|
|
1349
|
+
|
|
1350
|
+
def test_check_regenerates_missing_tasks_md(self):
|
|
1351
|
+
"""When DB exists but TASKS.md is missing, check regenerates it silently."""
|
|
1352
|
+
self.ok("add-sprint", "S")
|
|
1353
|
+
tasks.VIEW_FILE.unlink()
|
|
1354
|
+
self.assertFalse(tasks.VIEW_FILE.exists())
|
|
1355
|
+
out = self.ok("check")
|
|
1356
|
+
self.assertIn("view regenerated", out)
|
|
1357
|
+
self.assertTrue(tasks.VIEW_FILE.exists())
|
|
1358
|
+
|
|
1359
|
+
def test_check_still_fails_on_stale_tasks_md(self):
|
|
1360
|
+
"""A stale TASKS.md still causes check to fail."""
|
|
1361
|
+
self.ok("add-sprint", "S")
|
|
1362
|
+
tasks.VIEW_FILE.write_text("stale content", encoding="utf-8")
|
|
1363
|
+
code, _, err = self.run_cli("check")
|
|
1364
|
+
self.assertEqual(code, 1)
|
|
1365
|
+
self.assertIn("out of date", err)
|
|
1366
|
+
|
|
1367
|
+
def test_check_skips_when_no_db(self):
|
|
1368
|
+
"""check skips all comparisons when the DB does not exist."""
|
|
1369
|
+
code, out, err = self.run_cli("check")
|
|
1370
|
+
self.assertEqual(code, 0)
|
|
1371
|
+
self.assertIn("no database", out)
|
|
1372
|
+
|
|
1373
|
+
def test_change_command_recreates_deleted_tasks_md(self):
|
|
1374
|
+
"""A change command (e.g., add-sprint) recreates TASKS.md if it was deleted."""
|
|
1375
|
+
self.ok("add-sprint", "S")
|
|
1376
|
+
tasks.VIEW_FILE.unlink()
|
|
1377
|
+
self.assertFalse(tasks.VIEW_FILE.exists())
|
|
1378
|
+
self.ok("add-sprint", "S2")
|
|
1379
|
+
self.assertTrue(tasks.VIEW_FILE.exists())
|
|
1380
|
+
self.assertIn("S2", tasks.VIEW_FILE.read_text(encoding="utf-8"))
|
|
1381
|
+
|
|
1382
|
+
def test_git_ignores_view(self):
|
|
1383
|
+
"""The generated view (tasks/README.md + per-sprint files) is listed in .gitignore."""
|
|
1384
|
+
import subprocess
|
|
1385
|
+
# Find the repo root via git (works in worktrees too)
|
|
1386
|
+
try:
|
|
1387
|
+
result = subprocess.run(
|
|
1388
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
1389
|
+
capture_output=True, text=True, timeout=10
|
|
1390
|
+
)
|
|
1391
|
+
if result.returncode != 0:
|
|
1392
|
+
self.skipTest("not in a git repository")
|
|
1393
|
+
repo_root = Path(result.stdout.strip())
|
|
1394
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
1395
|
+
self.skipTest("git not available")
|
|
1396
|
+
|
|
1397
|
+
gitignore = repo_root / ".gitignore"
|
|
1398
|
+
if gitignore.exists():
|
|
1399
|
+
content = gitignore.read_text(encoding="utf-8")
|
|
1400
|
+
self.assertIn("tasks/README.md", content,
|
|
1401
|
+
".gitignore should ignore tasks/README.md")
|
|
1402
|
+
self.assertIn("tasks/sprint-*.md", content,
|
|
1403
|
+
".gitignore should ignore the per-sprint files")
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
class SafeRmTests(TasksTestCase):
|
|
1407
|
+
"""Safe deletion: preview, --dry-run, confirmation question, recovery file, restore-removed."""
|
|
1408
|
+
|
|
1409
|
+
def rich_build(self):
|
|
1410
|
+
"""Sprint 1 with Task A (subtasks, notes, an internal dep and a started leaf) and Task B."""
|
|
1411
|
+
self.ok("add-sprint", "Sprint one")
|
|
1412
|
+
self.ok("add", "1", "Task A")
|
|
1413
|
+
self.ok("add", "1.1", "Sub 1")
|
|
1414
|
+
self.ok("add", "1.1", "Sub 2")
|
|
1415
|
+
self.ok("add", "1", "Task B")
|
|
1416
|
+
self.ok("note", "1.1", "note root")
|
|
1417
|
+
self.ok("note", "1.1.1", "note sub")
|
|
1418
|
+
self.ok("edit", "1.1.2", "--after", "1.1.1")
|
|
1419
|
+
self.ok("start", "1.1.1")
|
|
1420
|
+
|
|
1421
|
+
def exists(self, node_id):
|
|
1422
|
+
try:
|
|
1423
|
+
tasks.find(tasks.load(), node_id)
|
|
1424
|
+
return True
|
|
1425
|
+
except tasks.TaskError:
|
|
1426
|
+
return False
|
|
1427
|
+
|
|
1428
|
+
def recovery_file(self):
|
|
1429
|
+
files = sorted(tasks.RECOVERY_DIR.glob("*.json"))
|
|
1430
|
+
self.assertEqual(len(files), 1, "expected exactly one recovery file")
|
|
1431
|
+
return files[0]
|
|
1432
|
+
|
|
1433
|
+
def test_preview_content_and_counts(self):
|
|
1434
|
+
self.rich_build()
|
|
1435
|
+
self.ok("edit", "1.2", "--after", "1.1") # outside depends on the subtree
|
|
1436
|
+
code, out, err = self.run_cli("rm", "1.1", "--dry-run")
|
|
1437
|
+
self.assertEqual(code, 0)
|
|
1438
|
+
self.assertIn("1.1 «Task A»", err)
|
|
1439
|
+
self.assertIn("status=in_progress", err)
|
|
1440
|
+
self.assertIn("subtasks=2", err)
|
|
1441
|
+
self.assertIn("notes=1", err)
|
|
1442
|
+
self.assertIn("depends-on-me=1.2", err)
|
|
1443
|
+
self.assertIn("3 items, 2 notes will be deleted", err)
|
|
1444
|
+
self.assertIn("warning: some items are in progress or done, have notes, "
|
|
1445
|
+
"or are depended on from outside", err)
|
|
1446
|
+
self.assertEqual(out, "") # preview goes to stderr; stdout stays machine-readable
|
|
1447
|
+
|
|
1448
|
+
def test_preview_plain_leaf_has_no_warning(self):
|
|
1449
|
+
self.rich_build()
|
|
1450
|
+
code, out, err = self.run_cli("rm", "1.2", "--dry-run")
|
|
1451
|
+
self.assertEqual(code, 0)
|
|
1452
|
+
self.assertIn("1.2 «Task B» status=none subtasks=0 notes=0", err)
|
|
1453
|
+
self.assertIn("1 items, 0 notes will be deleted", err)
|
|
1454
|
+
self.assertNotIn("warning:", err)
|
|
1455
|
+
|
|
1456
|
+
def test_dry_run_changes_nothing(self):
|
|
1457
|
+
self.rich_build()
|
|
1458
|
+
before = tasks.load()
|
|
1459
|
+
self.assertFalse(tasks.RECOVERY_DIR.exists())
|
|
1460
|
+
code, _, err = self.run_cli("rm", "1.1", "--dry-run")
|
|
1461
|
+
self.assertEqual(code, 0)
|
|
1462
|
+
self.assertEqual(tasks.load(), before)
|
|
1463
|
+
self.assertFalse(tasks.RECOVERY_DIR.exists())
|
|
1464
|
+
|
|
1465
|
+
def test_non_tty_without_yes_refuses_with_exit_2(self):
|
|
1466
|
+
self.rich_build()
|
|
1467
|
+
before = tasks.load()
|
|
1468
|
+
with mock.patch.object(tasks, "_tty", return_value=False):
|
|
1469
|
+
code, out, err = self.run_cli("rm", "1.1")
|
|
1470
|
+
self.assertEqual(code, 2)
|
|
1471
|
+
self.assertIn("refusing to delete without confirmation: pass --yes (see --dry-run for the preview)",
|
|
1472
|
+
err)
|
|
1473
|
+
self.assertIn("will be deleted", err) # preview always comes first
|
|
1474
|
+
self.assertEqual(tasks.load(), before)
|
|
1475
|
+
self.assertFalse(tasks.RECOVERY_DIR.exists())
|
|
1476
|
+
|
|
1477
|
+
def test_question_typed_id_confirms(self):
|
|
1478
|
+
self.rich_build()
|
|
1479
|
+
with mock.patch.object(tasks, "_tty", return_value=True), \
|
|
1480
|
+
mock.patch.object(tasks, "input", return_value="1.1"):
|
|
1481
|
+
code, out, err = self.run_cli("rm", "1.1")
|
|
1482
|
+
self.assertEqual(code, 0)
|
|
1483
|
+
self.assertIn('Type the id "1.1" to confirm, or press Enter to cancel:', err)
|
|
1484
|
+
self.assertNotIn("cancelled", err)
|
|
1485
|
+
self.assertFalse(self.exists("1.1"))
|
|
1486
|
+
self.assertTrue(tasks.RECOVERY_DIR.exists())
|
|
1487
|
+
|
|
1488
|
+
def test_question_wrong_text_cancels(self):
|
|
1489
|
+
self.rich_build()
|
|
1490
|
+
before = tasks.load()
|
|
1491
|
+
with mock.patch.object(tasks, "_tty", return_value=True), \
|
|
1492
|
+
mock.patch.object(tasks, "input", return_value="nope"):
|
|
1493
|
+
code, out, err = self.run_cli("rm", "1.1")
|
|
1494
|
+
self.assertEqual(code, 1)
|
|
1495
|
+
self.assertIn("cancelled", err)
|
|
1496
|
+
self.assertEqual(tasks.load(), before)
|
|
1497
|
+
self.assertFalse(tasks.RECOVERY_DIR.exists())
|
|
1498
|
+
|
|
1499
|
+
def test_question_empty_answer_cancels(self):
|
|
1500
|
+
self.rich_build()
|
|
1501
|
+
before = tasks.load()
|
|
1502
|
+
with mock.patch.object(tasks, "_tty", return_value=True), \
|
|
1503
|
+
mock.patch.object(tasks, "input", return_value=""):
|
|
1504
|
+
code, out, err = self.run_cli("rm", "1.1")
|
|
1505
|
+
self.assertEqual(code, 1)
|
|
1506
|
+
self.assertIn("cancelled", err)
|
|
1507
|
+
self.assertEqual(tasks.load(), before)
|
|
1508
|
+
|
|
1509
|
+
def test_plain_leaf_accepts_y(self):
|
|
1510
|
+
self.rich_build()
|
|
1511
|
+
with mock.patch.object(tasks, "_tty", return_value=True), \
|
|
1512
|
+
mock.patch.object(tasks, "input", return_value="y"):
|
|
1513
|
+
code, out, err = self.run_cli("rm", "1.2")
|
|
1514
|
+
self.assertEqual(code, 0)
|
|
1515
|
+
self.assertFalse(self.exists("1.2"))
|
|
1516
|
+
|
|
1517
|
+
def test_non_plain_item_requires_typed_id_not_y(self):
|
|
1518
|
+
self.rich_build()
|
|
1519
|
+
before = tasks.load()
|
|
1520
|
+
with mock.patch.object(tasks, "_tty", return_value=True), \
|
|
1521
|
+
mock.patch.object(tasks, "input", return_value="y"):
|
|
1522
|
+
code, out, err = self.run_cli("rm", "1.1") # has children, notes, a started leaf
|
|
1523
|
+
self.assertEqual(code, 1)
|
|
1524
|
+
self.assertIn("cancelled", err)
|
|
1525
|
+
self.assertEqual(tasks.load(), before)
|
|
1526
|
+
|
|
1527
|
+
def test_question_prompt_text(self):
|
|
1528
|
+
self.rich_build()
|
|
1529
|
+
prompt = tasks._rm_question(tasks.load(), ["1.1"])
|
|
1530
|
+
self.assertIn("Delete 1.1 «Task A» and everything below it (3 items, 2 notes)?", prompt)
|
|
1531
|
+
self.assertIn("This cannot be undone from the database (a recovery file is written).", prompt)
|
|
1532
|
+
self.assertIn('Type the id "1.1" to confirm, or press Enter to cancel:', prompt)
|
|
1533
|
+
|
|
1534
|
+
def test_multi_id_requires_word_delete(self):
|
|
1535
|
+
self.rich_build()
|
|
1536
|
+
before = tasks.load()
|
|
1537
|
+
with mock.patch.object(tasks, "_tty", return_value=True), \
|
|
1538
|
+
mock.patch.object(tasks, "input", return_value="y"):
|
|
1539
|
+
code, out, err = self.run_cli("rm", "1.1", "1.2")
|
|
1540
|
+
self.assertEqual(code, 1)
|
|
1541
|
+
self.assertIn("cancelled", err)
|
|
1542
|
+
self.assertEqual(tasks.load(), before)
|
|
1543
|
+
with mock.patch.object(tasks, "_tty", return_value=True), \
|
|
1544
|
+
mock.patch.object(tasks, "input", return_value="delete"):
|
|
1545
|
+
code, out, err = self.run_cli("rm", "1.1", "1.2")
|
|
1546
|
+
self.assertEqual(code, 0)
|
|
1547
|
+
self.assertFalse(self.exists("1.1"))
|
|
1548
|
+
self.assertFalse(self.exists("1.2"))
|
|
1549
|
+
|
|
1550
|
+
def test_yes_skips_question_but_never_preview_or_recovery(self):
|
|
1551
|
+
self.rich_build()
|
|
1552
|
+
code, out, err = self.run_cli("rm", "1.1", "--yes")
|
|
1553
|
+
self.assertEqual(code, 0)
|
|
1554
|
+
self.assertIn("1.1 «Task A»", err) # preview still printed with --yes
|
|
1555
|
+
self.assertIn("removed 1.1 and 2 item(s) below it", out)
|
|
1556
|
+
recovery = self.recovery_file()
|
|
1557
|
+
data = json.loads(recovery.read_text(encoding="utf-8"))
|
|
1558
|
+
self.assertEqual(data["removed"][0]["parent"], "1")
|
|
1559
|
+
node = data["removed"][0]["node"]
|
|
1560
|
+
self.assertEqual(node["id"], "1.1")
|
|
1561
|
+
self.assertEqual(node["children"][0]["id"], "1.1.1")
|
|
1562
|
+
self.assertEqual(node["children"][1]["depends_on"], ["1.1.1"])
|
|
1563
|
+
self.assertIn("note root", node["notes"][0])
|
|
1564
|
+
self.assertIn("note sub", node["children"][0]["notes"][0])
|
|
1565
|
+
self.assertFalse(self.exists("1.1"))
|
|
1566
|
+
self.ok("check") # snapshot and TASKS.md regenerated by the normal mutate path
|
|
1567
|
+
|
|
1568
|
+
def test_multi_id_recovery_filename_contains_ids(self):
|
|
1569
|
+
self.rich_build()
|
|
1570
|
+
self.ok("rm", "1.1", "1.2", "--yes")
|
|
1571
|
+
files = list(tasks.RECOVERY_DIR.glob("*.json"))
|
|
1572
|
+
self.assertEqual(len(files), 1)
|
|
1573
|
+
self.assertIn("1.1", files[0].name)
|
|
1574
|
+
self.assertIn("1.2", files[0].name)
|
|
1575
|
+
|
|
1576
|
+
def test_recovery_write_failure_aborts_deletion(self):
|
|
1577
|
+
self.rich_build()
|
|
1578
|
+
tasks.RECOVERY_DIR.write_text("I am a file, not a directory", encoding="utf-8")
|
|
1579
|
+
before = tasks.load()
|
|
1580
|
+
code, out, err = self.run_cli("rm", "1.1", "--yes")
|
|
1581
|
+
self.assertEqual(code, 1)
|
|
1582
|
+
self.assertIn("could not write recovery file", err)
|
|
1583
|
+
self.assertEqual(tasks.load(), before)
|
|
1584
|
+
self.assertTrue(self.exists("1.1"))
|
|
1585
|
+
|
|
1586
|
+
def test_old_single_id_message_preserved(self):
|
|
1587
|
+
self.build()
|
|
1588
|
+
out = self.ok("rm", "1.2", "--yes")
|
|
1589
|
+
self.assertEqual(out, "removed 1.2 and 0 item(s) below it\n")
|
|
1590
|
+
out = self.ok("rm", "1.1", "--yes")
|
|
1591
|
+
self.assertEqual(out, "removed 1.1 and 2 item(s) below it\n")
|
|
1592
|
+
|
|
1593
|
+
def test_dependency_blocked_rm_writes_no_recovery(self):
|
|
1594
|
+
self.rich_build()
|
|
1595
|
+
self.ok("edit", "1.2", "--after", "1.1")
|
|
1596
|
+
before = tasks.load()
|
|
1597
|
+
code, out, err = self.run_cli("rm", "1.1", "--yes")
|
|
1598
|
+
self.assertEqual(code, 1)
|
|
1599
|
+
self.assertIn("depends on", err)
|
|
1600
|
+
self.assertEqual(tasks.load(), before)
|
|
1601
|
+
self.assertFalse(tasks.RECOVERY_DIR.exists())
|
|
1602
|
+
|
|
1603
|
+
def test_restore_removed_round_trip(self):
|
|
1604
|
+
self.rich_build()
|
|
1605
|
+
self.ok("rm", "1.1", "--yes")
|
|
1606
|
+
recovery = self.recovery_file()
|
|
1607
|
+
out = self.ok("restore-removed", str(recovery))
|
|
1608
|
+
self.assertIn("restored 1.1 -> 1.3", out)
|
|
1609
|
+
self.assertIn("restored 1.1.1 -> 1.3.1", out)
|
|
1610
|
+
self.assertIn("restored 1.1.2 -> 1.3.2", out)
|
|
1611
|
+
data = tasks.load()
|
|
1612
|
+
node = tasks.find(data, "1.3")
|
|
1613
|
+
self.assertEqual(node["title"], "Task A")
|
|
1614
|
+
self.assertIn("note root", node["notes"][0])
|
|
1615
|
+
self.assertEqual(self.status("1.3.1"), "in_progress") # stored status kept
|
|
1616
|
+
self.assertIn("note sub", tasks.find(data, "1.3.1")["notes"][0])
|
|
1617
|
+
self.assertEqual(tasks.find(data, "1.3.2")["depends_on"], ["1.3.1"]) # internal dep mapped
|
|
1618
|
+
self.assertEqual(tasks.find(data, "1.2")["depends_on"], [])
|
|
1619
|
+
self.ok("check")
|
|
1620
|
+
|
|
1621
|
+
def test_restore_skips_missing_dependency_target(self):
|
|
1622
|
+
self.rich_build()
|
|
1623
|
+
self.ok("edit", "1.1.1", "--after", "1.2") # internal dep on an external id
|
|
1624
|
+
self.ok("rm", "1.1", "--yes")
|
|
1625
|
+
recovery = self.recovery_file()
|
|
1626
|
+
self.ok("rm", "1.2", "--yes") # the dep target no longer exists
|
|
1627
|
+
out = self.ok("restore-removed", str(recovery))
|
|
1628
|
+
self.assertIn("skipped dependency:", out)
|
|
1629
|
+
self.assertIn("1.2 (id does not exist)", out)
|
|
1630
|
+
self.assertEqual(tasks.find(tasks.load(), "1.1.1")["depends_on"], [])
|
|
1631
|
+
|
|
1632
|
+
def test_restore_original_parent_missing_requires_parent(self):
|
|
1633
|
+
self.rich_build()
|
|
1634
|
+
self.ok("add-sprint", "Two")
|
|
1635
|
+
self.ok("rm", "1.1", "--yes")
|
|
1636
|
+
recovery = self.recovery_file()
|
|
1637
|
+
self.ok("rm", "1", "--yes") # original parent sprint 1 is gone
|
|
1638
|
+
code, out, err = self.run_cli("restore-removed", str(recovery))
|
|
1639
|
+
self.assertEqual(code, 1)
|
|
1640
|
+
self.assertIn("original parent 1 no longer exists", err)
|
|
1641
|
+
out = self.ok("restore-removed", str(recovery), "--parent", "2")
|
|
1642
|
+
self.assertIn("restored 1.1 -> 2.1", out)
|
|
1643
|
+
self.assertIn("restored 1.1.1 -> 2.1.1", out)
|
|
1644
|
+
|
|
1645
|
+
def test_restore_sprint_with_parent_refused(self):
|
|
1646
|
+
self.rich_build()
|
|
1647
|
+
self.ok("add-sprint", "Two")
|
|
1648
|
+
self.ok("rm", "1", "--yes")
|
|
1649
|
+
recovery = self.recovery_file()
|
|
1650
|
+
code, out, err = self.run_cli("restore-removed", str(recovery), "--parent", "2")
|
|
1651
|
+
self.assertEqual(code, 1)
|
|
1652
|
+
self.assertIn("cannot be restored under", err)
|
|
1653
|
+
|
|
1654
|
+
def test_restore_too_deep_refused(self):
|
|
1655
|
+
self.rich_build()
|
|
1656
|
+
self.ok("add-sprint", "Two")
|
|
1657
|
+
self.ok("add", "2", "Task C")
|
|
1658
|
+
self.ok("rm", "1.1", "--yes")
|
|
1659
|
+
recovery = self.recovery_file()
|
|
1660
|
+
code, out, err = self.run_cli("restore-removed", str(recovery), "--parent", "2.1")
|
|
1661
|
+
self.assertEqual(code, 1)
|
|
1662
|
+
self.assertIn("too deep", err)
|
|
1663
|
+
|
|
1664
|
+
def test_restore_a_sprint(self):
|
|
1665
|
+
self.ok("add-sprint", "S1")
|
|
1666
|
+
self.ok("add", "1", "T")
|
|
1667
|
+
self.ok("rm", "1", "--yes")
|
|
1668
|
+
recovery = self.recovery_file()
|
|
1669
|
+
out = self.ok("restore-removed", str(recovery))
|
|
1670
|
+
self.assertIn("restored 1 -> 1", out)
|
|
1671
|
+
self.assertIn("restored 1.1 -> 1.1", out)
|
|
1672
|
+
data = tasks.load()
|
|
1673
|
+
self.assertEqual(tasks.find(data, "1")["title"], "S1")
|
|
1674
|
+
self.assertEqual(tasks.find(data, "1.1")["title"], "T")
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
if __name__ == "__main__":
|
|
1678
|
+
unittest.main()
|