"""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)