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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+164
View File
@@ -0,0 +1,164 @@
"""Shared fixtures for the LabVoice MCP server test suite.
Conventions pinned by this suite (adjust here if the implementation diverges):
1. Workflows and services receive their collaborators via constructor
arguments (``client``, ``resolver``, ``journal``, ``settings``) exactly as
declared in the scaffolds — tests inject ``FakeElabftwClient`` /
``StubResolver`` and a real ``SagaJournal`` against a temp SQLite file.
2. ``SagaJournal`` has an explicit ``connect()``/``close()`` lifecycle.
3. The REST app and MCP tools resolve their read services through
``mcp_server.server.build_services(settings)`` (the wiring container from
the scaffold); tests monkeypatch that factory. The service container is a
dict with the keys implied by the scaffold's build_services comment:
``client``, ``resolver``, ``journal``, ``protocol``, ``inventory``,
``setup``, ``templates``.
4. MCP-level tests drive the real ``FastMCP`` server (from ``mcp`` 2.1.x)
in-process via ``mcp.client.Client``.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
# Make the vendored elabapi_python client importable for tests (it is vendored,
# not installed). Its deps (six, urllib3, certifi) come from the dev group.
_VENDOR = Path(__file__).resolve().parent.parent / "vendor" / "elabapi-python"
if str(_VENDOR) not in sys.path:
sys.path.insert(0, str(_VENDOR))
from mcp_server.config import Settings
from mcp_server.journal import SagaJournal
from mcp_server.resolve import ResourceResolver
from mcp_server.templates import TemplateService
from mcp_server.workflows.inventory import InventoryWorkflow
from mcp_server.workflows.protocol import ProtocolWorkflow
from mcp_server.workflows.setup import SetupWorkflow
from fakes import (
FakeElabftwClient,
StubResolver,
make_experiment,
make_item,
)
@pytest.fixture
def settings(tmp_path, monkeypatch) -> Settings:
"""Isolated settings: temp DB, no env/.env interference, distinctive API key."""
for var in (
"ELABFTW_URL",
"ELABFTW_API_KEY",
"LABVOICE_DB_PATH",
"LABVOICE_REST_TOKEN",
"LABVOICE_TEMPLATE_DESC_LIMIT",
"LABVOICE_EMBED_MODEL",
"LABVOICE_UNIT_WHITELIST",
"LABVOICE_EXPIRY_FIELD",
):
monkeypatch.delenv(var, raising=False)
return Settings(
_env_file=None,
elabftw_url="https://eln.example.org",
elabftw_api_key="labvoice-secret-key-do-not-leak",
elabftw_timeout=5.0,
elabftw_retries=0,
labvoice_db_path=tmp_path / "journal.sqlite",
)
@pytest.fixture
def client() -> FakeElabftwClient:
"""Standard scenario: experiment 123 (steps 9, 10) and ethanol item 12."""
return FakeElabftwClient(experiments=[make_experiment()], items=[make_item()])
@pytest.fixture
def resolver_stub() -> StubResolver:
"""ethanol_absolute → item 12; other keys raise clarification."""
return StubResolver(mappings={"ethanol_absolute": 12})
@pytest.fixture
async def journal(settings) -> SagaJournal:
journal = SagaJournal(settings.labvoice_db_path)
await journal.connect()
yield journal
await journal.close()
@pytest.fixture
def protocol(client, resolver_stub, journal, settings) -> ProtocolWorkflow:
return ProtocolWorkflow(
client=client, resolver=resolver_stub, journal=journal, settings=settings
)
@pytest.fixture
def inventory(client, resolver_stub, journal, settings) -> InventoryWorkflow:
return InventoryWorkflow(
client=client, resolver=resolver_stub, journal=journal, settings=settings
)
@pytest.fixture
def setup(client, resolver_stub, settings) -> SetupWorkflow:
return SetupWorkflow(client=client, resolver=resolver_stub, settings=settings)
@pytest.fixture
def templates_service(client, settings) -> TemplateService:
return TemplateService(client=client, settings=settings)
@pytest.fixture
async def real_resolver(settings):
"""Real ResourceResolver over a temp DB — resolver/mapping-store tests only."""
resolver = ResourceResolver(settings)
if hasattr(resolver, "connect"):
await resolver.connect()
yield resolver
if hasattr(resolver, "close"):
await resolver.close()
@pytest.fixture
def service_container(client, resolver_stub, journal, settings, templates_service) -> dict:
"""Fake ``build_services`` container (keys per scaffold comment)."""
return {
"client": client,
"resolver": resolver_stub,
"journal": journal,
"protocol": ProtocolWorkflow(
client=client, resolver=resolver_stub, journal=journal, settings=settings
),
"inventory": InventoryWorkflow(
client=client, resolver=resolver_stub, journal=journal, settings=settings
),
"setup": SetupWorkflow(client=client, resolver=resolver_stub, settings=settings),
"templates": templates_service,
}
@pytest.fixture
def patch_build_services(monkeypatch, service_container):
"""Intercept ``build_services`` wherever a transport layer imports it."""
def _patch():
import mcp_server.rest as rest_module
import mcp_server.server as server_module
import mcp_server.tools as tools_module
for module in (server_module, rest_module, tools_module):
monkeypatch.setattr(
module,
"build_services",
lambda _settings, _container=service_container: _container,
raising=False,
)
return service_container
return _patch
+505
View File
@@ -0,0 +1,505 @@
"""Test doubles shared across the suite.
Three fakes live here, each at a different seam:
* :class:`FakeElabftwClient` duck-types the ``ElabftwClient`` adapter interface.
It is a stateful in-memory eLabFTW instance used by workflow/service tests,
so saga behaviour can be asserted against real state changes.
* :class:`StubResolver` duck-types ``ResourceResolver`` with a plain dict of
mappings, keeping workflow tests independent of the SQLite mapping store.
* :func:`stub_elabapi` installs fake ``elabapi_python`` API classes into the
``mcp_server.elabftw`` module namespace. Convention under test: the adapter
resolves vendored API classes as module globals of ``mcp_server.elabftw``
(``from elabapi_python import ExperimentsApi`` ...) and instantiates them
with an api_client. If the implementation wires this differently, only the
injection point in these stubs needs to move.
Scenario builders (``make_experiment``, ``make_item`` ...) produce the standard
fixtures referenced throughout the tests.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from mcp_server.errors import ClarificationNeededError, NotFoundError, PermissionDeniedError
from mcp_server.models import (
AllocationPolicy,
MappingEntry,
MappingProposal,
)
from mcp_server.schemas import (
ContainerLink,
ElabftwComment,
ElabftwExperiment,
ElabftwInfo,
ElabftwItem,
ElabftwStep,
ElabftwTemplate,
StorageUnit,
)
# ---------------------------------------------------------------------------
# Annotation snippets (the labvoice:v1 format from planning §4)
# ---------------------------------------------------------------------------
ANNOTATION_ETHANOL_FIFO = (
"<!-- labvoice:v1\n"
'{"consumables": [{"resource_key": "ethanol_absolute", "quantity": 2.0,'
' "unit": "mL", "allocation": "fifo"}]}\n'
"-->"
)
# ---------------------------------------------------------------------------
# Fake eLabFTW state (adapter-level)
# ---------------------------------------------------------------------------
@dataclass
class FakeStep:
id: int
body: str
finished: bool = False
ordering: int | None = None
@dataclass
class FakeComment:
id: int
body: str
created_at: str | None = None
@dataclass
class FakeContainer:
sub_id: int
qty_stored: float
unit: str
@dataclass
class FakeItem:
id: int
title: str
body: str = ""
extra_fields: dict = field(default_factory=dict)
containers: list[FakeContainer] = field(default_factory=list)
@dataclass
class FakeExperiment:
id: int
title: str
status: str = "running"
steps: list[FakeStep] = field(default_factory=list)
comments: list[FakeComment] = field(default_factory=list)
linked_items: list[int] = field(default_factory=list)
@dataclass
class FakeTemplate:
id: int
title: str
description: str = ""
tags: list[str] = field(default_factory=list)
steps: list[FakeStep] = field(default_factory=list)
def make_template(**overrides) -> FakeTemplate:
"""Standard template: two plain steps, description with two paragraphs."""
tpl = FakeTemplate(
id=7,
title="PCR cleanup",
description="Purify an amplicon after PCR.\n\nLonger protocol notes follow here.",
tags=["dna", "cleanup"],
steps=[
FakeStep(id=41, body="Add binding buffer", ordering=1),
FakeStep(id=42, body="Elute", ordering=2),
],
)
for key, value in overrides.items():
setattr(tpl, key, value)
return tpl
def make_experiment(**overrides) -> FakeExperiment:
"""Standard experiment: step 9 (annotated) and step 10 (plain), both unfinished."""
exp = FakeExperiment(
id=123,
title="Plasmid prep",
steps=[
FakeStep(
id=9,
body=f"Add ethanol to the tube\n{ANNOTATION_ETHANOL_FIFO}",
ordering=1,
),
FakeStep(id=10, body="Incubate 30 min", ordering=2),
],
linked_items=[12],
)
for key, value in overrides.items():
setattr(exp, key, value)
return exp
def make_item(**overrides) -> FakeItem:
"""Standard resource: ethanol with the marker comment and two containers."""
item = FakeItem(
id=12,
title="Absolute Ethanol",
body="<p>Absolutely ethanol.</p>\n<!-- labvoice:resource-key=ethanol_absolute -->",
containers=[
FakeContainer(sub_id=31, qty_stored=50.0, unit="mL"),
FakeContainer(sub_id=32, qty_stored=10.0, unit="mL"),
],
)
for key, value in overrides.items():
setattr(item, key, value)
return item
class FakeElabftwClient:
"""In-memory stand-in for the async eLabFTW adapter.
Mirrors the ``ElabftwClient`` method surface. All mutations are recorded
in ``self.calls`` as ``(method_name, kwargs)`` tuples so tests can assert
exactly what changed (and that nothing changed when a workflow must abort).
``read_only=True`` makes every mutating method raise
``PermissionDeniedError`` like a canwrite=false API key would.
"""
def __init__(
self,
*,
experiments: list[FakeExperiment] | None = None,
items: list[FakeItem] | None = None,
templates: list[FakeTemplate] | None = None,
storage_units: list[StorageUnit] | None = None,
read_only: bool = False,
) -> None:
self.experiments = {e.id: e for e in (experiments or [])}
self.items = {i.id: i for i in (items or [])}
self.templates = {t.id: t for t in (templates or [])}
self.storage_units = storage_units or [
StorageUnit(id=1, label="Bench fridge / Shelf 2")
]
self.read_only = read_only
self.calls: list[tuple[str, dict]] = []
self._next_comment_id = 500
self._next_experiment_id = 1000
# -- call recording ----------------------------------------------------
def _record(self, method: str, **kwargs) -> None:
self.calls.append((method, kwargs))
def calls_of(self, method: str) -> list[dict]:
return [kwargs for name, kwargs in self.calls if name == method]
def _deny_if_read_only(self) -> None:
if self.read_only:
raise PermissionDeniedError(
"The API key cannot modify eLabFTW; ask an admin for a writable key."
)
# -- reads ---------------------------------------------------------------
async def get_info(self) -> ElabftwInfo:
self._record("get_info")
return ElabftwInfo(elabftw_version="5.0.0", api_version="2")
async def search_experiments(self, *, q=None, tags=None, limit=15, offset=0):
self._record("search_experiments", q=q, tags=tags, limit=limit, offset=offset)
hits = list(self.experiments.values())
if q:
hits = [e for e in hits if q.lower() in e.title.lower()]
if tags:
hits = [e for e in hits if any(t in tags for t in getattr(e, "tags", []))]
return [
ElabftwExperiment(id=e.id, title=e.title, status=e.status)
for e in hits[offset : offset + limit]
]
async def get_experiment(self, experiment_id: int) -> ElabftwExperiment:
self._record("get_experiment", experiment_id=experiment_id)
exp = self._entity("experiments", experiment_id)
return ElabftwExperiment(id=exp.id, title=exp.title, status=exp.status)
async def create_experiment(self, template_id: int, title=None):
self._record("create_experiment", template_id=template_id, title=title)
self._deny_if_read_only()
if template_id not in self.templates:
raise NotFoundError(f"Template {template_id} not found.")
tpl = self.templates[template_id]
new_id = self._next_experiment_id
self._next_experiment_id += 1
exp = FakeExperiment(
id=new_id,
title=title or tpl.title,
steps=[
FakeStep(id=s.id, body=s.body, ordering=s.ordering) for s in tpl.steps
],
)
self.experiments[new_id] = exp
return ElabftwExperiment(id=exp.id, title=exp.title, status=exp.status)
async def get_steps(self, entity_type, entity_id):
self._record("get_steps", entity_type=entity_type, entity_id=entity_id)
steps = self._entity(entity_type, entity_id).steps
return [
ElabftwStep(id=s.id, body=s.body, finished=s.finished, ordering=s.ordering)
for s in steps
]
async def finish_step(self, entity_type, entity_id, step_id):
self._record(
"finish_step", entity_type=entity_type, entity_id=entity_id, step_id=step_id
)
self._deny_if_read_only()
for step in self._entity(entity_type, entity_id).steps:
if step.id == step_id:
if step.finished:
return ElabftwStep(
id=step.id, body=step.body, finished=True, ordering=step.ordering
)
step.finished = True
return ElabftwStep(
id=step.id, body=step.body, finished=True, ordering=step.ordering
)
raise NotFoundError(f"Step {step_id} not found on {entity_type} {entity_id}.")
async def list_comments(self, entity_type, entity_id):
self._record("list_comments", entity_type=entity_type, entity_id=entity_id)
return [
ElabftwComment(id=c.id, body=c.body, created_at=c.created_at)
for c in self._entity(entity_type, entity_id).comments
]
async def post_comment(self, entity_type, entity_id, body):
self._record(
"post_comment", entity_type=entity_type, entity_id=entity_id, body=body
)
self._deny_if_read_only()
comment = FakeComment(id=self._next_comment_id, body=body)
self._next_comment_id += 1
self._entity(entity_type, entity_id).comments.append(comment)
return ElabftwComment(id=comment.id, body=comment.body, created_at=None)
async def search_items(self, *, q=None, limit=15, offset=0):
self._record("search_items", q=q, limit=limit, offset=offset)
hits = list(self.items.values())
if q:
hits = [i for i in hits if q.lower() in i.title.lower()]
return [self._item_model(i) for i in hits[offset : offset + limit]]
async def get_item(self, item_id: int) -> ElabftwItem:
self._record("get_item", item_id=item_id)
return self._item_model(self._entity("items", item_id))
async def get_containers(self, entity_type, entity_id):
self._record("get_containers", entity_type=entity_type, entity_id=entity_id)
item = self._entity("items", entity_id)
return [
ContainerLink(id=c.sub_id, qty_stored=c.qty_stored, unit=c.unit)
for c in item.containers
]
async def set_container_quantity(self, entity_type, entity_id, subitem_id, qty_stored):
self._record(
"set_container_quantity",
entity_type=entity_type,
entity_id=entity_id,
subitem_id=subitem_id,
qty_stored=qty_stored,
)
self._deny_if_read_only()
for container in self._entity("items", entity_id).containers:
if container.sub_id == subitem_id:
container.qty_stored = qty_stored
return
raise NotFoundError(f"Container {subitem_id} not found on item {entity_id}.")
async def list_storage_units(self):
self._record("list_storage_units")
return list(self.storage_units)
async def list_templates(self, *, q=None, limit=25, offset=0):
self._record("list_templates", q=q, limit=limit, offset=offset)
hits = list(self.templates.values())
if q:
hits = [t for t in hits if q.lower() in t.title.lower()]
return [
ElabftwTemplate(id=t.id, title=t.title, description=t.description, tags=t.tags)
for t in hits[offset : offset + limit]
]
async def get_template(self, template_id: int) -> ElabftwTemplate:
self._record("get_template", template_id=template_id)
if template_id not in self.templates:
raise NotFoundError(f"Template {template_id} not found.")
t = self.templates[template_id]
return ElabftwTemplate(id=t.id, title=t.title, description=t.description, tags=t.tags)
# -- helpers -------------------------------------------------------------
def _entity(self, entity_type, entity_id):
if entity_type == "experiments":
if entity_id not in self.experiments:
raise NotFoundError(f"Experiment {entity_id} not found.")
return self.experiments[entity_id]
if entity_id not in self.items:
raise NotFoundError(f"Item {entity_id} not found.")
return self.items[entity_id]
def _item_model(self, item: FakeItem) -> ElabftwItem:
return ElabftwItem(
id=item.id,
title=item.title,
body=item.body,
**{"extra_fields": item.extra_fields} if item.extra_fields else {},
)
# ---------------------------------------------------------------------------
# Stub resolver (resolver-level)
# ---------------------------------------------------------------------------
class StubResolver:
"""Dict-backed ``ResourceResolver`` stand-in for workflow tests."""
def __init__(
self,
mappings: dict[str, int] | None = None,
*,
fail_on: set[str] | None = None,
options_by_key: dict[str, list[str]] | None = None,
) -> None:
self.mappings = dict(mappings or {})
self.fail_on = fail_on or set()
self.options_by_key = options_by_key or {}
self.resolved: list[str] = []
async def resolve(self, resource_key: str) -> int:
self.resolved.append(resource_key)
if resource_key in self.fail_on or resource_key not in self.mappings:
raise ClarificationNeededError(
f"I don't know which resource '{resource_key}' refers to.",
options=self.options_by_key.get(resource_key, []),
)
return self.mappings[resource_key]
async def find_candidates(self, resource_key: str) -> list[MappingProposal]:
return [
MappingProposal(resource_key=resource_key, item_id=item_id)
for item_id in sorted(self.mappings.values())
]
async def upsert_mapping(self, resource_key: str, item_id: int) -> MappingEntry:
self.mappings[resource_key] = item_id
return MappingEntry(resource_key=resource_key, item_id=item_id)
async def list_mappings(self) -> list[MappingEntry]:
return [
MappingEntry(resource_key=k, item_id=v)
for k, v in sorted(self.mappings.items())
]
# ---------------------------------------------------------------------------
# elabapi_python stubs (client construction seam, adapter tests only)
# ---------------------------------------------------------------------------
class StubElabapi:
"""Fake vendored elabapi_python client hub.
Each generated ``*Api`` class is replaced by a thin proxy that forwards
every method call to :meth:`handle`, which consults routes registered by
the test. Unrouted calls fail loudly, so adapter tests only ever pass
against the endpoints they explicitly define.
"""
API_CLASSES = (
"InfoApi",
"InstanceApi",
"ExperimentsApi",
"ExperimentsTemplatesApi",
"StepsApi",
"CommentsApi",
"ItemsApi",
"ContainersApi",
"StorageUnitsApi",
"LinksToItemsApi",
"UnfinishedStepsApi",
"TodolistApi",
)
def __init__(self) -> None:
self.routes: dict[tuple[str, str], object] = {}
self.calls: list[tuple[str, str, dict]] = []
self.thread_ids: list[tuple[str, int]] = []
def route(self, api: str, method: str, handler) -> None:
self.routes[(api, method)] = handler
async def handle(self, api: str, method: str, args: tuple, kwargs: dict):
self.calls.append((api, method, kwargs))
self.thread_ids.append((f"{api}.{method}", __import__("threading").get_ident()))
if (api, method) not in self.routes and (api, "*") not in self.routes:
raise AssertionError(f"Unexpected elabapi call: {api}.{method}{args}{kwargs}")
handler = self.routes.get((api, method)) or self.routes[(api, "*")]
return handler(*args, **kwargs)
def make_stub_elabapi_cls(hub: StubElabapi, api_name: str):
class _ApiProxy:
def __init__(self, api_client=None, *args, **kwargs):
self.api_client = api_client
def __getattr__(self, method: str):
async def _call(*args, **kwargs):
return await hub.handle(api_name, method, args, kwargs)
_call.__name__ = method
return _call
_ApiProxy.__name__ = f"Stub{api_name}"
return _ApiProxy
def stub_elabapi(monkeypatch, hub: StubElabapi) -> StubElabapi:
"""Patch elabapi API classes (and Configuration/ApiClient) into mcp_server.elabftw."""
import elabapi_python.rest as elabapi_rest
import mcp_server.elabftw as elabftw_module
for api_name in hub.API_CLASSES:
monkeypatch.setattr(
elabftw_module,
api_name,
make_stub_elabapi_cls(hub, api_name),
raising=False,
)
class _StubConfiguration:
def __init__(self, host=None, api_key=None, username=None, password=None, **kw):
self.host = host
self.api_key = api_key
self.username = username
self.password = password
class _StubApiClient:
def __init__(self, configuration=None, **kw):
self.configuration = configuration
def set_default_header(self, name, value):
pass
monkeypatch.setattr(elabftw_module, "Configuration", _StubConfiguration, raising=False)
monkeypatch.setattr(elabftw_module, "ApiClient", _StubApiClient, raising=False)
monkeypatch.setattr(
elabftw_module, "ApiException", elabapi_rest.ApiException, raising=False
)
return hub
+176
View File
@@ -0,0 +1,176 @@
"""allocate.py — unit whitelist/conversion + allocation policies (plan §4, §7).
Tested conversion pairs per the plan: mL↔L and the mass chain μg↔mg↔g↔kg;
anything outside the whitelist or across dimensions must be a clarification,
never a guess.
"""
import pytest
from mcp_server.allocate import are_compatible, choose_containers, convert
from mcp_server.errors import ClarificationNeededError, InsufficientStockError
from mcp_server.models import AllocationPolicy, ContainerStock
def stock(cid: int, available: float, unit: str = "mL") -> ContainerStock:
return ContainerStock(container_id=cid, amount=f"{available} {unit}", available=available, unit=unit)
# --- unit compatibility -----------------------------------------------------
@pytest.mark.parametrize(
("a", "b"),
[("mL", "L"), ("L", "mL"), ("mg", "g"), ("g", "kg"), ("μg", "mg"), ("ea", "ea")],
)
def test_compatible_within_dimension(a, b):
assert are_compatible(a, b)
@pytest.mark.parametrize(
("a", "b"),
[("mL", "mg"), ("L", "ea"), ("ea", "mL"), ("g", "μL")],
)
def test_incompatible_across_dimensions(a, b):
assert not are_compatible(a, b)
@pytest.mark.parametrize("unit", ["fl-oz", "cups", "", "mmol"])
def test_unknown_units_are_never_compatible(unit):
assert not are_compatible(unit, "mL")
assert not are_compatible("mL", unit)
# --- conversion -------------------------------------------------------------
def test_volume_conversions():
assert convert(1.0, "L", "mL") == pytest.approx(1000.0)
assert convert(500.0, "mL", "L") == pytest.approx(0.5)
def test_mass_conversions():
assert convert(1.0, "g", "mg") == pytest.approx(1000.0)
assert convert(1.0, "kg", "g") == pytest.approx(1000.0)
assert convert(1000.0, "mg", "g") == pytest.approx(1.0)
assert convert(1000.0, "μg", "mg") == pytest.approx(1.0)
def test_count_is_identity():
assert convert(3.0, "ea", "ea") == 3.0
def test_cross_dimension_conversion_is_a_clarification():
with pytest.raises(ClarificationNeededError):
convert(1.0, "mL", "mg")
def test_unknown_unit_conversion_is_a_clarification():
with pytest.raises(ClarificationNeededError):
convert(1.0, "fl-oz", "mL")
# --- fifo allocation --------------------------------------------------------
def test_fifo_takes_from_lowest_container_id_with_stock():
allocations = choose_containers(
[stock(45, 5.0), stock(31, 50.0)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO
)
assert len(allocations) == 1
assert allocations[0].container_id == 31
assert allocations[0].take == pytest.approx(2.0)
assert allocations[0].remaining == "48.0 mL"
def test_fifo_splits_across_containers_when_first_is_short():
allocations = choose_containers(
[stock(31, 1.5), stock(32, 10.0)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO
)
assert [a.container_id for a in allocations] == [31, 32]
assert allocations[0].take == pytest.approx(1.5)
assert allocations[1].take == pytest.approx(0.5)
assert allocations[1].remaining == "9.5 mL"
def test_fifo_skips_empty_containers():
allocations = choose_containers(
[stock(31, 0.0), stock(32, 10.0)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO
)
assert [a.container_id for a in allocations] == [32]
def test_fifo_exact_fit_leaves_container_empty():
allocations = choose_containers(
[stock(31, 2.0)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO
)
assert allocations[0].remaining == "0.0 mL"
def test_insufficient_stock_raises_with_shortfall():
with pytest.raises(InsufficientStockError) as excinfo:
choose_containers(
[stock(31, 1.0), stock(32, 0.5)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO
)
message = str(excinfo.value)
assert "0.5" in message # 1.5 available vs 2.0 required → 0.5 short
def test_insufficient_stock_with_no_containers_at_all():
with pytest.raises(InsufficientStockError):
choose_containers([], required=2.0, unit="mL", policy=AllocationPolicy.FIFO)
# --- unit conversion during allocation --------------------------------------
def test_allocation_converts_between_compatible_units():
"""Required 0.5 L against a container stocked in mL."""
allocations = choose_containers(
[stock(31, 1000.0, "mL")], required=0.5, unit="L", policy=AllocationPolicy.FIFO
)
assert allocations[0].take == pytest.approx(500.0)
assert allocations[0].remaining == "500.0 mL"
def test_allocation_rejects_incompatible_container_units():
containers = [stock(31, 10.0, "mg")]
with pytest.raises(ClarificationNeededError):
choose_containers(containers, required=2.0, unit="mL", policy=AllocationPolicy.FIFO)
# --- specific allocation ----------------------------------------------------
def test_specific_takes_only_from_the_listed_container():
"""The caller pre-filters to the hinted container (consumable.container_id)."""
allocations = choose_containers(
[stock(32, 10.0)], required=2.0, unit="mL", policy=AllocationPolicy.SPECIFIC
)
assert [a.container_id for a in allocations] == [32]
def test_specific_with_unavailable_container_is_a_clarification():
with pytest.raises(ClarificationNeededError):
choose_containers([], required=2.0, unit="mL", policy=AllocationPolicy.SPECIFIC)
# --- nearest expiry (deferred per plan §15.2, kept as executable spec) ------
@pytest.mark.xfail(reason="nearest_expiry deferred per plan §15.2; expiry_field config pending")
def test_nearest_expiry_picks_soonest_expiry_first():
soon = ContainerStock.model_construct(
container_id=44, amount="5 mL", available=5.0, unit="mL", expires="2026-01-01"
)
later = ContainerStock.model_construct(
container_id=31, amount="5 mL", available=5.0, unit="mL", expires="2027-01-01"
)
allocations = choose_containers(
[later, soon],
required=2.0,
unit="mL",
policy=AllocationPolicy.NEAREST_EXPIRY,
expiry_field="Expiry date",
)
assert allocations[0].container_id == 44
+159
View File
@@ -0,0 +1,159 @@
"""annotate.py — labvoice:v1 parser rules (plan §4, §13)."""
import json
import pytest
from mcp_server.annotate import extract_annotation_block, parse_annotation, validate_annotation
from mcp_server.errors import AnnotationError
from mcp_server.models import AllocationPolicy
def valid_block(consumables: list[dict], **extra) -> str:
payload = {"consumables": consumables, **extra}
return f"<!-- labvoice:v1\n{json.dumps(payload)}\n-->"
ETHANOL = {
"resource_key": "ethanol_absolute",
"quantity": 2.0,
"unit": "mL",
"allocation": "fifo",
}
# --- extraction -------------------------------------------------------------
def test_extract_returns_block_when_present():
body = f"Add ethanol to the tube\n{valid_block([ETHANOL])}"
block = extract_annotation_block(body)
assert block is not None
assert "labvoice:v1" in block
assert "ethanol_absolute" in block
def test_extract_returns_none_when_no_annotation():
assert extract_annotation_block("Just a normal step.") is None
assert extract_annotation_block("") is None
def test_extract_ignores_other_html_comments():
body = "Step text\n<!-- some editor note -->\nmore text"
assert extract_annotation_block(body) is None
def test_extract_takes_the_last_valid_block():
first = valid_block([{**ETHANOL, "quantity": 1.0}])
second = valid_block([{**ETHANOL, "quantity": 2.0}])
body = f"Step text\n{first}\n{second}"
block = extract_annotation_block(body)
assert json.loads(block.split("labvoice:v1")[1].rsplit("-->")[0])["consumables"][0][
"quantity"
] == 2.0
def test_extract_tolerates_multiline_and_whitespace():
body = "text\n<!-- labvoice:v1\n\n {\"consumables\": []} \n -->"
assert extract_annotation_block(body) is not None
# --- parsing ----------------------------------------------------------------
def test_parse_valid_annotation():
body = f"Add ethanol\n{valid_block([ETHANOL])}"
annotation = parse_annotation(body)
assert annotation is not None
assert len(annotation.consumables) == 1
consumable = annotation.consumables[0]
assert consumable.resource_key == "ethanol_absolute"
assert consumable.quantity == 2.0
assert consumable.unit == "mL"
assert consumable.allocation is AllocationPolicy.FIFO
assert annotation.prompt_quantity is False
def test_parse_returns_none_without_annotation():
assert parse_annotation("Plain step body.") is None
def test_parse_defaults_allocation_to_fifo():
body = f"x\n{valid_block([{k: ETHANOL[k] for k in ('resource_key', 'quantity', 'unit')}])}"
annotation = parse_annotation(body)
assert annotation.consumables[0].allocation is AllocationPolicy.FIFO
def test_parse_prompt_quantity_annotation():
body = f"x\n{valid_block([{'resource_key': 'buffer', 'quantity': None, 'unit': 'mL'}], prompt_quantity=True)}"
annotation = parse_annotation(body)
assert annotation.prompt_quantity is True
assert annotation.consumables[0].quantity is None
def test_parse_malformed_json_raises_annotation_error():
body = "text\n<!-- labvoice:v1\n{not json at all\n-->"
with pytest.raises(AnnotationError):
parse_annotation(body)
def test_parse_schema_violation_missing_required_field():
body = f"x\n{valid_block([{'resource_key': 'ethanol_absolute', 'unit': 'mL'}])}"
with pytest.raises(AnnotationError):
parse_annotation(body)
def test_parse_schema_violation_unknown_allocation():
body = f"x\n{valid_block([{**ETHANOL, 'allocation': 'magical'}])}"
with pytest.raises(AnnotationError):
parse_annotation(body)
def test_parse_schema_violation_negative_quantity():
body = f"x\n{valid_block([{**ETHANOL, 'quantity': -2.0}])}"
with pytest.raises(AnnotationError):
parse_annotation(body)
def test_parse_schema_violation_null_quantity_requires_prompt_quantity():
body = f"x\n{valid_block([{'resource_key': 'buffer', 'quantity': None, 'unit': 'mL'}])}"
with pytest.raises(AnnotationError):
parse_annotation(body)
def test_parse_schema_violation_container_id_outside_specific():
body = f"x\n{valid_block([{**ETHANOL, 'container_id': 31}])}"
with pytest.raises(AnnotationError):
parse_annotation(body)
def test_parse_allows_container_id_with_specific_allocation():
body = f"x\n{valid_block([{**ETHANOL, 'allocation': 'specific', 'container_id': 31}])}"
annotation = parse_annotation(body)
assert annotation.consumables[0].container_id == 31
# --- validation -------------------------------------------------------------
def test_validate_clean_annotation_returns_no_violations():
body = f"Add ethanol\n{valid_block([ETHANOL])}"
assert validate_annotation(body) == []
def test_validate_body_without_annotation_has_nothing_to_check():
assert validate_annotation("No annotations here.") == []
def test_validate_malformed_annotation_reports_violation():
body = "text\n<!-- labvoice:v1\n{broken\n-->"
issues = validate_annotation(body)
assert issues, "malformed annotation must be reported, never silently ignored"
assert any("json" in issue.lower() or "parse" in issue.lower() for issue in issues)
def test_validate_missing_required_field_is_reported():
body = f"x\n{valid_block([{'resource_key': 'ethanol_absolute'}])}"
issues = validate_annotation(body)
assert issues
assert any("unit" in issue.lower() or "quantity" in issue.lower() for issue in issues)
+50
View File
@@ -0,0 +1,50 @@
"""config.py — env-driven settings (plan §11)."""
from pathlib import Path
from mcp_server.config import Settings, get_settings
def test_defaults():
settings = Settings(_env_file=None)
assert settings.elabftw_timeout == 10.0
assert settings.elabftw_retries == 2
assert settings.labvoice_db_path == Path("~/.labvoice/journal.sqlite")
assert settings.labvoice_template_desc_limit == 200
assert settings.labvoice_rest_token is None
assert settings.labvoice_embed_model is None
def test_default_unit_whitelist_matches_plan():
settings = Settings(_env_file=None)
assert "μL" in settings.unit_whitelist
assert "mL" in settings.unit_whitelist
assert "ea" in settings.unit_whitelist
def test_unit_whitelist_parsing_ignores_blanks_and_spaces():
settings = Settings(_env_file=None, labvoice_unit_whitelist=" mL , L , , ea ")
assert settings.unit_whitelist == ("mL", "L", "ea")
def test_api_base_url_strips_trailing_slash():
settings = Settings(_env_file=None, elabftw_url="https://eln.example.org/")
assert settings.api_base_url == "https://eln.example.org/api/v2"
def test_env_overrides(monkeypatch):
monkeypatch.setenv("ELABFTW_URL", "https://eln2.example.org")
monkeypatch.setenv("ELABFTW_TIMEOUT", "3.5")
monkeypatch.setenv("LABVOICE_DB_PATH", "/tmp/other.sqlite")
settings = Settings(_env_file=None)
assert settings.elabftw_url == "https://eln2.example.org"
assert settings.elabftw_timeout == 3.5
assert settings.labvoice_db_path == Path("/tmp/other.sqlite")
def test_get_settings_is_cached(monkeypatch):
monkeypatch.setenv("ELABFTW_URL", "https://a.example.org")
first = get_settings()
monkeypatch.setenv("ELABFTW_URL", "https://b.example.org")
assert get_settings() is first
get_settings.cache_clear()
+138
View File
@@ -0,0 +1,138 @@
"""elabftw.py — async adapter over vendored elabapi_python (plan §3, §12).
Error normalization: ApiException status → typed error; network failures →
api_error; the API key must never leak into messages. Sync client calls run
off the event loop.
Convention under test: the adapter resolves vendored API classes as module
globals of ``mcp_server.elabftw`` and instantiates them with an api_client;
tests stub those globals (see tests/fakes.stub_elabapi). Routes use the
``"*"`` wildcard method so the suite doesn't pin the generated method names.
"""
import threading
import pytest
from elabapi_python.rest import ApiException
from fakes import StubElabapi, stub_elabapi
from mcp_server.errors import ApiError, AuthError, NotFoundError, PermissionDeniedError
from mcp_server.elabftw import ElabftwClient
@pytest.fixture
def hub() -> StubElabapi:
return StubElabapi()
@pytest.fixture
def adapter(settings, hub, monkeypatch) -> ElabftwClient:
stub_elabapi(monkeypatch, hub)
return ElabftwClient(settings)
INFO_PAYLOAD = {"elabftw_version": "5.0.0", "api": "2"}
EXPERIMENT_PAYLOAD = {"id": 123, "title": "Plasmid prep"}
async def test_get_info_returns_schema(adapter, hub):
hub.route("InfoApi", "*", lambda **kw: INFO_PAYLOAD)
info = await adapter.get_info()
assert info.elabftw_version == "5.0.0"
async def test_get_experiment_maps_payload(adapter, hub):
hub.route("ExperimentsApi", "*", lambda **kw: EXPERIMENT_PAYLOAD)
experiment = await adapter.get_experiment(123)
assert experiment.id == 123
assert experiment.title == "Plasmid prep"
@pytest.mark.parametrize(
("status", "expected"),
[(401, AuthError), (403, PermissionDeniedError), (404, NotFoundError), (500, ApiError)],
)
async def test_http_status_normalization(adapter, hub, status, expected):
def boom(**kw):
raise ApiException(status=status, reason="boom")
hub.route("ExperimentsApi", "*", boom)
with pytest.raises(expected):
await adapter.get_experiment(123)
async def test_network_failure_becomes_api_error(adapter, hub):
def boom(**kw):
raise OSError("connection refused")
hub.route("ExperimentsApi", "*", boom)
with pytest.raises(ApiError):
await adapter.get_experiment(123)
@pytest.mark.parametrize("status", [401, 403, 404, 500])
async def test_api_key_never_leaks_into_error_messages(settings, hub, monkeypatch, status):
stub_elabapi(monkeypatch, hub)
def boom(**kw):
raise ApiException(
status=status, reason="boom", body="key=labvoice-secret-key-do-not-leak"
)
hub.route("ExperimentsApi", "*", boom)
client = ElabftwClient(settings)
with pytest.raises(Exception) as excinfo:
await client.get_experiment(123)
assert "labvoice-secret-key-do-not-leak" not in str(excinfo.value)
assert "labvoice-secret-key-do-not-leak" not in repr(excinfo.value)
async def test_sync_calls_run_off_the_event_loop(adapter, hub):
main_thread = threading.get_ident()
def whoami(**kw):
assert threading.get_ident() != main_thread
return EXPERIMENT_PAYLOAD
hub.route("ExperimentsApi", "*", whoami)
await adapter.get_experiment(123)
async def test_steps_endpoint_returns_steps_schema(adapter, hub):
hub.route(
"StepsApi",
"*",
lambda **kw: [{"id": 9, "body": "Add ethanol", "finished": False, "ordering": 1}],
)
steps = await adapter.get_steps("experiments", 123)
assert steps[0].id == 9
async def test_containers_endpoint_returns_container_links(adapter, hub):
hub.route(
"ContainersApi",
"*",
lambda **kw: [{"id": 31, "qty_stored": 50.0, "unit": "mL"}],
)
containers = await adapter.get_containers("items", 12)
assert containers[0].id == 31
assert containers[0].qty_stored == 50.0
async def test_search_normalizes_pagination(adapter, hub):
seen = {}
def capture(**kwargs):
seen.update(kwargs)
return [EXPERIMENT_PAYLOAD]
hub.route("ExperimentsApi", "*", capture)
results = await adapter.search_experiments(q="plasmid", limit=5, offset=10)
assert results[0].id == 123
assert seen, "search must reach the client"
assert "q" in str(seen) or "query" in str(seen)
async def test_client_keeps_settings(settings):
client = ElabftwClient(settings)
assert client.settings is settings
+64
View File
@@ -0,0 +1,64 @@
"""errors.py — typed errors with voice-friendly messages (plan §12)."""
from mcp_server.errors import (
AnnotationError,
ApiError,
AuthError,
ClarificationNeededError,
ErrorKind,
InsufficientStockError,
LabVoiceError,
NotFoundError,
PermissionDeniedError,
)
def test_every_error_kind_maps_to_its_class():
assert AuthError.kind is ErrorKind.AUTH
assert PermissionDeniedError.kind is ErrorKind.PERMISSION
assert NotFoundError.kind is ErrorKind.NOT_FOUND
assert ApiError.kind is ErrorKind.API
assert AnnotationError.kind is ErrorKind.ANNOTATION
assert ClarificationNeededError.kind is ErrorKind.CLARIFICATION
assert InsufficientStockError.kind is ErrorKind.INSUFFICIENT_STOCK
assert LabVoiceError.kind is ErrorKind.API
def test_kind_values_are_the_wire_strings():
assert ErrorKind.AUTH == "auth_error"
assert ErrorKind.PERMISSION == "permission_error"
assert ErrorKind.NOT_FOUND == "not_found"
assert ErrorKind.CLARIFICATION == "clarification"
assert ErrorKind.INSUFFICIENT_STOCK == "insufficient_stock"
def test_message_and_detail_are_preserved():
err = NotFoundError("Experiment 123 not found.", detail="GET /experiments/123")
assert str(err) == "Experiment 123 not found."
assert err.message == "Experiment 123 not found."
assert err.detail == "GET /experiments/123"
def test_all_labvoice_errors_share_the_base():
for cls in (
AuthError,
PermissionDeniedError,
NotFoundError,
ApiError,
AnnotationError,
ClarificationNeededError,
InsufficientStockError,
):
assert issubclass(cls, LabVoiceError)
def test_clarification_options_default_empty_and_are_preserved():
err = ClarificationNeededError("Which ethanol?")
assert err.options == []
err = ClarificationNeededError("Which ethanol?", options=["Absolute Ethanol", "96%"])
assert err.options == ["Absolute Ethanol", "96%"]
def test_error_messages_are_voice_friendly_strings():
err = PermissionDeniedError("This API key is read-only; I can't finish the step.")
assert isinstance(err.message, str) and err.message
+135
View File
@@ -0,0 +1,135 @@
"""journal.py — SQLite saga journal: idempotency, statuses, durability (plan §10)."""
import pytest
from mcp_server.journal import ActionStatus, OperationStatus, SagaJournal
@pytest.fixture
async def fresh_journal(tmp_path):
journal = SagaJournal(tmp_path / "saga.sqlite")
await journal.connect()
yield journal
await journal.close()
async def test_connect_is_idempotent_safe_for_new_db(fresh_journal):
record = await fresh_journal.begin_operation("complete_step", "op-1")
assert record.status is OperationStatus.PLANNED
assert record.kind == "complete_step"
assert record.actions == []
async def test_begin_generates_operation_id_when_none_given(fresh_journal):
record = await fresh_journal.begin_operation("complete_step")
assert record.operation_id
async def test_record_and_mark_actions(fresh_journal):
await fresh_journal.begin_operation("complete_step", "op-1")
action = await fresh_journal.record_action(
"op-1", "decrement_container", {"item_id": 12, "subitem_id": 31, "qty_stored": 48.0}
)
assert action.status is ActionStatus.PLANNED
assert action.payload["subitem_id"] == 31
marked = await fresh_journal.mark_action(
"op-1", action.id, ActionStatus.DONE, response={"ok": True}
)
assert marked.status is ActionStatus.DONE
assert marked.response == {"ok": True}
async def test_get_operation_returns_actions_in_order(fresh_journal):
await fresh_journal.begin_operation("complete_step", "op-1")
first = await fresh_journal.record_action("op-1", "decrement_container", {"subitem_id": 31})
second = await fresh_journal.record_action("op-1", "finish_step", {"step_id": 9})
third = await fresh_journal.record_action("op-1", "post_comment", {"body": "done"})
record = await fresh_journal.get_operation("op-1")
assert [a.id for a in record.actions] == [first.id, second.id, third.id]
assert [a.action for a in record.actions] == [
"decrement_container",
"finish_step",
"post_comment",
]
async def test_has_completed_only_after_finish(fresh_journal):
await fresh_journal.begin_operation("complete_step", "op-1")
action = await fresh_journal.record_action("op-1", "decrement_container", {})
await fresh_journal.mark_action("op-1", action.id, ActionStatus.DONE)
assert await fresh_journal.has_completed("op-1") is False
await fresh_journal.finish_operation("op-1", OperationStatus.COMPLETED)
assert await fresh_journal.has_completed("op-1") is True
async def test_begin_with_completed_operation_id_is_idempotent_replay(fresh_journal):
"""Plan §13: double execution of the same operation_id is idempotent."""
await fresh_journal.begin_operation("complete_step", "op-1")
await fresh_journal.record_action("op-1", "decrement_container", {"subitem_id": 31})
await fresh_journal.finish_operation("op-1", OperationStatus.COMPLETED)
replayed = await fresh_journal.begin_operation("complete_step", "op-1")
assert replayed.status is OperationStatus.COMPLETED
assert len(replayed.actions) == 1
record = await fresh_journal.get_operation("op-1")
assert len(record.actions) == 1, "replay must not append new actions"
async def test_begin_with_inprogress_operation_id_resumes_it(fresh_journal):
await fresh_journal.begin_operation("complete_step", "op-1")
await fresh_journal.record_action("op-1", "decrement_container", {"subitem_id": 31})
resumed = await fresh_journal.begin_operation("complete_step", "op-1")
assert resumed.status is OperationStatus.PLANNED
assert len(resumed.actions) == 1
async def test_failed_and_compensated_statuses_persist(fresh_journal):
await fresh_journal.begin_operation("complete_step", "op-1")
action = await fresh_journal.record_action("op-1", "finish_step", {"step_id": 9})
await fresh_journal.mark_action("op-1", action.id, ActionStatus.FAILED)
await fresh_journal.finish_operation("op-1", OperationStatus.COMPENSATED)
record = await fresh_journal.get_operation("op-1")
assert record.status is OperationStatus.COMPENSATED
assert record.actions[0].status is ActionStatus.FAILED
assert await fresh_journal.has_completed("op-1") is False
async def test_partial_failure_status_roundtrip(fresh_journal):
"""Plan §10: compensation failure ⇒ partial_failure in the journal."""
await fresh_journal.begin_operation("complete_step", "op-1")
await fresh_journal.finish_operation("op-1", OperationStatus.PARTIAL_FAILURE)
record = await fresh_journal.get_operation("op-1")
assert record.status is OperationStatus.PARTIAL_FAILURE
async def test_journal_persists_across_reconnect(tmp_path):
"""The journal doubles as the audit log — it must survive restarts."""
path = tmp_path / "saga.sqlite"
journal = SagaJournal(path)
await journal.connect()
await journal.begin_operation("complete_step", "op-1")
action = await journal.record_action("op-1", "decrement_container", {"subitem_id": 31})
await journal.mark_action("op-1", action.id, ActionStatus.DONE, response={"qty": 48.0})
await journal.finish_operation("op-1", OperationStatus.COMPLETED)
await journal.close()
reopened = SagaJournal(path)
await reopened.connect()
try:
record = await reopened.get_operation("op-1")
assert record.kind == "complete_step"
assert record.status is OperationStatus.COMPLETED
assert record.actions[0].response == {"qty": 48.0}
assert await reopened.has_completed("op-1") is True
finally:
await reopened.close()
async def test_get_operation_of_unknown_id_raises(fresh_journal):
with pytest.raises(Exception):
await fresh_journal.get_operation("nope")
+72
View File
@@ -0,0 +1,72 @@
"""models.py — compact result shapes (plan §6: ids, titles, quantities, statuses)."""
import pytest
from pydantic import ValidationError
from mcp_server.models import (
AllocationPolicy,
CompleteStepResult,
Consumable,
HandoffContext,
NextStep,
ProtocolAnnotation,
StepInfo,
)
def test_consumable_defaults_to_fifo_and_required():
consumable = Consumable(resource_key="ethanol_absolute", quantity=2.0, unit="mL")
assert consumable.allocation is AllocationPolicy.FIFO
assert consumable.optional is False
assert consumable.container_id is None
def test_consumable_quantity_may_be_null_for_prompted_steps():
consumable = Consumable(resource_key="x", quantity=None, unit="mL")
assert consumable.quantity is None
def test_consumable_rejects_missing_required_fields():
with pytest.raises(ValidationError):
Consumable(resource_key="x") # quantity and unit missing
def test_annotation_defaults():
annotation = ProtocolAnnotation()
assert annotation.consumables == []
assert annotation.prompt_quantity is False
def test_step_info_defaults_unfinished():
step = StepInfo(id=9, body="Add ethanol")
assert step.finished is False
assert step.position is None
def test_complete_step_result_status_is_restricted():
for status in ("completed", "protocol_complete", "reverted", "partial_failure"):
CompleteStepResult(
experiment_id=123,
status=status,
step=StepInfo(id=9, body="b"),
)
with pytest.raises(ValidationError):
CompleteStepResult(
experiment_id=123,
status="exploded",
step=StepInfo(id=9, body="b"),
)
def test_next_step_carries_consumables_and_stock():
next_step = NextStep(experiment_id=123, step=StepInfo(id=9, body="Add ethanol"))
assert next_step.consumables == []
assert next_step.stock == []
def test_handoff_context_is_id_only_and_rest_optional():
"""Plan §8: the app forwards only ids; device/location are optional extras."""
handoff = HandoffContext(experiment_id=123, user_id=2)
assert handoff.step_id is None
assert handoff.device_id is None
assert handoff.location_id is None
+89
View File
@@ -0,0 +1,89 @@
"""resolve.py — resource_key → item id precedence and ambiguity (plan §5).
Precedence: local mapping store (authoritative at execution) → resource marker
→ (setup-time-only matchers/title, never silently applied at runtime).
Ambiguous or missing ⇒ ClarificationNeededError listing candidates.
"""
import pytest
from fakes import make_item
from mcp_server.errors import ClarificationNeededError
async def test_resolve_via_marker_when_store_is_empty(client, real_resolver):
"""Marker comment in the resource body is discovered without any mapping."""
assert await real_resolver.resolve("ethanol_absolute") == 12
async def test_mapping_store_is_authoritative_over_marker(client, real_resolver):
await real_resolver.upsert_mapping("ethanol_absolute", 12)
# Even if another item also carries the marker, the store binding wins.
client.items[13] = make_item(id=13, title="Ethanol bottle (old)", body="<!-- labvoice:resource-key=ethanol_absolute -->")
assert await real_resolver.resolve("ethanol_absolute") == 12
async def test_resolve_unknown_key_raises_clarification(client, real_resolver):
with pytest.raises(ClarificationNeededError) as excinfo:
await real_resolver.resolve("sodium_azide")
assert "sodium_azide" in str(excinfo.value)
async def test_resolve_ambiguous_marker_raises_clarification_with_candidates(client, real_resolver):
client.items[13] = make_item(id=13, title="Ethanol bottle 2", body="<!-- labvoice:resource-key=ethanol_absolute -->")
with pytest.raises(ClarificationNeededError) as excinfo:
await real_resolver.resolve("ethanol_absolute")
assert excinfo.value.options, "clarification must list candidate resources"
async def test_upsert_mapping_is_idempotent(client, real_resolver):
await real_resolver.upsert_mapping("ethanol_absolute", 12)
await real_resolver.upsert_mapping("ethanol_absolute", 12)
mappings = await real_resolver.list_mappings()
assert [m.item_id for m in mappings if m.resource_key == "ethanol_absolute"] == [12]
async def test_upsert_mapping_updates_existing_binding(client, real_resolver):
await real_resolver.upsert_mapping("ethanol_absolute", 12)
await real_resolver.upsert_mapping("ethanol_absolute", 99)
assert await real_resolver.resolve("ethanol_absolute") == 99
async def test_list_mappings_empty_store(real_resolver):
assert await real_resolver.list_mappings() == []
async def test_export_mapping_produces_portable_json(client, real_resolver):
await real_resolver.upsert_mapping("ethanol_absolute", 12)
data = await real_resolver.export_mapping()
assert "ethanol_absolute" in data
entry = data["ethanol_absolute"]
assert entry.get("title") in (None, "Absolute Ethanol")
async def test_import_mapping_returns_proposals(client, real_resolver):
data = {"ethanol_absolute": {"cas": "64-17-5", "title": "Absolute Ethanol", "unit": "mL"}}
proposals = await real_resolver.import_mapping(data)
assert proposals
assert proposals[0].resource_key == "ethanol_absolute"
async def test_find_candidates_finds_marker_match(client, real_resolver):
proposals = await real_resolver.find_candidates("ethanol_absolute")
assert any(p.item_id == 12 for p in proposals)
async def test_find_candidates_finds_exact_title_match(client, real_resolver):
"""Exact title match is a setup-time suggestion only — proposals carry scores."""
client.items[14] = make_item(id=14, title="sodium_azide", body="")
proposals = await real_resolver.find_candidates("sodium_azide")
assert any(p.item_id == 14 for p in proposals)
proposal = next(p for p in proposals if p.item_id == 14)
assert proposal.score is not None
async def test_exact_title_never_applies_silently_at_runtime(client, real_resolver):
"""An exact title match alone must not create a runtime binding (plan §5.4)."""
client.items[14] = make_item(id=14, title="sodium_azide", body="")
with pytest.raises(ClarificationNeededError):
await real_resolver.resolve("sodium_azide")
+184
View File
@@ -0,0 +1,184 @@
"""rest.py — read-only REST mirror for the mobile app (plan §8, §13).
Bearer-token protected, mutations never exposed, same compact shapes and typed
errors as the MCP tools. Tests use Starlette's TestClient (ASGI, no network).
Convention under test: the app resolves its read services via
``mcp_server.server.build_services`` — monkeypatched here (see conftest).
"""
import pytest
from fastapi import FastAPI
from starlette.testclient import TestClient
from mcp_server.errors import LabVoiceError
TOKEN = "app-bearer-token-123"
AUTH = {"Authorization": f"Bearer {TOKEN}"}
@pytest.fixture
def rest_settings(settings):
settings.labvoice_rest_token = TOKEN
return settings
@pytest.fixture
def rest_app(rest_settings, patch_build_services) -> FastAPI:
patch_build_services()
from mcp_server.rest import create_rest_app
return create_rest_app(rest_settings)
@pytest.fixture
def api(rest_app) -> TestClient:
return TestClient(rest_app)
# --- auth --------------------------------------------------------------------
def test_missing_token_is_rejected(api):
assert api.get("/api/experiments").status_code == 401
assert api.get("/api/experiments/123/state").status_code == 401
assert api.get("/api/experiments/123/next-step").status_code == 401
assert api.get("/api/templates").status_code == 401
def test_wrong_token_is_rejected(api):
response = api.get("/api/experiments", headers={"Authorization": "Bearer nope"})
assert response.status_code == 401
def test_correct_token_passes(api):
response = api.get("/api/experiments", headers=AUTH)
assert response.status_code == 200
def test_rest_app_requires_token_configured(settings, patch_build_services):
patch_build_services()
from mcp_server.rest import create_rest_app
settings.labvoice_rest_token = None
with pytest.raises(Exception) as excinfo:
create_rest_app(settings)
assert not isinstance(excinfo.value, NotImplementedError)
# --- endpoints ----------------------------------------------------------------
def test_find_experiments_returns_compact_summaries(api):
response = api.get("/api/experiments", params={"q": "plasmid"}, headers=AUTH)
assert response.status_code == 200
payload = response.json()
assert isinstance(payload, list) and payload
summary = payload[0]
assert summary["id"] == 123
assert summary["title"] == "Plasmid prep"
def test_next_step_is_the_hot_path(api):
"""Plan §8: GET /api/experiments/{id}/next-step returns the parsed step + stock."""
response = api.get("/api/experiments/123/next-step", headers=AUTH)
assert response.status_code == 200
payload = response.json()
assert payload["experiment_id"] == 123
assert payload["step"]["id"] == 9
assert payload["step"]["finished"] is False
assert payload["consumables"][0]["resource_key"] == "ethanol_absolute"
assert payload["consumables"][0]["quantity"] == 2.0
assert payload["stock"][0]["container_id"] == 31
assert payload["stock"][0]["available"] == 50.0
def test_next_step_null_when_protocol_complete(api, client):
for step in client.experiments[123].steps:
step.finished = True
response = api.get("/api/experiments/123/next-step", headers=AUTH)
assert response.status_code == 200
assert response.json() is None
def test_state_snapshot_shape(api):
"""Plan §8: `state` is what the app renders: title, steps, stock, next step."""
response = api.get("/api/experiments/123/state", headers=AUTH)
assert response.status_code == 200
state = response.json()
assert state["experiment_id"] == 123
assert state["title"] == "Plasmid prep"
assert state["unfinished_steps"] == [9, 10]
next_step = state["next_step"]
assert set(next_step) == {"experiment_id", "step", "consumables", "stock"}
assert next_step["step"]["id"] == 9
assert len(next_step["consumables"]) == 1
assert {c["container_id"] for c in next_step["stock"]} == {31, 32}
def test_state_of_unknown_experiment_is_typed_error(api):
response = api.get("/api/experiments/9999/state", headers=AUTH)
assert response.status_code == 404
payload = response.json()
assert payload["error"] == "not_found"
assert "message" in payload
def test_templates_endpoint_lists_summaries(api, client):
from fakes import make_template
client.templates[7] = make_template()
response = api.get("/api/templates", headers=AUTH)
assert response.status_code == 200
templates = response.json()
summary = templates[0]
assert summary["id"] == 7
assert summary["title"] == "PCR cleanup"
assert "short_description" in summary
assert summary["tags"] == ["dna", "cleanup"]
def test_templates_search_ranks(api, client):
from fakes import make_template
client.templates[7] = make_template()
response = api.get("/api/templates", params={"q": "pcr"}, headers=AUTH)
assert response.status_code == 200
assert response.json()[0]["id"] == 7
# --- mutations are never exposed ----------------------------------------------
def test_no_mutation_routes_exist(rest_app):
"""Plan §8: mutations exist only as MCP tools so every mutation is journaled."""
paths = rest_app.openapi().get("paths", {})
assert paths, "REST app must document its endpoints"
for path, operations in paths.items():
for method in operations:
assert method.lower() == "get", f"{method.upper()} {path} must not exist"
# --- unit-level helpers --------------------------------------------------------
def test_error_payload_shape():
from mcp_server.rest import error_payload
assert error_payload("clarification", "Which ethanol?") == {
"error": "clarification",
"message": "Which ethanol?",
}
def test_verify_token_accepts_configured_token(rest_settings):
from mcp_server.rest import verify_token
assert verify_token(TOKEN) is None
def test_verify_token_rejects_wrong_token(rest_settings):
from mcp_server.rest import verify_token
with pytest.raises(LabVoiceError):
verify_token("wrong")
+97
View File
@@ -0,0 +1,97 @@
"""Safety checks as tests (plan §13).
* a read-only API key must fail cleanly on every mutating tool
* failed workflows leave no partial state (no comment, no finished step)
* the API key never appears in any tool result
"""
import json
import pytest
from mcp.client import Client
from fakes import make_template
from mcp_server.tools import register_tools
TOKEN_SAFE_MESSAGE = "labvoice-secret-key-do-not-leak"
MUTATING_TOOLS = {
"complete_next_protocol_step": {"experiment_id": 123},
"complete_protocol_step": {"experiment_id": 123, "step_id": 9},
"record_protocol_observation": {"experiment_id": 123, "comment": "note"},
"create_experiment_from_template": {"template_id": 7},
"adjust_inventory": {"amount": 5.0, "unit": "mL", "container_id": 31},
}
@pytest.fixture
async def mcp_client(patch_build_services, client):
from mcp.server.fastmcp import FastMCP
patch_build_services()
client.templates[7] = make_template()
mcp = FastMCP("labvoice-safety")
register_tools(mcp)
async with Client(mcp, raise_exceptions=False) as c:
yield c
def _state_fingerprint(client):
experiment = client.experiments[123]
return {
"steps": [(s.id, s.finished) for s in experiment.steps],
"comments": [c.body for c in experiment.comments],
"containers": [
(i.id, c.sub_id, c.qty_stored)
for i in client.items.values()
for c in i.containers
],
}
@pytest.mark.parametrize("tool", sorted(MUTATING_TOOLS))
async def test_read_only_key_fails_cleanly_on_every_mutating_tool(
mcp_client, client, tool
):
"""Plan §13: read-only key ⇒ every mutation fails with a clear message."""
client.read_only = True
before = _state_fingerprint(client)
result = await mcp_client.call_tool(tool, MUTATING_TOOLS[tool])
payload = None
structured = getattr(result, "structured_content", None)
texts = " ".join(getattr(b, "text", "") for b in getattr(result, "content", []))
if structured is not None:
payload = structured
assert getattr(result, "is_error", False) or (
payload is not None and payload.get("ok") is False
) or "permission" in texts.lower() or "read-only" in texts.lower(), (
f"{tool} must not silently succeed with a read-only key"
)
assert _state_fingerprint(client) == before, f"{tool} must not mutate anything"
async def test_aborted_workflow_leaves_no_partial_state(mcp_client, client, resolver_stub):
"""Unresolvable resource ⇒ no finished step, no comment, no stock change."""
resolver_stub.mappings.clear()
before = _state_fingerprint(client)
result = await mcp_client.call_tool(
"complete_next_protocol_step", {"experiment_id": 123}
)
texts = " ".join(getattr(b, "text", "") for b in getattr(result, "content", []))
assert result.is_error or "clarification" in texts.lower() or "not know" in texts.lower()
assert _state_fingerprint(client) == before
async def test_error_results_never_contain_the_api_key(mcp_client, client, resolver_stub):
client.read_only = True
resolver_stub.mappings.clear()
for tool, args in MUTATING_TOOLS.items():
result = await mcp_client.call_tool(tool, dict(args))
texts = " ".join(getattr(b, "text", "") for b in getattr(result, "content", []))
structured = getattr(result, "structured_content", None)
blob = texts + json.dumps(structured, default=str)
assert TOKEN_SAFE_MESSAGE not in blob
+132
View File
@@ -0,0 +1,132 @@
"""templates.py — template search/ranking and creation flow (plan §9, §13)."""
import pytest
from fakes import FakeElabftwClient, FakeTemplate, make_template
from mcp_server.errors import NotFoundError
def ranking_client() -> FakeElabftwClient:
return FakeElabftwClient(
templates=[
FakeTemplate(id=1, title="Cell culture", description="Maintain adherent lines."),
FakeTemplate(
id=2,
title="Protein extraction",
description="Lyse cells; collect supernatant in pcr tubes.",
),
FakeTemplate(id=3, title="Ethanol precipitation", description="Precipitate DNA.", tags=["pcr"]),
FakeTemplate(id=4, title="PCR cleanup", description="Purify an amplicon."),
]
)
# --- short description ------------------------------------------------------
async def test_short_description_is_first_paragraph(templates_service, client):
client.templates[7] = make_template()
result = await templates_service.list_templates()
assert result[0].short_description == "Purify an amplicon after PCR."
async def test_short_description_truncated_to_limit(templates_service, settings, client):
settings.labvoice_template_desc_limit = 20
client.templates[7] = make_template(
description="P" * 100 + "\n\nSecond paragraph that must not appear."
)
result = await templates_service.list_templates()
summary = result[0]
assert len(summary.short_description) <= 20
assert "Second paragraph" not in summary.short_description
async def test_short_description_empty_when_no_description(templates_service, client):
client.templates[7] = make_template(description="")
result = await templates_service.list_templates()
assert result[0].short_description == ""
async def test_summary_carries_id_title_tags(templates_service, client):
client.templates[7] = make_template()
result = await templates_service.list_templates()
summary = result[0]
assert summary.id == 7
assert summary.title == "PCR cleanup"
assert summary.tags == ["dna", "cleanup"]
# --- ranking ----------------------------------------------------------------
async def test_no_query_preserves_listing_order(templates_service, client):
client.templates.update({t.id: t for t in ranking_client().templates.values()})
result = await templates_service.list_templates()
assert [t.id for t in result] == [1, 2, 3, 4]
async def test_title_match_ranks_above_tag_and_description_match(templates_service, client):
client.templates.update({t.id: t for t in ranking_client().templates.values()})
result = await templates_service.list_templates(query="pcr")
ids = [t.id for t in result]
assert ids.index(4) < ids.index(3) < ids.index(2), (
"expected title(4) > tags(3) > description(2) per plan §9 weights"
)
async def test_non_matching_templates_rank_below_matches(templates_service, client):
client.templates.update({t.id: t for t in ranking_client().templates.values()})
result = await templates_service.list_templates(query="pcr")
assert result[-1].id == 1
async def test_limit_is_respected(templates_service, client):
client.templates.update({t.id: t for t in ranking_client().templates.values()})
result = await templates_service.list_templates(query="pcr", limit=2)
assert len(result) == 2
assert result[0].id == 4
# --- creation ----------------------------------------------------------------
async def test_create_from_template_returns_experiment_and_first_step(templates_service, client):
client.templates[7] = make_template()
result = await templates_service.create_experiment_from_template(7)
assert result.ok is True
assert result.experiment_id == 1000
assert result.title == "PCR cleanup"
assert result.first_step is not None
assert result.first_step.id == 41
assert result.first_step.finished is False
async def test_create_with_explicit_title(templates_service, client):
client.templates[7] = make_template()
result = await templates_service.create_experiment_from_template(7, title="My amplicon run")
assert result.title == "My amplicon run"
async def test_created_experiment_copies_template_steps_and_annotations(
templates_service, client
):
"""eLabFTW copies template steps — annotations must come along (plan §9)."""
from fakes import ANNOTATION_ETHANOL_FIFO, FakeStep
client.templates[7] = make_template(
steps=[FakeStep(id=41, body=f"Add ethanol\n{ANNOTATION_ETHANOL_FIFO}", ordering=1)]
)
result = await templates_service.create_experiment_from_template(7)
created = client.experiments[result.experiment_id]
assert "labvoice:v1" in created.steps[0].body
async def test_create_from_unknown_template_fails_cleanly(templates_service, client):
with pytest.raises((NotFoundError, KeyError)):
await templates_service.create_experiment_from_template(9999)
async def test_search_index_build_is_optional(templates_service, client):
"""Lexical fallback works without an explicitly built index (plan §9 fallback)."""
client.templates.update({t.id: t for t in ranking_client().templates.values()})
result = await templates_service.list_templates(query="pcr")
assert result[0].id == 4
+169
View File
@@ -0,0 +1,169 @@
"""tools.py + MCP surface — small-model-friendly tool schemas (plan §6, §13).
Checks the tool *surface* (names, short descriptions, small input schemas)
over the real FastMCP server using the in-process client, plus a golden
result for the headline workflow.
Note: the plan's "ten tools" are the reads + aggregates; the scaffold adds
four admin/setup tools (scan/map/export/import), so the registered surface
is fourteen functions.
"""
import json
import pytest
from mcp.client import Client
from mcp_server.tools import TOOL_FUNCTIONS, register_tools
EXPECTED_TOOLS = {
"find_experiments",
"get_experiment_context",
"get_next_protocol_step",
"list_experiment_templates",
"validate_protocol_template",
"complete_next_protocol_step",
"complete_protocol_step",
"record_protocol_observation",
"create_experiment_from_template",
"adjust_inventory",
"scan_instance",
"map_resource",
"export_mapping",
"import_mapping",
}
def result_json(call_result):
"""Extract the JSON payload from a CallToolResult (structured or text)."""
structured = getattr(call_result, "structured_content", None)
if structured is not None:
return structured
for block in call_result.content:
text = getattr(block, "text", None)
if text:
return json.loads(text)
raise AssertionError(f"No usable content in tool result: {call_result!r}")
@pytest.fixture
async def mcp_server(patch_build_services):
from mcp.server.fastmcp import FastMCP
patch_build_services()
mcp = FastMCP("labvoice-test")
register_tools(mcp)
return mcp
@pytest.fixture
async def mcp_client(mcp_server):
async with Client(mcp_server, raise_exceptions=True) as client:
yield client
# --- tool surface ------------------------------------------------------------
async def test_all_tools_registered_and_listed(mcp_client):
tools = await mcp_client.list_tools()
names = {t.name for t in tools}
assert names == EXPECTED_TOOLS
assert len(TOOL_FUNCTIONS) == len(EXPECTED_TOOLS)
async def test_every_tool_has_a_short_description(mcp_client):
"""Plan §6: tool descriptions are one or two short sentences."""
for tool in await mcp_client.list_tools():
assert tool.description, f"{tool.name} needs a description"
assert len(tool.description) <= 300, f"{tool.name} description too long for a small model"
async def test_input_schemas_are_small(mcp_client):
"""Plan §13 lint: small input schemas — no kitchen-sink parameter lists."""
for tool in await mcp_client.list_tools():
properties = tool.inputSchema.get("properties", {})
assert len(properties) <= 5, f"{tool.name} takes {len(properties)} parameters"
assert tool.inputSchema.get("type") == "object"
async def test_input_schemas_match_function_signatures(mcp_client):
import inspect
for tool in await mcp_client.list_tools():
fn = next(f for f in TOOL_FUNCTIONS if f.__name__ == tool.name)
params = inspect.signature(fn).parameters
properties = tool.inputSchema.get("properties", {})
assert set(properties) == set(params), f"{tool.name}: schema/args mismatch"
required = set(tool.inputSchema.get("required", []))
expected_required = {
name for name, p in params.items() if p.default is inspect.Parameter.empty
}
assert required == expected_required, f"{tool.name}: required set mismatch"
async def test_mutating_tools_are_clearly_named(mcp_client):
"""A small model must not confuse reads with mutations (plan §2)."""
mutating = {
"complete_next_protocol_step",
"complete_protocol_step",
"record_protocol_observation",
"create_experiment_from_template",
"adjust_inventory",
"map_resource",
"scan_instance",
"import_mapping",
}
tools = {t.name: t for t in await mcp_client.list_tools()}
for name in mutating:
assert name in tools
# --- golden workflow result over the MCP transport -----------------------------
async def test_headline_workflow_golden_result_over_mcp(mcp_client, client):
"""Plan §13: golden result for complete_next_protocol_step (§7)."""
call_result = await mcp_client.call_tool(
"complete_next_protocol_step",
{"experiment_id": 123, "comment": "Done at the bench"},
)
payload = result_json(call_result)
assert payload["ok"] is True
assert payload["status"] == "completed"
assert payload["experiment_id"] == 123
assert payload["step"]["id"] == 9
assert payload["step"]["finished"] is True
assert payload["consumed"][0]["resource_key"] == "ethanol_absolute"
assert payload["consumed"][0]["container_id"] == 31
assert payload["consumed"][0]["amount"] == "2.0 mL"
assert payload["consumed"][0]["remaining"] == "48.0 mL"
assert payload["next_step"]["id"] == 10
# stock really moved in eLabFTW
assert client.items[12].containers[0].qty_stored == 48.0
async def test_read_tool_over_mcp_returns_compact_json(mcp_client):
call_result = await mcp_client.call_tool("get_next_protocol_step", {"experiment_id": 123})
payload = result_json(call_result)
assert payload["step"]["id"] == 9
assert payload["consumables"][0]["resource_key"] == "ethanol_absolute"
async def test_clarification_over_mcp_is_a_typed_error(mcp_client, resolver_stub):
resolver_stub.mappings.clear()
call_result = await mcp_client.call_tool(
"complete_next_protocol_step", {"experiment_id": 123}
)
payload = result_json(call_result)
# Either the transport surfaces the error or the payload carries it —
# both are acceptable, but it must be the clarification kind either way.
if getattr(call_result, "is_error", False):
text = " ".join(getattr(b, "text", "") for b in call_result.content)
assert "clarification" in text or "ethanol" in text
else:
assert payload.get("error") == "clarification" or payload.get("status") in (
"clarification",
"reverted",
)
+92
View File
@@ -0,0 +1,92 @@
"""workflows/inventory.py — stock reads + adjust_inventory (plan §6, §10)."""
import pytest
from mcp_server.errors import ClarificationNeededError, NotFoundError
# --- stock reads ------------------------------------------------------------
async def test_stock_for_resource_lists_containers(inventory):
stock = await inventory.stock_for_resource("ethanol_absolute")
assert [s.container_id for s in stock] == [31, 32]
assert stock[0].available == 50.0
assert stock[0].unit == "mL"
assert stock[0].amount == "50.0 mL"
async def test_stock_for_unknown_resource_raises_clarification(inventory, resolver_stub):
resolver_stub.mappings.clear()
with pytest.raises(ClarificationNeededError):
await inventory.stock_for_resource("unobtainium")
# --- adjust_inventory -------------------------------------------------------
async def test_adjust_adds_to_container_by_id(inventory, client):
result = await inventory.adjust_inventory(amount=500.0, unit="mL", container_id=31)
assert result.ok is True
assert result.container_id == 31
assert result.amount == "500.0 mL"
assert result.remaining == "550.0 mL"
assert client.items[12].containers[0].qty_stored == 550.0
async def test_adjust_supports_correction_negative_delta(inventory, client):
await inventory.adjust_inventory(amount=-2.0, unit="mL", container_id=31)
assert client.items[12].containers[0].qty_stored == 48.0
async def test_adjust_by_resource_key_uses_allocable_container(inventory, client):
result = await inventory.adjust_inventory(
amount=100.0, unit="mL", resource_key="ethanol_absolute"
)
assert result.resource_key == "ethanol_absolute"
assert result.container_id == 31
assert result.remaining == "150.0 mL"
async def test_adjust_converts_units(inventory, client):
"""“Add half a litre” against an mL-stocked container."""
await inventory.adjust_inventory(amount=0.5, unit="L", container_id=31)
assert client.items[12].containers[0].qty_stored == 550.0
async def test_adjust_rejects_non_whitelisted_unit(inventory):
with pytest.raises(ClarificationNeededError):
await inventory.adjust_inventory(amount=1.0, unit="fl-oz", container_id=31)
async def test_adjust_rejects_cross_dimension_unit(inventory):
with pytest.raises(ClarificationNeededError):
await inventory.adjust_inventory(amount=5.0, unit="g", container_id=31)
async def test_adjust_unknown_container_fails_cleanly(inventory):
with pytest.raises(NotFoundError):
await inventory.adjust_inventory(amount=1.0, unit="mL", container_id=9999)
async def test_adjust_requires_container_or_resource(inventory):
with pytest.raises(ClarificationNeededError):
await inventory.adjust_inventory(amount=1.0, unit="mL")
async def test_adjust_is_journaled(inventory, journal):
await inventory.adjust_inventory(amount=500.0, unit="mL", container_id=31)
assert journal.db_path.exists()
async def test_adjust_failure_does_not_silently_lose_data(inventory, client, monkeypatch):
"""If the write fails, the workflow surfaces the error (journaled saga)."""
from mcp_server.errors import ApiError
async def fail_write(entity_type, entity_id, subitem_id, qty_stored):
raise ApiError("write refused")
monkeypatch.setattr(inventory.client, "set_container_quantity", fail_write)
with pytest.raises(ApiError):
await inventory.adjust_inventory(amount=500.0, unit="mL", container_id=31)
assert client.items[12].containers[0].qty_stored == 50.0
+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)
+135
View File
@@ -0,0 +1,135 @@
"""workflows/setup.py — scan_instance, map_resource, validate_protocol_template (plan §5)."""
import pytest
from fakes import FakeItem, make_item
# --- scan_instance ----------------------------------------------------------
async def test_scan_counts_containers_and_markers(setup, client):
client.items[13] = make_item(id=13, title="Water bottle", body="plain", containers=[])
result = await setup.scan_instance()
assert result.containers_seen == 2 # containers on item 12
assert result.markers_found == 1 # marker on item 12
async def test_scan_proposes_marker_matches(setup, client):
result = await setup.scan_instance()
proposal = next(p for p in result.proposals if p.resource_key == "ethanol_absolute")
assert proposal.item_id == 12
assert proposal.reason is not None
async def test_scan_proposes_exact_title_match_as_suggestion(setup, client):
"""Exact title matches are setup-time suggestions with scores (plan §5.4)."""
client.items[14] = FakeItem(id=14, title="sodium_azide", body="")
result = await setup.scan_instance()
proposal = next((p for p in result.proposals if p.item_id == 14), None)
assert proposal is not None
assert proposal.resource_key == "sodium_azide"
# --- map_resource -----------------------------------------------------------
async def test_map_resource_binds_key(setup, client, resolver_stub):
entry = await setup.map_resource("ethanol_absolute", 12)
assert entry.resource_key == "ethanol_absolute"
assert entry.item_id == 12
assert resolver_stub.mappings["ethanol_absolute"] == 12
async def test_map_resource_is_idempotent_upsert(setup, resolver_stub):
await setup.map_resource("ethanol_absolute", 12)
await setup.map_resource("ethanol_absolute", 12)
assert resolver_stub.mappings["ethanol_absolute"] == 12
entries = await resolver_stub.list_mappings()
assert len([e for e in entries if e.resource_key == "ethanol_absolute"]) == 1
async def test_map_resource_rebinding_updates(setup, resolver_stub):
await setup.map_resource("ethanol_absolute", 12)
await setup.map_resource("ethanol_absolute", 13)
assert resolver_stub.mappings["ethanol_absolute"] == 13
# --- validate_protocol_template ---------------------------------------------
def _annotated_template_client():
from fakes import FakeElabftwClient, FakeStep, make_experiment, make_item, make_template
annotated_step = FakeStep(
id=41,
body=(
"Add ethanol\n"
"<!-- labvoice:v1\n"
'{"consumables": [{"resource_key": "ethanol_absolute", "quantity": 2.0,'
' "unit": "mL"}]}\n'
"-->"
),
ordering=1,
)
broken_step = FakeStep(
id=42, body="Broken\n<!-- labvoice:v1\n{invalid json\n-->", ordering=2
)
templates = [
make_template(
id=7,
steps=[annotated_step, broken_step],
)
]
return FakeElabftwClient(
templates=templates, experiments=[make_experiment()], items=[make_item()]
)
async def test_validate_flags_unmapped_resource_key(setup, client, resolver_stub):
"""Annotation ok, but the mapping store lacks the key → blocking error."""
client.templates[7] = _annotated_template_client().templates[7]
resolver_stub.mappings.clear()
report = await setup.validate_protocol_template("experiments_templates", 7)
assert report.ok is False
errors = [i for i in report.issues if i.severity == "error"]
assert any(i.resource_key == "ethanol_absolute" for i in errors)
async def test_validate_ok_when_annotation_and_mapping_are_fine(setup, client, resolver_stub):
client.templates[7] = _annotated_template_client().templates[7]
report = await setup.validate_protocol_template("experiments_templates", 7)
assert report.ok is False # the broken json step must block
# fix the broken step, then expect a clean report
client.templates[7].steps[1].body = "Clean step without annotation"
report = await setup.validate_protocol_template("experiments_templates", 7)
assert report.ok is True
assert report.issues == []
async def test_validate_reports_annotation_error_with_step_id(setup, client):
client.templates[7] = _annotated_template_client().templates[7]
report = await setup.validate_protocol_template("experiments_templates", 7)
issues = [i for i in report.issues if i.step_id == 42]
assert issues and issues[0].severity == "error"
async def test_validate_entity_type_is_mirrored_in_report(setup, client):
client.templates[7] = _annotated_template_client().templates[7]
report = await setup.validate_protocol_template("experiments_templates", 7)
assert report.entity_type == "experiments_templates"
assert report.entity_id == 7
async def test_validate_experiment_steps_too(setup, client, resolver_stub):
"""validate works on live experiments, not only templates."""
resolver_stub.mappings.clear()
report = await setup.validate_protocol_template("experiments", 123)
assert report.entity_type == "experiments"
assert report.ok is False
async def test_validate_unknown_entity_fails_cleanly(setup):
with pytest.raises(Exception) as excinfo:
await setup.validate_protocol_template("experiments", 9999)
assert not isinstance(excinfo.value, NotImplementedError)