Files
Labvoice/tests/test_annotate.py
T
2026-08-30 20:44:00 +02:00

160 lines
5.4 KiB
Python

"""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)