Initial version

This commit is contained in:
Tom Kasper
2026-08-30 20:44:00 +02:00
commit 9aa2da72b5
612 changed files with 64514 additions and 0 deletions
+338
View File
@@ -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)