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