Files
rune/tests/test_task_persist.py
T

154 lines
5.7 KiB
Python

"""Tests: task_persist.py surface (dichotomy wave #2479, gap-close).
The module has NO delegation seam (pure Python CLI/plugin module — the
Rust port is a standalone crate driven by tp_parity), so the both-pins
gate is: the module's behavior must be IDENTICAL with RUNE_TASK_PERSIST_CORE
unset and set to "python" (the env the loader family would consult —
task_persist ignores it by design; this pins that the env stays inert).
Every test runs against a sandboxed RUNE_MEMORY_DIR — the real ledger
under /mnt/workdrive/rune/memory is never touched.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "src" / "rune" / "task_persist.py"
@pytest.fixture()
def sandbox(monkeypatch):
sd = Path(os.environ.get("TMPDIR", "/tmp")) / "tp_test_sandbox"
tasks = sd / "tasks"
tasks.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("RUNE_MEMORY_DIR", str(sd))
ledger = tasks / "persisted.json"
if ledger.exists():
ledger.unlink()
return {"sd": sd, "ledger": ledger}
def cli(*argv, env_extra=None, stdin=None):
env = dict(os.environ)
if env_extra:
env.update(env_extra)
return subprocess.run(
[sys.executable, str(SCRIPT), *argv],
capture_output=True, text=True, env=env, input=stdin, timeout=60,
)
def test_import_is_pure_python_surface(sandbox):
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "src" / "rune"))
for name in list(sys.modules):
if name == "task_persist":
del sys.modules[name]
env = os.environ.copy()
sys.path.insert(0, str(ROOT / "src/rune"))
# fresh import under the sandbox; the module reads env at import time
import importlib
task_persist = importlib.import_module("task_persist")
assert task_persist.LEDGER_DIR.endswith(os.path.join("tasks"))
assert task_persist.LEDGER_PATH.endswith("persisted.json")
def test_load_default_shape(sandbox):
ledger = sandbox["ledger"]
data = json.loads(subprocess.run(
[sys.executable, "-c",
"import sys, json; sys.path.insert(0, %r); sys.path.insert(0, %r); "
"import task_persist; print(json.dumps(task_persist.load()))"
% (str(ROOT / "src"), str(SCRIPT.parent))],
capture_output=True, text=True, env=os.environ,
).stdout)
assert data == {"tasks": [], "updated": None}
def test_corrupted_file_recovers(sandbox):
sandbox["ledger"].write_text("{bad json")
data = json.loads(subprocess.run(
[sys.executable, "-c",
"import sys, json; sys.path.insert(0, %r); sys.path.insert(0, %r); "
"import task_persist; print(json.dumps(task_persist.load()))"
% (str(ROOT / "src"), str(SCRIPT.parent))],
capture_output=True, text=True, env=os.environ,
).stdout)
assert data == {"tasks": [], "updated": None, "recovered": True}
def test_add_update_restore_cli_roundtrip(sandbox):
r = cli("add", "ship the port")
assert r.returncode == 0 and "Added T1: ship the port" in r.stdout
r = cli("update", "T1", "--status", "done")
assert "Updated T1" in r.stdout
r = cli("status")
assert "[done ] T1: ship the port" in r.stdout
r = cli("restore")
assert "# 1 persisted tasks (last updated: " in r.stdout
assert '[done ] T1: ship the port' in r.stdout
def test_export_replaces_and_warns_on_wipe(sandbox):
cli("add", "one")
cli("add", "two")
r = cli("export", json.dumps([{"id": "T9", "summary": "fresh"}]))
assert "Exported 1 tasks" in r.stdout
r = cli("export", "[]")
assert "Exported 0 tasks" in r.stdout
assert r.stderr.rstrip() == ("[task_persist] WARNING: export with empty "
"list will wipe 1 existing tasks")
def test_add_usage_and_invalid_choice(sandbox):
r = cli("add")
assert r.returncode == 0
assert "usage: task_persist.py add '<summary>'" in r.stdout
r = cli("bogus")
assert r.returncode == 2
assert "error: argument cmd: invalid choice: 'bogus'" in r.stderr
def test_parent_id_and_auto_id(sandbox):
cli("add", "parent task")
cli("add", "child", "--parent", "T1")
data = json.loads(sandbox["ledger"].read_text())
assert [t["id"] for t in data["tasks"]] == ["T1", "T2"]
assert data["tasks"][1]["parent_id"] == "T1"
def test_env_pin_inert_both_ways(sandbox):
"""Both pins: the CLI surface is IDENTICAL with the (hypothetical)
RUNE_TASK_PERSIST_CORE env unset vs pinned to python — task_persist
has no delegation seam; this proves the env stays inert."""
seeded = json.dumps({"tasks": [{"id": "T1", "summary": "pinned",
"status": "open"}]}, indent=2)
sandbox["ledger"].write_text(seeded)
out = []
for extra in ({}, {"RUNE_TASK_PERSIST_CORE": "python"}):
r1 = cli("status", env_extra=extra)
r2 = cli("restore", env_extra=extra)
r3 = cli("add", "two", env_extra=extra)
r4 = cli("add", "again", env_extra=extra)
ledger_after = sandbox["ledger"].read_text()
# wall-clock keys are NOT part of the both-pins contract (the frozen
# battery pins timestamps against the oracle clock); strip them
doc = json.loads(ledger_after)
for t in doc.get("tasks", []):
t.pop("created", None)
t.pop("updated", None)
doc.pop("updated", None)
out.append((r1.stdout, r2.stdout, r3.stdout, r4.stdout, doc))
# re-seed from the ORIGINAL constant (dropping whatever pin-1's
# writes stamped onto the doc)
sandbox["ledger"].write_text(seeded)
assert out[0] == out[1]
assert "[open ] T1: pinned" in out[0][0]