tests/test_archive_card.py
Rama master · réplica de lectura del repositorio original.
"""Unit tests for the Exec `archive_card` tool + the shared recurrence clone.
Pure unit tests against the api modules — no live app, no container; the
helpers.DATA_DIR monkeypatch is the same one test_rd_lock.py uses.
What's pinned here is the parity that makes chat-side archiving safe: archiving
through Exec must do what the card dialog's archive button does — keep
`scheduled_day` (the only record of the day the work happened, and what /rd's
calendar counts archived cards on), put the nudge loop to rest, and revive a
recurring card's next occurrence exactly once.
"""
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "api"))
import helpers # noqa: E402
def _seed(data_dir: Path, cards: list) -> None:
(data_dir / "rd.json").write_text(json.dumps(
{"columns": ["rd", "hq", "archives", "exile"], "cards": cards}))
@pytest.fixture
def data_dir(tmp_path, monkeypatch):
monkeypatch.setattr(helpers, "DATA_DIR", tmp_path)
monkeypatch.setattr(helpers, "_ACTIVITY_LOG", tmp_path / "activity_log.json")
helpers._json_cache.clear()
yield tmp_path
helpers._json_cache.clear()
def _cards(data_dir: Path) -> list:
return json.loads((data_dir / "rd.json").read_text())["cards"]
def test_archive_moves_card_and_keeps_scheduled_day(data_dir):
import chat_tools
_seed(data_dir, [{"id": "card-1", "title": "file taxes", "column": "hq",
"scheduled_day": "2026-09-15", "dir_start_min": 600}])
res = chat_tools._tool_archive_card({"id": "card-1"})
assert res["ok"] and res["column"] == "archives"
card = _cards(data_dir)[0]
assert card["column"] == "archives"
# the day the work happened must survive the archive — /rd's calendar reads it
assert card["scheduled_day"] == "2026-09-15"
def test_archive_resolves_the_nudge_loop(data_dir):
import chat_tools
_seed(data_dir, [{"id": "card-1", "title": "file taxes", "column": "hq",
"scheduled_day": "2026-09-15",
"nudge": {"stage": "awaiting", "awaiting_reply": True,
"next_nudge_at": "2026-09-15T11:00:00"}}])
chat_tools._tool_archive_card({"id": "card-1"})
n = _cards(data_dir)[0]["nudge"]
assert n["stage"] == "resolved"
assert n["awaiting_reply"] is False
assert n["next_nudge_at"] is None
def test_archive_unknown_card_is_an_error_not_a_write(data_dir):
import chat_tools
_seed(data_dir, [{"id": "card-1", "title": "x", "column": "hq"}])
assert "error" in chat_tools._tool_archive_card({"id": "card-nope"})
assert _cards(data_dir)[0]["column"] == "hq"
def test_archive_twice_does_not_double_anything(data_dir):
"""Second call is a no-op report — it must not re-log or re-clone."""
import chat_tools
_seed(data_dir, [{"id": "card-1", "title": "weekly review", "column": "hq",
"due_date": "2026-09-15", "recur_type": "week"}])
chat_tools._tool_archive_card({"id": "card-1"})
again = chat_tools._tool_archive_card({"id": "card-1"})
assert again["ok"] and "next_occurrence" not in again
assert len(_cards(data_dir)) == 2 # the archived card + exactly one clone
def test_archive_revives_a_recurring_card(data_dir):
import chat_tools
_seed(data_dir, [{"id": "card-1", "title": "weekly review", "column": "hq",
"due_date": "2026-09-15", "recur_type": "week",
"scheduled_day": "2026-09-15", "dir_start_min": 600,
"nudge": {"stage": "nudging"}}])
res = chat_tools._tool_archive_card({"id": "card-1"})
clone = [c for c in _cards(data_dir) if c["id"] != "card-1"][0]
assert res["next_occurrence"] == clone["due_date"]
assert clone["column"] == "rd"
assert clone["due_date"] > "2026-09-15"
assert clone["scheduled_day"] is None
# each occurrence starts its own loop / placement
assert "nudge" not in clone and "dir_start_min" not in clone
def test_recurring_clone_dedupes_against_an_existing_occurrence(data_dir):
"""The next occurrence already on the board must not be cloned again — a
re-archive would otherwise stack duplicates of the same week."""
nxt = helpers._next_recurrence("2026-09-15", "week")
done = {"id": "card-1", "title": "weekly review", "column": "archives",
"due_date": "2026-09-15", "recur_type": "week"}
existing = {"id": "card-2", "title": "Weekly Review", "column": "rd",
"due_date": nxt, "recur_type": "week"}
assert helpers.recurring_clone(done, [done, existing]) is None
def test_recurring_clone_id_never_collides(data_dir):
"""Two cards archived in the same millisecond must not share an id."""
a = {"id": "card-1", "title": "one", "column": "archives",
"due_date": "2026-09-15", "recur_type": "week"}
b = {"id": "card-2", "title": "two", "column": "archives",
"due_date": "2026-09-15", "recur_type": "week"}
board = [a, b]
c1 = helpers.recurring_clone(a, board)
board = board + [c1]
c2 = helpers.recurring_clone(b, board)
assert c1["id"] != c2["id"]
def test_non_recurring_card_clones_nothing(data_dir):
import chat_tools
_seed(data_dir, [{"id": "card-1", "title": "one-off", "column": "hq",
"due_date": "2026-09-15"}])
res = chat_tools._tool_archive_card({"id": "card-1"})
assert "next_occurrence" not in res
assert len(_cards(data_dir)) == 1
def test_archive_card_is_dispatchable_by_name(data_dir):
"""The handler map is what the model actually reaches — a tool declared in
chat._chat_tools() but missing here answers 'Unknown tool'."""
import chat_tools
_seed(data_dir, [{"id": "card-1", "title": "x", "column": "hq"}])
assert chat_tools._handle_tool("archive_card", {"id": "card-1"})["ok"]
def test_every_declared_tool_has_a_handler():
"""Schema and handler map must stay in sync in BOTH directions."""
import chat
import chat_tools
declared = {t["name"] for t in chat._chat_tools()}
assert declared == set(chat_tools._TOOL_HANDLERS)