168 lines
5.7 KiB
Python
168 lines
5.7 KiB
Python
"""Tests for nested subtasks in the todo tool (parent field).
|
|
|
|
Covers: validation of the parent field, dangling/cyclic parent
|
|
sanitization, merge-mode parent updates, tree-aware post-compression
|
|
injection, and the flat-only reorder guard.
|
|
"""
|
|
|
|
import json
|
|
|
|
from tools.todo_tool import TodoStore, todo_tool
|
|
|
|
|
|
def _item(i, status="pending", parent=None, content=None):
|
|
d = {"id": i, "content": content or f"task {i}", "status": status}
|
|
if parent is not None:
|
|
d["parent"] = parent
|
|
return d
|
|
|
|
|
|
class TestParentValidation:
|
|
def test_parent_preserved(self):
|
|
store = TodoStore()
|
|
items = store.write([_item("a"), _item("a1", parent="a")])
|
|
assert items[1]["parent"] == "a"
|
|
|
|
def test_self_parent_dropped(self):
|
|
store = TodoStore()
|
|
items = store.write([_item("a", parent="a")])
|
|
assert "parent" not in items[0]
|
|
|
|
def test_dangling_parent_dropped(self):
|
|
store = TodoStore()
|
|
items = store.write([_item("a", parent="ghost")])
|
|
assert "parent" not in items[0]
|
|
|
|
def test_cycle_broken(self):
|
|
store = TodoStore()
|
|
items = store.write([_item("a", parent="b"), _item("b", parent="a")])
|
|
# At least one link removed; no item can walk a loop
|
|
by_id = {i["id"]: i for i in items}
|
|
for item in items:
|
|
seen = set()
|
|
node = item
|
|
while node.get("parent"):
|
|
assert node["parent"] not in seen
|
|
seen.add(node["id"])
|
|
node = by_id[node["parent"]]
|
|
|
|
def test_empty_parent_omitted(self):
|
|
store = TodoStore()
|
|
items = store.write([_item("a", parent="")])
|
|
assert "parent" not in items[0]
|
|
|
|
|
|
class TestMergeParent:
|
|
def test_merge_sets_parent(self):
|
|
store = TodoStore()
|
|
store.write([_item("a"), _item("b")])
|
|
items = store.write([{"id": "b", "parent": "a"}], merge=True)
|
|
by_id = {i["id"]: i for i in items}
|
|
assert by_id["b"]["parent"] == "a"
|
|
|
|
def test_merge_clears_parent_with_empty_string(self):
|
|
store = TodoStore()
|
|
store.write([_item("a"), _item("b", parent="a")])
|
|
items = store.write([{"id": "b", "parent": ""}], merge=True)
|
|
by_id = {i["id"]: i for i in items}
|
|
assert "parent" not in by_id["b"]
|
|
|
|
def test_merge_new_child_appended(self):
|
|
store = TodoStore()
|
|
store.write([_item("a")])
|
|
items = store.write([_item("a2", parent="a")], merge=True)
|
|
assert items[-1] == {"id": "a2", "content": "task a2", "status": "pending", "parent": "a"}
|
|
|
|
|
|
class TestNestedInjection:
|
|
def test_children_indented(self):
|
|
store = TodoStore()
|
|
store.write([
|
|
_item("wp1", status="in_progress"),
|
|
_item("t1", parent="wp1"),
|
|
_item("t2", parent="wp1"),
|
|
])
|
|
text = store.format_for_injection()
|
|
assert text is not None
|
|
lines = text.split("\n")
|
|
assert lines[1].startswith("- [>] wp1.")
|
|
assert lines[2].startswith(" - [ ] t1.")
|
|
assert lines[3].startswith(" - [ ] t2.")
|
|
|
|
def test_completed_parent_kept_when_child_active(self):
|
|
store = TodoStore()
|
|
store.write([
|
|
_item("wp1", status="completed"),
|
|
_item("t1", status="pending", parent="wp1"),
|
|
])
|
|
text = store.format_for_injection()
|
|
assert text is not None
|
|
assert "wp1" in text # parent context survives
|
|
assert "[x]" in text
|
|
assert " - [ ] t1." in text
|
|
|
|
def test_finished_subtree_omitted(self):
|
|
store = TodoStore()
|
|
store.write([
|
|
_item("wp1", status="completed"),
|
|
_item("t1", status="completed", parent="wp1"),
|
|
_item("wp2", status="pending"),
|
|
])
|
|
text = store.format_for_injection()
|
|
assert text is not None
|
|
assert "wp1" not in text
|
|
assert "t1" not in text
|
|
assert "wp2" in text
|
|
|
|
def test_all_finished_returns_none(self):
|
|
store = TodoStore()
|
|
store.write([
|
|
_item("a", status="completed"),
|
|
_item("a1", status="cancelled", parent="a"),
|
|
])
|
|
assert store.format_for_injection() is None
|
|
|
|
def test_flat_injection_unchanged(self):
|
|
store = TodoStore()
|
|
store.write([_item("a", status="in_progress"), _item("b")])
|
|
text = store.format_for_injection()
|
|
assert text is not None
|
|
assert text.split("\n")[1] == "- [>] a. task a (in_progress)"
|
|
|
|
|
|
class TestOrderGuard:
|
|
def test_nested_list_keeps_authored_order(self):
|
|
store = TodoStore()
|
|
items = store.write([
|
|
_item("a", status="pending"),
|
|
_item("b", status="in_progress"),
|
|
_item("b1", parent="b"),
|
|
])
|
|
assert [i["id"] for i in items] == ["a", "b", "b1"]
|
|
|
|
def test_flat_list_still_reordered(self):
|
|
store = TodoStore()
|
|
items = store.write([
|
|
_item("a", status="pending"),
|
|
_item("b", status="in_progress"),
|
|
])
|
|
assert [i["id"] for i in items] == ["b", "a"]
|
|
|
|
|
|
class TestToolRoundTrip:
|
|
def test_parent_survives_json(self):
|
|
store = TodoStore()
|
|
out = todo_tool(todos=[_item("a"), _item("a1", parent="a")], store=store)
|
|
data = json.loads(out)
|
|
assert data["todos"][1]["parent"] == "a"
|
|
assert data["summary"]["total"] == 2
|
|
|
|
def test_hydration_replay_preserves_parent(self):
|
|
# Simulate _hydrate_todo_store: write the previous tool result's
|
|
# todos array back into a fresh store in replace mode.
|
|
store = TodoStore()
|
|
out = json.loads(todo_tool(todos=[_item("a"), _item("a1", parent="a")], store=store))
|
|
fresh = TodoStore()
|
|
replayed = fresh.write(out["todos"], merge=False)
|
|
assert replayed[1]["parent"] == "a"
|