agent-todo-tracker/tests/test_cli_box.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

169 lines
5.8 KiB
Python

"""CLI tests for the `box` command group (issue #16)."""
from click.testing import CliRunner
from agent_todos.cli import main
def _runner(tmp_path, db="todos.db"):
return (CliRunner(), str(tmp_path / db))
def test_box_help_lists_commands(tmp_path):
runner, _ = _runner(tmp_path)
result = runner.invoke(main, ["box", "--help"])
assert result.exit_code == 0
for cmd in ["start", "status", "pause", "resume", "interrupt", "complete", "abandon", "recover", "list"]:
assert cmd in result.output, f"{cmd} missing from box --help:\n{result.output}"
def test_start_default_25m(tmp_path):
runner, db = _runner(tmp_path)
result = runner.invoke(main, ["box", "start", "--path", db])
assert result.exit_code == 0
assert "25:00" in result.output
assert "started box" in result.output
def test_start_custom_minutes(tmp_path):
runner, db = _runner(tmp_path)
result = runner.invoke(main, ["box", "start", "10", "--path", db])
assert result.exit_code == 0
assert "10:00" in result.output
def test_start_with_todo_link(tmp_path):
runner, db = _runner(tmp_path)
runner.invoke(main, ["create", "deep work", "--path", db])
result = runner.invoke(main, ["box", "start", "5", "--todo", "1", "--path", db])
assert result.exit_code == 0
assert "started" in result.output
def test_start_rejects_out_of_range(tmp_path):
runner, db = _runner(tmp_path)
result = runner.invoke(main, ["box", "start", "500", "--path", db])
assert result.exit_code != 0
assert "0.5 and 240" in result.output
def test_status_before_any_box_is_friendly(tmp_path):
runner, db = _runner(tmp_path)
result = runner.invoke(main, ["box", "status", "--path", db])
assert result.exit_code != 0
assert "no active time-box" in result.output
def test_full_lifecycle_open_running_paused_completed(tmp_path):
runner, db = _runner(tmp_path)
r = runner.invoke(main, ["box", "start", "5", "--path", db])
assert r.exit_code == 0
box_id = r.output.split("#")[1].split(" ")[0]
r = runner.invoke(main, ["box", "status", "--path", db])
assert r.exit_code == 0
assert "focusing" in r.output
r = runner.invoke(main, ["box", "pause", "--path", db])
assert r.exit_code == 0
assert "paused" in r.output
r = runner.invoke(main, ["box", "resume", "--path", db])
assert r.exit_code == 0
assert "resumed" in r.output
r = runner.invoke(main, ["box", "complete", "--reason", "made progress", "--path", db])
assert r.exit_code == 0
assert "completed" in r.output
assert "made progress" in r.output
# after completion, status should be terminal-friendly
r = runner.invoke(main, ["box", "status", "--path", db])
assert r.exit_code != 0 # terminal box => active_box helper raises
assert "no active time-box" in r.output
def test_interrupt_and_resume(tmp_path):
runner, db = _runner(tmp_path)
runner.invoke(main, ["box", "start", "5", "--path", db])
r = runner.invoke(main, ["box", "interrupt", "meeting popped up", "--path", db])
assert r.exit_code == 0
assert "meeting popped up" in r.output
r = runner.invoke(main, ["box", "status", "--path", db])
assert r.exit_code == 0
assert "interrupted" in r.output
r = runner.invoke(main, ["box", "resume", "--path", db])
assert r.exit_code == 0
assert "resumed" in r.output
def test_abandon_is_nonjudgmental(tmp_path):
runner, db = _runner(tmp_path)
runner.invoke(main, ["box", "start", "5", "--path", db])
r = runner.invoke(main, ["box", "abandon", "--reason", "tired", "--path", db])
assert r.exit_code == 0
assert "it's okay" in r.output.lower() or "no judgment" in r.output.lower()
def test_pause_without_active_box_fails(tmp_path):
runner, db = _runner(tmp_path)
r = runner.invoke(main, ["box", "pause", "--path", db])
assert r.exit_code != 0
assert "no active time-box" in r.output
def test_recover_after_restart(tmp_path):
runner, db = _runner(tmp_path)
runner.invoke(main, ["box", "start", "5", "--path", db])
r = runner.invoke(main, ["box", "interrupt", "power blip", "--path", db])
assert r.exit_code == 0
# simulate a fresh CLI invocation by using a new CliRunner on same db
r = runner.invoke(main, ["box", "recover", "--path", db])
assert r.exit_code == 0
assert "recovered box" in r.output
assert "power blip" in r.output
def test_recover_nothing_returns_helpful_message(tmp_path):
runner, db = _runner(tmp_path)
r = runner.invoke(main, ["box", "recover", "--path", db])
assert r.exit_code == 0
assert "nothing to recover" in r.output
def test_list_empty_is_friendly(tmp_path):
runner, db = _runner(tmp_path)
r = runner.invoke(main, ["box", "list", "--path", db])
assert r.exit_code == 0
assert "no boxes yet" in r.output
def test_list_shows_active_boxes(tmp_path):
runner, db = _runner(tmp_path)
runner.invoke(main, ["box", "start", "5", "--path", db])
r = runner.invoke(main, ["box", "list", "--path", db])
assert r.exit_code == 0
assert "[open]" not in r.output # box started, so it's running
# should still show the running box
assert "running" in r.output
def test_pause_while_not_running_rejected(tmp_path):
runner, db = _runner(tmp_path)
runner.invoke(main, ["box", "start", "5", "--path", db])
runner.invoke(main, ["box", "pause", "--path", db])
r = runner.invoke(main, ["box", "pause", "--path", db])
assert r.exit_code != 0
assert "Cannot pause" in r.output
def test_complete_twice_fails_cleanly(tmp_path):
runner, db = _runner(tmp_path)
runner.invoke(main, ["box", "start", "5", "--path", db])
runner.invoke(main, ["box", "complete", "--path", db])
r = runner.invoke(main, ["box", "complete", "--path", db])
assert r.exit_code != 0 # no active box