160 lines
5.4 KiB
Python
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)
|