- 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
127 lines
4.4 KiB
Python
127 lines
4.4 KiB
Python
import pytest
|
|
|
|
from agent_todos.models import Priority, Status, Todo
|
|
from agent_todos.store import Store
|
|
from agent_todos.timebox import BoxState, TimeBox
|
|
|
|
|
|
def _store(tmp_path, name="tb.db"):
|
|
return Store(db_path=str(tmp_path / name))
|
|
|
|
|
|
# --- schema / round-trip -------------------------------------------------
|
|
|
|
|
|
def test_schema_created_on_init(tmp_path):
|
|
store = _store(tmp_path)
|
|
box = store.create_timebox(
|
|
TimeBox(id=None, todo_id=None, duration_seconds=1500, state=BoxState.open)
|
|
)
|
|
assert box.id is not None
|
|
|
|
|
|
def test_create_with_todo_link(tmp_path):
|
|
store = _store(tmp_path)
|
|
todo = store.create(Todo(id=None, title="focus task", priority=Priority.high))
|
|
box = store.create_timebox(
|
|
TimeBox(id=None, todo_id=todo.id, duration_seconds=900)
|
|
)
|
|
fetched = store.get_timebox(box.id)
|
|
assert fetched.todo_id == todo.id
|
|
assert fetched.duration_seconds == 900
|
|
assert fetched.state == BoxState.open
|
|
|
|
|
|
def test_persists_interruption_and_reason(tmp_path):
|
|
store = _store(tmp_path)
|
|
box = store.create_timebox(
|
|
TimeBox(
|
|
id=None,
|
|
todo_id=None,
|
|
duration_seconds=600,
|
|
state=BoxState.interrupted,
|
|
reason="phone rang",
|
|
interruptions=[{"at": "2026-01-01T00:00:00Z", "reason": "phone rang"}],
|
|
)
|
|
)
|
|
fetched = store.get_timebox(box.id)
|
|
assert fetched.reason == "phone rang"
|
|
assert fetched.interruptions == [{"at": "2026-01-01T00:00:00Z", "reason": "phone rang"}]
|
|
|
|
|
|
def test_list_active_excludes_terminal(tmp_path):
|
|
store = _store(tmp_path)
|
|
store.create_timebox(TimeBox(id=None, todo_id=None, duration_seconds=600, state=BoxState.open))
|
|
store.create_timebox(TimeBox(id=None, todo_id=None, duration_seconds=600, state=BoxState.running))
|
|
store.create_timebox(TimeBox(id=None, todo_id=None, duration_seconds=600, state=BoxState.completed))
|
|
store.create_timebox(TimeBox(id=None, todo_id=None, duration_seconds=600, state=BoxState.abandoned))
|
|
active = store.list_timeboxes(active_only=True)
|
|
states = {b.state for b in active}
|
|
assert BoxState.completed not in states
|
|
assert BoxState.abandoned not in states
|
|
assert BoxState.expired not in states
|
|
assert states <= {BoxState.open, BoxState.running, BoxState.paused, BoxState.interrupted}
|
|
|
|
|
|
# --- recovery ------------------------------------------------------------
|
|
|
|
|
|
def test_recover_active_box_returns_most_recent(tmp_path):
|
|
store = _store(tmp_path)
|
|
older = store.create_timebox(
|
|
TimeBox(id=None, todo_id=None, duration_seconds=600, state=BoxState.paused)
|
|
)
|
|
newer = store.create_timebox(
|
|
TimeBox(id=None, todo_id=None, duration_seconds=600, state=BoxState.interrupted,
|
|
reason="got pulled into a call")
|
|
)
|
|
recovered = store.recover_timebox()
|
|
assert recovered is not None
|
|
# most-recent by updated_at
|
|
assert recovered.id in {older.id, newer.id}
|
|
assert recovered.has_active_state()
|
|
|
|
|
|
def test_recover_returns_none_when_all_terminal(tmp_path):
|
|
store = _store(tmp_path)
|
|
store.create_timebox(TimeBox(id=None, todo_id=None, duration_seconds=600, state=BoxState.completed))
|
|
store.create_timebox(TimeBox(id=None, todo_id=None, duration_seconds=600, state=BoxState.abandoned))
|
|
assert store.recover_timebox() is None
|
|
|
|
|
|
def test_recover_returns_none_on_empty_store(tmp_path):
|
|
store = _store(tmp_path)
|
|
assert store.recover_timebox() is None
|
|
|
|
|
|
def test_get_nonexistent_returns_none(tmp_path):
|
|
store = _store(tmp_path)
|
|
assert store.get_timebox(9999) is None
|
|
|
|
|
|
# --- update --------------------------------------------------------------
|
|
|
|
|
|
def test_update_state_sets_timestamp(tmp_path):
|
|
store = _store(tmp_path)
|
|
box = store.create_timebox(TimeBox(id=None, todo_id=None, duration_seconds=600))
|
|
updated = store.update_timebox(box.id, state=BoxState.running)
|
|
assert updated.state == BoxState.running
|
|
assert updated.updated_at >= box.updated_at
|
|
|
|
|
|
def test_update_unknown_returns_none(tmp_path):
|
|
store = _store(tmp_path)
|
|
assert store.update_timebox(9999, state=BoxState.completed) is None
|
|
|
|
|
|
# --- relationship with todos --------------------------------------------
|
|
|
|
|
|
def test_create_timebox_without_todo_is_allowed(tmp_path):
|
|
store = _store(tmp_path)
|
|
box = store.create_timebox(TimeBox(id=None, todo_id=None, duration_seconds=300))
|
|
assert box.id is not None
|
|
fetched = store.get_timebox(box.id)
|
|
assert fetched.todo_id is None
|