Initial version
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
"""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 = (
|
||||
"<!-- labvoice:v1\n"
|
||||
'{"consumables": ['
|
||||
'{"resource_key": "ethanol_absolute", "quantity": 2.0, "unit": "mL"},'
|
||||
'{"resource_key": "peanut_oil", "quantity": 1.0, "unit": "mL", "optional": true}'
|
||||
"]}\n"
|
||||
"-->"
|
||||
)
|
||||
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 (
|
||||
"<!-- labvoice:v1\n"
|
||||
'{"prompt_quantity": true, "consumables": ['
|
||||
'{"resource_key": "ethanol_absolute", "quantity": null, "unit": "mL"}'
|
||||
"]}\n"
|
||||
"-->"
|
||||
)
|
||||
|
||||
|
||||
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 = (
|
||||
"<!-- labvoice:v1\n"
|
||||
'{"consumables": [{"resource_key": "ethanol_absolute", "quantity": 2.0,'
|
||||
' "unit": "mL", "allocation": "specific", "container_id": 32}]}\n'
|
||||
"-->"
|
||||
)
|
||||
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 = (
|
||||
"<!-- labvoice:v1\n"
|
||||
'{"consumables": [{"resource_key": "ethanol_absolute", "quantity": 0.02,'
|
||||
' "unit": "L"}]}\n'
|
||||
"-->"
|
||||
)
|
||||
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)
|
||||
Reference in New Issue
Block a user