Initial version
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user