- 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
223 lines
6.6 KiB
Python
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
|