"""
Tests for JSON-RPC 2.0 params field handling in LSP protocol.
These tests verify the correct handling of the params field in LSP requests and notifications,
specifically ensuring:
- Void-type methods (shutdown, exit) omit params field entirely
- Methods with explicit params include them unchanged
- Methods with None params receive params: {} for Delphi/FPC compatibility
Reference: JSON-RPC 2.0 spec - params field is optional but must be object/array when present.
"""
from typing import Any
import pytest
from solidlsp.lsp_protocol_handler.server import make_notification, make_request
# =============================================================================
# Shared Assertion Helpers (DRY extraction per AI Panel recommendation)
# =============================================================================
def assert_jsonrpc_structure(
result: dict[str, Any],
expected_method: str,
expected_keys: set[str],
*,
expected_id: Any | None = None,
) -> None:
"""Verify JSON-RPC 2.0 structural requirements with 5-point error messages.
Args:
result: The dict returned by make_request/make_notification
expected_method: The method name that should be in the result
expected_keys: Exact set of keys that should be present
expected_id: If provided, verify the id field matches (for requests)
"""
# Verify jsonrpc field
assert "jsonrpc" in result, (
f"STRUCTURE ERROR: Missing required 'jsonrpc' field.\n"
f"Expected: jsonrpc='2.0'\n"
f"Actual keys: {list(result.keys())}\n"
f"GUIDANCE: All JSON-RPC 2.0 messages must include jsonrpc field."
)
assert result["jsonrpc"] == "2.0", (
f"STRUCTURE ERROR: Invalid jsonrpc version.\n"
f"Expected: '2.0'\n"
f"Actual: {result['jsonrpc']!r}\n"
f"GUIDANCE: JSON-RPC 2.0 requires jsonrpc='2.0' exactly."
)
# Verify method field
assert "method" in result, (
f"STRUCTURE ERROR: Missing required 'method' field.\n"
f"Expected: method='{expected_method}'\n"
f"Actual keys: {list(result.keys())}\n"
f"GUIDANCE: All requests/notifications must include method field."
)
assert result["method"] == expected_method, (
f"STRUCTURE ERROR: Method mismatch.\n"
f"Expected: '{expected_method}'\n"
f"Actual: {result['method']!r}\n"
f"GUIDANCE: Method field must match the requested method name."
)
# Verify id field if expected (requests only)
if expected_id is not None:
assert "id" in result, (
f"STRUCTURE ERROR: Missing required 'id' field for request.\n"
f"Expected: id={expected_id!r}\n"
f"Actual keys: {list(result.keys())}\n"
f"GUIDANCE: JSON-RPC 2.0 requests must include id field."
)
assert result["id"] == expected_id, (
f"STRUCTURE ERROR: Request ID mismatch.\n"
f"Expected: {expected_id!r}\n"
f"Actual: {result['id']!r}\n"
f"GUIDANCE: Request ID must be preserved exactly as provided."
)
# Verify exact key set
actual_keys = set(result.keys())
if actual_keys != expected_keys:
extra = sorted(actual_keys - expected_keys)
missing = sorted(expected_keys - actual_keys)
pytest.fail(
f"STRUCTURE ERROR: Key set mismatch for method '{expected_method}'.\n"
f"Expected keys: {sorted(expected_keys)}\n"
f"Actual keys: {sorted(actual_keys)}\n"
f"Extra keys: {extra}\n"
f"Missing keys: {missing}\n"
f"GUIDANCE: Verify key construction logic for Void-type vs normal methods."
)
def assert_params_omitted(result: dict[str, Any], method: str, req_id: str, input_params: Any = None) -> None:
"""Assert that params field is NOT present (for Void-type methods).
Args:
result: The dict returned by make_request/make_notification
method: Method name for error message context
req_id: Requirement ID (e.g., 'REQ-1', 'REQ-AI-PANEL-GAP')
input_params: If provided, shows what params were passed (for explicit params tests)
"""
if "params" in result:
input_note = f"\nInput params: {input_params}" if input_params is not None else ""
pytest.fail(
f"{req_id} VIOLATED: {method} method MUST omit params field entirely.{input_note}\n"
f"Expected: No 'params' key in result\n"
f"Actual: params={result.get('params')!r}\n"
f"Actual keys: {list(result.keys())}\n"
f"REASON: HLS/rust-analyzer Void types reject any params field (even empty object).\n"
f"GUIDANCE: Void-type constraint takes precedence - implementation must omit params entirely."
)
def assert_params_equal(result: dict[str, Any], expected_params: Any, req_id: str) -> None:
"""Assert that params field equals expected value.
Args:
result: The dict returned by make_request/make_notification
expected_params: The exact params value expected
req_id: Requirement ID for error message context
"""
if "params" not in result:
pytest.fail(
f"{req_id} VIOLATED: params field missing.\n"
f"Expected: params={expected_params!r}\n"
f"Actual keys: {list(result.keys())}\n"
f"GUIDANCE: Non-Void methods must include params field."
)
if result["params"] != expected_params:
pytest.fail(
f"{req_id} VIOLATED: params value mismatch.\n"
f"Expected: {expected_params!r}\n"
f"Actual: {result['params']!r}\n"
f"GUIDANCE: Params must be included exactly as provided (or {{}} for None)."
)
class TestMakeNotificationParamsHandling:
"""Test make_notification() params field handling per JSON-RPC 2.0 spec."""
def test_shutdown_method_omits_params_entirely(self) -> None:
"""REQ-1: Void-type method 'shutdown' MUST omit params field entirely."""
result = make_notification("shutdown", None)
assert_jsonrpc_structure(result, "shutdown", {"jsonrpc", "method"})
assert_params_omitted(result, "shutdown", "REQ-1")
def test_exit_method_omits_params_entirely(self) -> None:
"""REQ-1: Void-type method 'exit' MUST omit params field entirely."""
result = make_notification("exit", None)
assert_jsonrpc_structure(result, "exit", {"jsonrpc", "method"})
assert_params_omitted(result, "exit", "REQ-1")
def test_notification_with_explicit_params_dict(self) -> None:
"""REQ-2: Methods with explicit params MUST include them unchanged."""
test_params = {"uri": "file:///test.py", "languageId": "python"}
result = make_notification("textDocument/didOpen", test_params)
assert_jsonrpc_structure(result, "textDocument/didOpen", {"jsonrpc", "method", "params"})
assert_params_equal(result, test_params, "REQ-2")
def test_notification_with_explicit_params_list(self) -> None:
"""REQ-2: Methods with explicit params (list) MUST include them unchanged."""
test_params = ["arg1", "arg2", "arg3"]
result = make_notification("custom/method", test_params)
assert_jsonrpc_structure(result, "custom/method", {"jsonrpc", "method", "params"})
assert_params_equal(result, test_params, "REQ-2")
def test_notification_with_none_params_sends_empty_dict(self) -> None:
"""REQ-3: Methods with None params MUST send params: {} (Delphi/FPC compat)."""
result = make_notification("textDocument/didChange", None)
assert_jsonrpc_structure(result, "textDocument/didChange", {"jsonrpc", "method", "params"})
assert_params_equal(result, {}, "REQ-3")
def test_notification_with_empty_dict_params(self) -> None:
"""REQ-2: Explicit empty dict params MUST be included unchanged."""
result = make_notification("custom/notify", {})
assert_jsonrpc_structure(result, "custom/notify", {"jsonrpc", "method", "params"})
assert_params_equal(result, {}, "REQ-2")
class TestMakeRequestParamsHandling:
"""Test make_request() params field handling per JSON-RPC 2.0 spec."""
def test_shutdown_request_omits_params_entirely(self) -> None:
"""REQ-1: Void-type method 'shutdown' MUST omit params field entirely (requests)."""
result = make_request("shutdown", request_id=1, params=None)
assert_jsonrpc_structure(result, "shutdown", {"jsonrpc", "method", "id"}, expected_id=1)
assert_params_omitted(result, "shutdown", "REQ-1")
def test_request_with_explicit_params_dict(self) -> None:
"""REQ-2: Requests with explicit params MUST include them unchanged."""
test_params = {"textDocument": {"uri": "file:///test.py"}, "position": {"line": 10, "character": 5}}
result = make_request("textDocument/hover", request_id=42, params=test_params)
assert_jsonrpc_structure(result, "textDocument/hover", {"jsonrpc", "method", "id", "params"}, expected_id=42)
assert_params_equal(result, test_params, "REQ-2")
def test_request_with_none_params_sends_empty_dict(self) -> None:
"""REQ-3: Requests with None params MUST send params: {} (Delphi/FPC compat)."""
result = make_request("workspace/configuration", request_id=100, params=None)
assert_jsonrpc_structure(result, "workspace/configuration", {"jsonrpc", "method", "id", "params"}, expected_id=100)
assert_params_equal(result, {}, "REQ-3")
def test_request_id_preservation(self) -> None:
"""Verify request_id is correctly included in result (string ID)."""
test_id = "unique-request-123"
result = make_request("custom/request", request_id=test_id, params={"key": "value"})
assert_jsonrpc_structure(result, "custom/request", {"jsonrpc", "method", "id", "params"}, expected_id=test_id)
def test_request_with_explicit_params_list(self) -> None:
"""REQ-2: Requests with explicit params (list) MUST include them unchanged."""
test_params = [1, 2, 3]
result = make_request("custom/sum", request_id=99, params=test_params)
assert_jsonrpc_structure(result, "custom/sum", {"jsonrpc", "method", "id", "params"}, expected_id=99)
assert_params_equal(result, test_params, "REQ-2")
class TestVoidMethodsExhaustive:
"""Test all methods that should be treated as Void-type (no params)."""
def test_shutdown_request_ignores_explicit_params_dict(self) -> None:
"""REQ-AI-PANEL-GAP: shutdown MUST omit params even when caller explicitly provides params."""
explicit_params = {"key": "value", "another": "param"}
result = make_request("shutdown", request_id=1, params=explicit_params)
assert_jsonrpc_structure(result, "shutdown", {"jsonrpc", "method", "id"}, expected_id=1)
assert_params_omitted(result, "shutdown", "REQ-AI-PANEL-GAP", input_params=explicit_params)
def test_exit_notification_ignores_explicit_params(self) -> None:
"""REQ-AI-PANEL-GAP: exit MUST omit params even when caller explicitly provides params."""
explicit_params = {"unexpected": "params"}
result = make_notification("exit", explicit_params)
assert_jsonrpc_structure(result, "exit", {"jsonrpc", "method"})
assert_params_omitted(result, "exit", "REQ-AI-PANEL-GAP", input_params=explicit_params)
def test_only_shutdown_and_exit_are_void_methods(self) -> None:
"""REQ-BOUNDARY: Verify EXACTLY shutdown/exit are Void-type - no more, no less."""
# Positive verification: shutdown and exit MUST omit params
shutdown_notif = make_notification("shutdown", None)
exit_notif = make_notification("exit", None)
shutdown_req = make_request("shutdown", 1, None)
assert "params" not in shutdown_notif, "shutdown notification should omit params"
assert "params" not in exit_notif, "exit notification should omit params"
assert "params" not in shutdown_req, "shutdown request should omit params"
# Negative verification: other methods MUST include params (even when None -> {})
non_void_methods = [
"initialize",
"initialized",
"textDocument/didOpen",
"textDocument/didChange",
"textDocument/didClose",
"workspace/didChangeConfiguration",
"workspace/didChangeWatchedFiles",
]
for method in non_void_methods:
result_notif = make_notification(method, None)
result_req = make_request(method, 1, None)
if "params" not in result_notif:
pytest.fail(
f"BOUNDARY VIOLATION: '{method}' notification treated as Void-type.\n"
f"Expected: params field present (should be {{}})\n"
f"Actual keys: {list(result_notif.keys())}\n"
f"GUIDANCE: Only 'shutdown' and 'exit' should omit params field."
)
assert_params_equal(result_notif, {}, f"REQ-3 ({method} notification)")
if "params" not in result_req:
pytest.fail(
f"BOUNDARY VIOLATION: '{method}' request treated as Void-type.\n"
f"Expected: params field present (should be {{}})\n"
f"Actual keys: {list(result_req.keys())}\n"
f"GUIDANCE: Only 'shutdown' and 'exit' should omit params field."
)
assert_params_equal(result_req, {}, f"REQ-3 ({method} request)")