"""workflows/protocol.py — the step-completion saga (plan §7, §10). Golden path: select next step → parse annotation → resolve keys → allocate → validate stock → decrement containers → finish step → post comment → verify. Failure semantics: clarifications abort before any mutation; journal failures compensate (reverted) or escalate (partial_failure + audit comment). """ import pytest from fakes import ANNOTATION_ETHANOL_FIFO, FakeContainer, FakeStep from mcp_server.errors import ( ApiError, ClarificationNeededError, InsufficientStockError, NotFoundError, PermissionDeniedError, ) # --- get_next_step ---------------------------------------------------------- async def test_next_step_is_lowest_ordering_unfinished(protocol): next_step = await protocol.get_next_step(123) assert next_step is not None assert next_step.experiment_id == 123 assert next_step.step.id == 9 assert next_step.step.body.startswith("Add ethanol") async def test_next_step_includes_parsed_consumables(protocol): next_step = await protocol.get_next_step(123) assert len(next_step.consumables) == 1 assert next_step.consumables[0].resource_key == "ethanol_absolute" assert next_step.consumables[0].quantity == 2.0 async def test_next_step_includes_stock_preview(protocol): next_step = await protocol.get_next_step(123) assert [s.container_id for s in next_step.stock] == [31, 32] assert next_step.stock[0].available == 50.0 assert next_step.stock[0].unit == "mL" async def test_next_step_none_when_protocol_complete(protocol, client): for step in client.experiments[123].steps: step.finished = True assert await protocol.get_next_step(123) is None async def test_next_step_skips_finished_steps(protocol, client): client.experiments[123].steps[0].finished = True next_step = await protocol.get_next_step(123) assert next_step.step.id == 10 async def test_next_step_unknown_experiment_fails_cleanly(protocol): with pytest.raises(NotFoundError): await protocol.get_next_step(9999) # --- complete_next_step: golden path ---------------------------------------- async def test_golden_path_completes_step_and_consumes_stock(protocol, client): result = await protocol.complete_next_step(123, comment="Done at the bench") assert result.ok is True assert result.status == "completed" assert result.experiment_id == 123 assert result.step.id == 9 assert result.step.finished is True assert result.next_step is not None assert result.next_step.id == 10 # stock decremented (fifo: container 31 first) assert client.items[12].containers[0].qty_stored == 48.0 assert client.items[12].containers[1].qty_stored == 10.0 # compact consumed record with TTS-ready amounts (plan §6) assert len(result.consumed) == 1 consumed = result.consumed[0] assert consumed.resource_key == "ethanol_absolute" assert consumed.container_id == 31 assert consumed.amount == "2.0 mL" assert consumed.remaining == "48.0 mL" # comment posted assert result.comment_id is not None comments = client.experiments[123].comments assert any("Done at the bench" in c.body for c in comments) async def test_step_is_marked_finished_in_elabftw(protocol, client): await protocol.complete_next_step(123) assert client.experiments[123].steps[0].finished is True async def test_saga_journals_a_completed_operation(protocol, journal): await protocol.complete_next_step(123) # The journal must have recorded a completed operation for this run; the # audit trail lives in the SQLite file (plan §10). assert journal.db_path.exists() assert journal.db_path.stat().st_size > 0 # --- step selection variants ------------------------------------------------ async def test_complete_with_explicit_step_id(protocol, client): result = await protocol.complete_step(123, 10, comment="redo") assert result.step.id == 10 assert result.step.finished is True assert client.experiments[123].steps[1].finished is True async def test_complete_next_skips_finished_steps(protocol, client): client.experiments[123].steps[0].finished = True result = await protocol.complete_next_step(123) assert result.step.id == 10 # --- no-annotation steps ---------------------------------------------------- async def test_step_without_annotation_completes_and_posts_comment(protocol, client): """Plan §7.2: no annotation ⇒ complete step + comment, skip inventory.""" client.experiments[123].steps[0].body = "Plain step, no annotation" result = await protocol.complete_next_step(123, comment="ok") assert result.status == "completed" assert result.consumed == [] assert result.comment_id is not None assert client.calls_of("set_container_quantity") == [] # --- clarification before mutation ------------------------------------------ async def test_unresolvable_resource_key_aborts_without_mutation(protocol, client, resolver_stub): resolver_stub.mappings.clear() client.experiments[123].steps[0] = FakeStep( id=9, body=f"Add mystery reagent\n{ANNOTATION_ETHANOL_FIFO}", ordering=1 ) with pytest.raises(ClarificationNeededError): await protocol.complete_next_step(123) assert client.calls_of("set_container_quantity") == [] assert client.calls_of("finish_step") == [] assert client.calls_of("post_comment") == [] assert client.experiments[123].steps[0].finished is False async def test_insufficient_stock_aborts_without_mutation(protocol, client): client.items[12].containers = [FakeContainer(sub_id=31, qty_stored=1.0, unit="mL")] with pytest.raises(InsufficientStockError) as excinfo: await protocol.complete_next_step(123) assert "1.0" in str(excinfo.value) assert client.calls_of("set_container_quantity") == [] assert client.calls_of("finish_step") == [] # --- optional consumables --------------------------------------------------- async def test_optional_consumable_missing_stock_is_skipped_with_warning(protocol, client): """Plan §7.5: optional consumables are skipped with a warning, step completes.""" annotated = ( "" ) client.experiments[123].steps[0] = FakeStep(id=9, body=f"Mix\n{annotated}", ordering=1) result = await protocol.complete_next_step(123) assert result.status == "completed" assert [c.resource_key for c in result.consumed] == ["ethanol_absolute"] # --- prompt_quantity -------------------------------------------------------- def _prompt_annotation_body() -> str: return ( "" ) async def test_prompt_quantity_without_quantities_asks_for_clarification(protocol, client): client.experiments[123].steps[0] = FakeStep( id=9, body=f"How much did you use?\n{_prompt_annotation_body()}", ordering=1 ) with pytest.raises(ClarificationNeededError) as excinfo: await protocol.complete_next_step(123) assert excinfo.value.options, "clarification should name the resources needing amounts" assert client.calls_of("set_container_quantity") == [] async def test_prompt_quantity_with_supplied_quantities_completes(protocol, client): client.experiments[123].steps[0] = FakeStep( id=9, body=f"How much did you use?\n{_prompt_annotation_body()}", ordering=1 ) result = await protocol.complete_next_step(123, quantities={"ethanol_absolute": 5.0}) assert result.status == "completed" assert result.consumed[0].amount == "5.0 mL" assert client.items[12].containers[0].qty_stored == 45.0 # --- allocation behaviour in the saga --------------------------------------- async def test_fifo_split_across_containers(protocol, client): client.items[12].containers[0].qty_stored = 1.0 result = await protocol.complete_next_step(123) assert [(c.container_id, c.amount) for c in result.consumed] == [ (31, "1.0 mL"), (32, "1.0 mL"), ] assert client.items[12].containers[0].qty_stored == 0.0 assert client.items[12].containers[1].qty_stored == 9.0 async def test_specific_allocation_uses_the_hinted_container(protocol, client): annotated = ( "" ) client.experiments[123].steps[0] = FakeStep(id=9, body=f"x\n{annotated}", ordering=1) result = await protocol.complete_next_step(123) assert result.consumed[0].container_id == 32 assert client.items[12].containers[1].qty_stored == 8.0 async def test_unit_conversion_between_compatible_units(protocol, client): """Required 0.02 L (= 20 mL) against mL-stocked containers.""" annotated = ( "" ) client.experiments[123].steps[0] = FakeStep(id=9, body=f"x\n{annotated}", ordering=1) result = await protocol.complete_next_step(123) assert client.items[12].containers[0].qty_stored == 30.0 assert result.consumed[0].amount == "20.0 mL" # --- failure & compensation (plan §10) --------------------------------------- async def test_failure_during_finish_step_returns_reverted_and_restores_stock( protocol, client, monkeypatch ): """Plan §10: compensate by restoring decremented quantities, then `reverted`.""" async def fail_finish(entity_type, entity_id, step_id): raise ApiError("eLabFTW is unhappy.") monkeypatch.setattr(protocol.client, "finish_step", fail_finish) result = await protocol.complete_next_step(123) assert result.status == "reverted" assert client.items[12].containers[0].qty_stored == 50.0 assert client.experiments[123].steps[0].finished is False assert client.calls_of("post_comment") == [] async def test_comment_failure_after_finish_compensates_stock(protocol, client, monkeypatch): async def fail_comment(entity_type, entity_id, body): raise ApiError("comment refused.") monkeypatch.setattr(protocol.client, "post_comment", fail_comment) result = await protocol.complete_next_step(123) assert result.status == "reverted" assert client.items[12].containers[0].qty_stored == 50.0 async def test_failed_compensation_marks_partial_failure_and_posts_audit_comment( protocol, client, monkeypatch ): async def fail_finish(entity_type, entity_id, step_id): raise ApiError("eLabFTW is unhappy.") async def fail_restore(entity_type, entity_id, subitem_id, qty_stored): raise ApiError("restore also failed.") monkeypatch.setattr(protocol.client, "finish_step", fail_finish) monkeypatch.setattr(protocol.client, "set_container_quantity", fail_restore) result = await protocol.complete_next_step(123) assert result is not None, "workflow must report partial_failure, not raise" assert result.status == "partial_failure" comments = client.experiments[123].comments assert any("31" in c.body or "ethanol" in c.body.lower() for c in comments), ( "partial_failure must post an audit comment naming the inconsistent container" ) # --- protocol_complete edge case (plan §7.1) --------------------------------- async def test_complete_next_with_no_steps_left_returns_protocol_complete(protocol, client): for step in client.experiments[123].steps: step.finished = True result = await protocol.complete_next_step(123) assert result.status == "protocol_complete" assert result.consumed == [] assert client.calls_of("set_container_quantity") == [] assert client.calls_of("post_comment") == [] # --- guard rails ------------------------------------------------------------- async def test_completing_an_already_finished_explicit_step_errors(protocol, client): """Plan §13 safety: immutable (already finished) step ⇒ explicit error.""" client.experiments[123].steps[0].finished = True with pytest.raises(Exception) as excinfo: await protocol.complete_step(123, 9) assert not isinstance(excinfo.value, NotImplementedError), "scaffold not implemented yet" async def test_read_only_key_surfaces_permission_error(protocol, client): client.read_only = True with pytest.raises(PermissionDeniedError): await protocol.complete_next_step(123)