diff --git a/tests/protocol_fixtures/PROTOCOL_FIXTURES.md b/tests/protocol_fixtures/PROTOCOL_FIXTURES.md new file mode 100644 index 0000000..0b85c8b --- /dev/null +++ b/tests/protocol_fixtures/PROTOCOL_FIXTURES.md @@ -0,0 +1,12 @@ +# Protocol Conformance Fixtures (Phase 1) + +This directory contains the phase-1 protocol conformance baseline for Python. + +- `schema.phase1.json`: fixture document shape and supported case targets. +- `python_phase1_cases.json`: initial baseline cases (report-only mode metadata). + +Phase-1 scope: + +- No runtime behavior changes. +- Python-only execution through `tests/test_protocol_conformance.py`. +- Fixture format is language-neutral to enable future cross-binding runners. diff --git a/tests/protocol_fixtures/python_phase1_cases.json b/tests/protocol_fixtures/python_phase1_cases.json new file mode 100644 index 0000000..f8a7218 --- /dev/null +++ b/tests/protocol_fixtures/python_phase1_cases.json @@ -0,0 +1,105 @@ +{ + "schema_version": "1.0", + "runtime": "python", + "mode": "report_only", + "cases": [ + { + "id": "parse_params/simple_types_and_whitespace", + "target": "parse_params", + "description": "Semicolon-delimited params preserve string values and coerce literals.", + "input": { + "sparams": "delay=5; coeffs=[1,2,3]; label = hello world" + }, + "expected": { + "result": { + "delay": 5, + "coeffs": [ + 1, + 2, + 3 + ], + "label": "hello world" + } + } + }, + { + "id": "parse_params/embedded_equals_not_split", + "target": "parse_params", + "description": "Only the first '=' is used as key/value separator.", + "input": { + "sparams": "url=https://example.com?a=1&b=2" + }, + "expected": { + "result": { + "url": "https://example.com?a=1&b=2" + } + } + }, + { + "id": "initval/valid_list_sets_simtime", + "target": "initval", + "description": "initval sets simtime to first numeric entry and returns payload tail.", + "input": { + "initial_simtime": 0, + "simtime_val_str": "[12.5, \"a\", 3]" + }, + "expected": { + "result": [ + "a", + 3 + ], + "simtime_after": 12.5 + } + }, + { + "id": "initval/invalid_input_returns_empty_and_preserves_simtime", + "target": "initval", + "description": "Invalid non-list input returns [] and leaves simtime unchanged.", + "input": { + "initial_simtime": 7, + "simtime_val_str": "not_a_list" + }, + "expected": { + "result": [], + "simtime_after": 7 + } + }, + { + "id": "write_zmq/list_payload_prepends_timestamp_without_mutation", + "target": "write_zmq", + "description": "write() prepends simtime+delta for list payloads but does not mutate global simtime.", + "input": { + "initial_simtime": 10, + "delta": 2, + "name": "data", + "value": [ + 1.5, + 2.5 + ] + }, + "expected": { + "sent_payload": [ + 12, + 1.5, + 2.5 + ], + "simtime_after": 10 + } + }, + { + "id": "write_zmq/non_list_payload_forwarded_as_is", + "target": "write_zmq", + "description": "Non-list payloads are forwarded as-is and simtime remains unchanged.", + "input": { + "initial_simtime": 10, + "delta": 3, + "name": "status", + "value": "ok" + }, + "expected": { + "sent_payload": "ok", + "simtime_after": 10 + } + } + ] +} diff --git a/tests/protocol_fixtures/schema.phase1.json b/tests/protocol_fixtures/schema.phase1.json new file mode 100644 index 0000000..3b54e13 --- /dev/null +++ b/tests/protocol_fixtures/schema.phase1.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Concore Protocol Conformance Fixtures (Phase 1)", + "description": "Language-neutral fixture format. Phase 1 executes Python-only baseline checks.", + "type": "object", + "required": [ + "schema_version", + "runtime", + "mode", + "cases" + ], + "properties": { + "schema_version": { + "type": "string" + }, + "runtime": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "report_only" + ] + }, + "cases": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "target", + "input", + "expected" + ], + "properties": { + "id": { + "type": "string" + }, + "target": { + "type": "string", + "enum": [ + "parse_params", + "initval", + "write_zmq" + ] + }, + "description": { + "type": "string" + }, + "input": { + "type": "object" + }, + "expected": { + "type": "object" + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": false +} diff --git a/tests/test_protocol_conformance.py b/tests/test_protocol_conformance.py new file mode 100644 index 0000000..e831165 --- /dev/null +++ b/tests/test_protocol_conformance.py @@ -0,0 +1,115 @@ +import json +from pathlib import Path + +import pytest + +import concore + + +FIXTURE_DIR = Path(__file__).parent / "protocol_fixtures" +SCHEMA_PATH = FIXTURE_DIR / "schema.phase1.json" +CASES_PATH = FIXTURE_DIR / "python_phase1_cases.json" +SUPPORTED_TARGETS = {"parse_params", "initval", "write_zmq"} + + +def _load_json(path): + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def _validate_fixture_document_shape(doc): + required_top = {"schema_version", "runtime", "mode", "cases"} + missing = required_top - set(doc.keys()) + if missing: + raise AssertionError(f"Fixture document missing required top-level keys: {sorted(missing)}") + if doc["runtime"] != "python": + raise AssertionError(f"Phase-1 fixture runtime must be 'python', found: {doc['runtime']}") + if doc["mode"] != "report_only": + raise AssertionError(f"Phase-1 fixture mode must be 'report_only', found: {doc['mode']}") + if not isinstance(doc["cases"], list) or not doc["cases"]: + raise AssertionError("Fixture document must contain a non-empty 'cases' list") + + for idx, case in enumerate(doc["cases"]): + for key in ("id", "target", "input", "expected"): + if key not in case: + raise AssertionError(f"Case index {idx} missing required key '{key}'") + if case["target"] not in SUPPORTED_TARGETS: + raise AssertionError( + f"Case '{case['id']}' has unsupported target '{case['target']}'" + ) + + +def _run_parse_params_case(case): + result = concore.parse_params(case["input"]["sparams"]) + assert result == case["expected"]["result"] + + +def _run_initval_case(case): + old_simtime = concore.simtime + try: + concore.simtime = case["input"]["initial_simtime"] + result = concore.initval(case["input"]["simtime_val_str"]) + assert result == case["expected"]["result"] + assert concore.simtime == case["expected"]["simtime_after"] + finally: + concore.simtime = old_simtime + + +def _run_write_zmq_case(case): + class DummyPort: + def __init__(self): + self.sent_payload = None + + def send_json_with_retry(self, message): + self.sent_payload = message + + old_simtime = concore.simtime + port_name = f"fixture_{case['id'].replace('/', '_')}" + existing_port = concore.zmq_ports.get(port_name) + dummy_port = DummyPort() + + try: + concore.simtime = case["input"]["initial_simtime"] + concore.zmq_ports[port_name] = dummy_port + concore.write( + port_name, + case["input"]["name"], + case["input"]["value"], + delta=case["input"]["delta"], + ) + assert dummy_port.sent_payload == case["expected"]["sent_payload"] + assert concore.simtime == case["expected"]["simtime_after"] + finally: + concore.simtime = old_simtime + if existing_port is None: + concore.zmq_ports.pop(port_name, None) + else: + concore.zmq_ports[port_name] = existing_port + + +def _run_case(case): + if case["target"] == "parse_params": + _run_parse_params_case(case) + elif case["target"] == "initval": + _run_initval_case(case) + elif case["target"] == "write_zmq": + _run_write_zmq_case(case) + else: + raise AssertionError(f"Unsupported target: {case['target']}") + + +def _load_cases(): + doc = _load_json(CASES_PATH) + _validate_fixture_document_shape(doc) + return doc["cases"] + + +def test_phase1_schema_file_present_and_basic_shape(): + schema = _load_json(SCHEMA_PATH) + assert schema["title"] == "Concore Protocol Conformance Fixtures (Phase 1)" + assert "cases" in schema["properties"] + + +@pytest.mark.parametrize("case", _load_cases(), ids=lambda case: case["id"]) +def test_phase1_python_protocol_conformance(case): + _run_case(case)