- 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
131 lines
4.3 KiB
Python
131 lines
4.3 KiB
Python
"""Tests for the BoxController: state transitions + elapsed bookkeeping."""
|
|
import pytest
|
|
|
|
from agent_todos.box import BoxController
|
|
from agent_todos.timebox import BoxState, TimeBox
|
|
|
|
|
|
class FakeStore:
|
|
def __init__(self, box: TimeBox):
|
|
self._box = box
|
|
self.saved = []
|
|
|
|
def get_timebox(self, box_id):
|
|
return self._box
|
|
|
|
def update_timebox(self, box_id, **kwargs):
|
|
for k, v in kwargs.items():
|
|
setattr(self._box, k, v)
|
|
self.saved.append(kwargs)
|
|
return self._box
|
|
|
|
|
|
def _box(**kw):
|
|
base = dict(id=1, todo_id=None, duration_seconds=600, state=BoxState.open)
|
|
base.update(kw)
|
|
return TimeBox(**base)
|
|
|
|
|
|
def test_start_moves_open_to_running():
|
|
box = _box()
|
|
ctrl = BoxController(FakeStore(box))
|
|
out = ctrl.start(box)
|
|
assert out.state == BoxState.running
|
|
assert box.last_started_at is not None
|
|
|
|
|
|
def test_start_rejects_illegal_state():
|
|
box = _box(state=BoxState.paused)
|
|
ctrl = BoxController(FakeStore(box))
|
|
with pytest.raises(ValueError, match="Cannot start"):
|
|
ctrl.start(box)
|
|
|
|
|
|
def test_pause_accrues_elapsed_and_freezes():
|
|
box = _box(state=BoxState.running, last_started_at="2026-01-01T00:00:00Z")
|
|
# monkeypatch "now" via a fake by setting last_started far in past so elapsed > 0
|
|
box.last_started_at = "2000-01-01T00:00:00Z"
|
|
ctrl = BoxController(FakeStore(box))
|
|
out = ctrl.pause(box)
|
|
assert out.state == BoxState.paused
|
|
assert box.accumulated_seconds > 0
|
|
assert box.last_started_at is None # frozen
|
|
|
|
|
|
def test_pause_rejects_non_running():
|
|
box = _box(state=BoxState.open)
|
|
ctrl = BoxController(FakeStore(box))
|
|
with pytest.raises(ValueError, match="Cannot pause"):
|
|
ctrl.pause(box)
|
|
|
|
|
|
def test_resume_moves_paused_to_running():
|
|
box = _box(state=BoxState.paused, accumulated_seconds=300)
|
|
ctrl = BoxController(FakeStore(box))
|
|
out = ctrl.resume(box)
|
|
assert out.state == BoxState.running
|
|
assert box.last_started_at is not None
|
|
assert box.accumulated_seconds == 300 # preserved
|
|
|
|
|
|
def test_resume_rejects_non_paused():
|
|
box = _box(state=BoxState.open)
|
|
ctrl = BoxController(FakeStore(box))
|
|
with pytest.raises(ValueError, match="Cannot resume"):
|
|
ctrl.resume(box)
|
|
|
|
|
|
def test_interrupt_appends_reason_and_freezes():
|
|
box = _box(state=BoxState.running, accumulated_seconds=120)
|
|
box.last_started_at = "2000-01-01T00:00:00Z"
|
|
ctrl = BoxController(FakeStore(box))
|
|
out = ctrl.interrupt(box, reason="Slack DM from greg")
|
|
assert out.state == BoxState.interrupted
|
|
assert out.interruptions and out.interruptions[-1]["reason"] == "Slack DM from greg"
|
|
assert box.accumulated_seconds > 120 # accrued before freeze
|
|
assert box.last_started_at is None
|
|
assert box.reason == "Slack DM from greg"
|
|
|
|
|
|
def test_interrupt_on_paused_is_allowed():
|
|
box = _box(state=BoxState.paused, accumulated_seconds=60)
|
|
ctrl = BoxController(FakeStore(box))
|
|
out = ctrl.interrupt(box, reason="phone")
|
|
assert out.state == BoxState.interrupted
|
|
assert out.interruptions[-1]["reason"] == "phone"
|
|
|
|
|
|
def test_complete_marks_terminal_and_timestamp():
|
|
box = _box(state=BoxState.paused, accumulated_seconds=300)
|
|
ctrl = BoxController(FakeStore(box))
|
|
out = ctrl.complete(box)
|
|
assert out.state == BoxState.completed
|
|
assert box.completed_at is not None
|
|
|
|
|
|
def test_abandon_marks_terminal():
|
|
box = _box(state=BoxState.running, accumulated_seconds=30)
|
|
box.last_started_at = "2000-01-01T00:00:00Z"
|
|
ctrl = BoxController(FakeStore(box))
|
|
out = ctrl.abandon(box, reason="lost focus, switching tasks")
|
|
assert out.state == BoxState.abandoned
|
|
assert box.accumulated_seconds > 30 # accrues the live time
|
|
|
|
|
|
def test_expire_marks_terminal():
|
|
box = _box(state=BoxState.running, accumulated_seconds=590)
|
|
box.last_started_at = "2000-01-01T00:00:00Z" # way in past -> elapsed >= duration
|
|
ctrl = BoxController(FakeStore(box))
|
|
out = ctrl.expire(box)
|
|
assert out.state == BoxState.expired
|
|
assert box.elapsed_seconds() >= box.duration_seconds
|
|
|
|
|
|
def test_action_on_terminal_state_raises():
|
|
box = _box(state=BoxState.completed)
|
|
ctrl = BoxController(FakeStore(box))
|
|
with pytest.raises(ValueError, match="terminal"):
|
|
ctrl.resume(box)
|
|
with pytest.raises(ValueError, match="terminal"):
|
|
ctrl.complete(box)
|