agent-todo-tracker/tests/test_cli_repl.py
Hermes Agent 7a03de305a feat(box): ADHD-friendly time-boxing mode (issue #16)
- TimeBox model with explicit recoverable BoxState state machine
- Store layer: time_boxes schema, CRUD, active-box recovery
- BoxController: start/pause/resume/interrupt/complete/expire with elapsed math
- CLI  group (start/status/pause/resume/interrupt/complete/abandon/recover/list/focus)
- Live ADHD-friendly focus REPL with single-key commands, calm UX, no-shame quit
- Interruptions logged with timestamp + reason for pattern recognition
- Non-interactive helpers + auto-resume on entry
- 65 tests green, strict vertical RED-GREEN TDD
2026-08-22 21:49:35 +00:00

223 lines
6.6 KiB
Python

"""Tests for the ADHD-friendly live focus REPL (issue #16)."""
import pytest
from agent_todos import cli_repl
from agent_todos.box import BoxController
from agent_todos.store import Store
from agent_todos.timebox import BoxState, TimeBox
def _store(tmp_path, name="r.db"):
return Store(db_path=str(tmp_path / name))
def _box(duration=1500):
return TimeBox(id=None, todo_id=None, duration_seconds=duration)
def _seq(inputs):
"""Return a callable that yields items from `inputs`, then forever returns the last one."""
it = iter(inputs)
state = {"val": None, "done": False}
def _next(timeout):
if state["done"]:
return state["val"]
try:
state["val"] = next(it)
return state["val"]
except StopIteration:
state["done"] = True
return state["val"]
return _next
def test_repl_shows_help_and_headline_on_enter(tmp_path):
store = _store(tmp_path)
box = store.create_timebox(_box())
outputs = []
final = cli_repl.run_repl(
store, box,
wait_input=_seq(["c"]),
display=outputs.append,
tick_interval=0,
)
assert final.state == BoxState.completed
assert any("25:00" in o for o in outputs)
assert any("p" in o and "pause" in o for o in outputs) # help lists commands
def test_repl_auto_starts_fresh_box(tmp_path):
store = _store(tmp_path)
box = store.create_timebox(_box(duration=1500))
assert box.state == BoxState.open
outputs = []
final = cli_repl.run_repl(
store, box,
wait_input=_seq(["c"]),
display=outputs.append,
tick_interval=0,
)
assert final.state == BoxState.completed
assert any("focusing" in o for o in outputs)
def test_repl_auto_resumes_paused_box(tmp_path):
store = _store(tmp_path)
box = store.create_timebox(_box())
ctrl = BoxController(store)
box = ctrl.start(box)
box = ctrl.pause(box)
assert box.state == BoxState.paused
outputs = []
final = cli_repl.run_repl(
store, box,
wait_input=_seq(["c"]),
display=outputs.append,
tick_interval=0,
)
# should have resumed, then completed
assert final.state == BoxState.completed
assert any("focusing" in o for o in outputs)
def test_repl_pause_and_resume(tmp_path):
store = _store(tmp_path)
box = store.create_timebox(_box())
outputs = []
final = cli_repl.run_repl(
store, box,
wait_input=_seq(["p", "r", "c"]),
display=outputs.append,
tick_interval=0,
)
assert final.state == BoxState.completed
assert any("paused" in o.lower() for o in outputs)
assert any("resumed" in o.lower() or "focusing" in o for o in outputs)
def test_repl_interrupt_records_reason(tmp_path):
store = _store(tmp_path)
box = store.create_timebox(_box())
outputs = []
final = cli_repl.run_repl(
store, box,
wait_input=_seq(["i phone call", "c"]),
display=outputs.append,
tick_interval=0,
)
assert final.state == BoxState.completed
assert final.interruptions, "interruptions should be recorded"
assert final.interruptions[-1]["reason"] == "phone call"
def test_repl_interrupt_without_reason_prompts(tmp_path):
store = _store(tmp_path)
box = store.create_timebox(_box())
outputs = []
final = cli_repl.run_repl(
store, box,
wait_input=_seq(["i", "c"]),
display=outputs.append,
tick_interval=0,
)
assert final.state == BoxState.completed
assert any("needs a reason" in o for o in outputs)
def test_repl_quit_abandons_with_no_judgment(tmp_path):
store = _store(tmp_path)
box = store.create_timebox(_box())
outputs = []
final = cli_repl.run_repl(
store, box,
wait_input=_seq(["q"]),
display=outputs.append,
tick_interval=0,
)
assert final.state == BoxState.abandoned
ok_phrase = "it's okay" in " ".join(outputs).lower() or "no judgment" in " ".join(outputs).lower() or "come back" in " ".join(outputs).lower()
assert ok_phrase
def test_repl_auto_expires_when_timer_runs_out(tmp_path):
store = _store(tmp_path)
box = store.create_timebox(_box(duration=3))
ctrl = BoxController(store)
box = ctrl.start(box)
# Force elapsed time far in the past so remaining == 0.
store.update_timebox(box.id, last_started_at="2000-01-01T00:00:00Z")
outputs = []
final = cli_repl.run_repl(
store, box,
wait_input=_seq([None, None]), # never type anything; should auto-expire
display=outputs.append,
tick_interval=0,
)
assert final.state == BoxState.expired
assert any("timer ran out" in o.lower() or "expired" in o.lower() for o in outputs)
def test_repl_unknown_command_shows_error_and_help_hint(tmp_path):
store = _store(tmp_path)
box = store.create_timebox(_box())
outputs = []
final = cli_repl.run_repl(
store, box,
wait_input=_seq(["xyz", "c"]),
display=outputs.append,
tick_interval=0,
)
assert final.state == BoxState.completed
assert any("unknown command" in o for o in outputs)
assert any("h" in o and "help" in o for o in outputs[-5:]) # hint to press h
def test_repl_help_command(tmp_path):
store = _store(tmp_path)
box = store.create_timebox(_box())
outputs = []
final = cli_repl.run_repl(
store, box,
wait_input=_seq(["h", "c"]),
display=outputs.append,
tick_interval=0,
)
assert final.state == BoxState.completed
# help text appears at least twice (on enter + on "h")
help_lines = [o for o in outputs if "commands" in o.lower() and "pause" in o.lower()]
assert len(help_lines) >= 2
def test_repl_refuses_pause_when_already_paused(tmp_path):
store = _store(tmp_path)
box = store.create_timebox(_box())
outputs = []
final = cli_repl.run_repl(
store, box,
wait_input=_seq(["p", "p", "c"]), # pause, then pause again (should error), then complete
display=outputs.append,
tick_interval=0,
)
assert final.state == BoxState.completed
assert any("can't do that" in o for o in outputs)
from agent_todos.models import Priority, Todo
def test_repl_preserves_data_integrity(tmp_path):
"""Existing TODO data must be untouched by the focus session."""
store = _store(tmp_path)
store.create(Todo(id=None, title="my task", priority=Priority.high))
todo_count_before = len(store.list())
box = store.create_timebox(_box())
cli_repl.run_repl(
store, box,
wait_input=_seq(["c"]),
display=lambda _: None,
tick_interval=0,
)
assert len(store.list()) == todo_count_before