"""Placement — the pure half: what "next" means, and that a lookup never breaks a write. The query half (position over readable steps, what a collaborator may see) needs Postgres and lives in test_integration_placement.py. """ from types import SimpleNamespace from unittest.mock import AsyncMock, patch from scribe.services import placement from scribe.services.placement import _next_open def _steps(*statuses): return [SimpleNamespace(id=i, title=f"step {i}", status=s) for i, s in enumerate(statuses, start=1)] def test_next_is_the_first_open_step_after_this_one(): steps = _steps("done", "in_progress", "done", "todo", "todo") assert _next_open(steps, current_id=2)["id"] == 4 def test_next_falls_back_to_an_earlier_open_step(): """The last step finishing does not mean the milestone is finished.""" steps = _steps("todo", "done", "done") assert _next_open(steps, current_id=3) == {"id": 1, "title": "step 1", "status": "todo"} def test_nothing_open_means_no_next(): assert _next_open(_steps("done", "cancelled", "done"), current_id=3) is None async def test_a_task_with_no_project_or_milestone_has_no_placement_and_no_query(): boom = AsyncMock(side_effect=AssertionError("must not open a session")) with patch.object(placement, "async_session", boom): task = SimpleNamespace(id=1, project_id=None, milestone_id=None) assert await placement.task_placement(7, task) is None async def test_attach_omits_placement_rather_than_sending_it_empty(): with patch.object(placement, "task_placement", AsyncMock(return_value=None)): data = await placement.attach_placement(7, {"id": 1}, SimpleNamespace(id=1)) assert "placement" not in data async def test_a_failing_lookup_returns_the_payload_untouched(): with patch.object(placement, "task_placement", AsyncMock(side_effect=RuntimeError("db down"))): data = await placement.attach_placement(7, {"id": 1}, SimpleNamespace(id=1)) assert data == {"id": 1}