Add complete test coverage for the mcilspy package: - T7: Create TestAssembly.dll fixture with known types/members - T1: Integration tests using real assembly (metadata reader + ILSpy wrapper) - T2: MCP tool tests with mocked wrapper for each @mcp.tool() - T3: Error path tests for regex, file not found, invalid assemblies - T4: Concurrency tests with asyncio.gather() for parallel operations - T5: Docstring coverage tests using AST introspection - T6: Timeout behavior tests for 5-minute subprocess timeout Test summary: - 147 tests passing - 14 skipped (ilspycmd-dependent integration tests) - 73% code coverage - All ruff linting checks pass
380 lines
13 KiB
Python
380 lines
13 KiB
Python
"""Integration tests using the custom TestAssembly.dll fixture.
|
|
|
|
These tests exercise the full stack including ilspycmd calls.
|
|
Tests are skipped if ilspycmd is not installed.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from mcilspy.ilspy_wrapper import ILSpyWrapper
|
|
from mcilspy.metadata_reader import MetadataReader
|
|
from mcilspy.models import (
|
|
DecompileRequest,
|
|
EntityType,
|
|
LanguageVersion,
|
|
ListTypesRequest,
|
|
)
|
|
|
|
|
|
class TestMetadataReaderWithTestAssembly:
|
|
"""Test MetadataReader against our custom test assembly."""
|
|
|
|
def test_get_assembly_metadata(self, test_assembly_path):
|
|
"""Test reading metadata from test assembly."""
|
|
with MetadataReader(test_assembly_path) as reader:
|
|
meta = reader.get_assembly_metadata()
|
|
|
|
assert meta.name == "TestAssemblyProject"
|
|
assert meta.type_count > 0
|
|
assert meta.method_count > 0
|
|
|
|
def test_list_methods_finds_known_methods(self, test_assembly_path):
|
|
"""Test that we can find methods we know exist."""
|
|
with MetadataReader(test_assembly_path) as reader:
|
|
methods = reader.list_methods()
|
|
|
|
method_names = [m.name for m in methods]
|
|
|
|
# Check for methods we defined in TestClass
|
|
assert "DoSomething" in method_names
|
|
assert "GetGreeting" in method_names
|
|
assert "Add" in method_names
|
|
|
|
def test_list_methods_with_type_filter(self, test_assembly_path):
|
|
"""Test filtering methods by type."""
|
|
with MetadataReader(test_assembly_path) as reader:
|
|
methods = reader.list_methods(type_filter="TestClass")
|
|
|
|
# All methods should be from types containing "TestClass"
|
|
for method in methods:
|
|
assert "TestClass" in method.declaring_type
|
|
|
|
def test_list_methods_with_namespace_filter(self, test_assembly_path):
|
|
"""Test filtering methods by namespace."""
|
|
with MetadataReader(test_assembly_path) as reader:
|
|
methods = reader.list_methods(namespace_filter="SubNamespace")
|
|
|
|
# Should only find methods from SubNamespace
|
|
for method in methods:
|
|
assert method.namespace is not None
|
|
assert "SubNamespace" in method.namespace
|
|
|
|
def test_list_methods_public_only(self, test_assembly_path):
|
|
"""Test filtering for public methods only."""
|
|
with MetadataReader(test_assembly_path) as reader:
|
|
public_methods = reader.list_methods(public_only=True)
|
|
all_methods = reader.list_methods(public_only=False)
|
|
|
|
# Should have fewer public methods than total
|
|
assert len(public_methods) <= len(all_methods)
|
|
# All returned methods should be public
|
|
for method in public_methods:
|
|
assert method.is_public
|
|
|
|
def test_list_fields_finds_known_fields(self, test_assembly_path):
|
|
"""Test that we can find fields we defined."""
|
|
with MetadataReader(test_assembly_path) as reader:
|
|
fields = reader.list_fields()
|
|
|
|
field_names = [f.name for f in fields]
|
|
|
|
# Check for constants and fields we defined
|
|
assert "API_KEY" in field_names
|
|
assert "BASE_URL" in field_names
|
|
assert "MAX_RETRIES" in field_names
|
|
|
|
def test_list_fields_constants_only(self, test_assembly_path):
|
|
"""Test filtering for constant fields only."""
|
|
with MetadataReader(test_assembly_path) as reader:
|
|
constants = reader.list_fields(constants_only=True)
|
|
|
|
# All returned fields should be literals
|
|
for field in constants:
|
|
assert field.is_literal
|
|
|
|
const_names = [f.name for f in constants]
|
|
assert "API_KEY" in const_names
|
|
assert "MAX_RETRIES" in const_names
|
|
|
|
def test_list_properties_finds_known_properties(self, test_assembly_path):
|
|
"""Test that we can find properties we defined."""
|
|
with MetadataReader(test_assembly_path) as reader:
|
|
properties = reader.list_properties()
|
|
|
|
prop_names = [p.name for p in properties]
|
|
|
|
# Check for properties we defined
|
|
assert "Name" in prop_names
|
|
assert "Age" in prop_names
|
|
assert "IsActive" in prop_names
|
|
assert "ServiceName" in prop_names
|
|
|
|
def test_list_events_finds_known_events(self, test_assembly_path):
|
|
"""Test that we can find events we defined."""
|
|
with MetadataReader(test_assembly_path) as reader:
|
|
events = reader.list_events()
|
|
|
|
event_names = [e.name for e in events]
|
|
|
|
# Check for events we defined
|
|
assert "OnChange" in event_names
|
|
assert "OnMessage" in event_names
|
|
|
|
def test_list_resources_empty_for_test_assembly(self, test_assembly_path):
|
|
"""Test that test assembly has no embedded resources."""
|
|
with MetadataReader(test_assembly_path) as reader:
|
|
resources = reader.list_resources()
|
|
|
|
# Our simple test assembly has no resources
|
|
assert isinstance(resources, list)
|
|
|
|
|
|
class TestILSpyWrapperWithTestAssembly:
|
|
"""Integration tests for ILSpyWrapper using real ilspycmd calls."""
|
|
|
|
@pytest.fixture
|
|
def wrapper(self, skip_without_ilspycmd):
|
|
"""Get wrapper instance, skipping if ilspycmd not available."""
|
|
return ILSpyWrapper()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_types_finds_classes(self, wrapper, test_assembly_path):
|
|
"""Test listing classes from test assembly."""
|
|
request = ListTypesRequest(
|
|
assembly_path=test_assembly_path,
|
|
entity_types=[EntityType.CLASS],
|
|
)
|
|
response = await wrapper.list_types(request)
|
|
|
|
assert response.success
|
|
assert response.total_count > 0
|
|
|
|
type_names = [t.name for t in response.types]
|
|
assert "TestClass" in type_names
|
|
assert "TestServiceImpl" in type_names
|
|
assert "OuterClass" in type_names
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_types_finds_interfaces(self, wrapper, test_assembly_path):
|
|
"""Test listing interfaces from test assembly."""
|
|
request = ListTypesRequest(
|
|
assembly_path=test_assembly_path,
|
|
entity_types=[EntityType.INTERFACE],
|
|
)
|
|
response = await wrapper.list_types(request)
|
|
|
|
assert response.success
|
|
|
|
type_names = [t.name for t in response.types]
|
|
assert "ITestService" in type_names
|
|
assert "IConfigurable" in type_names
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_types_finds_structs(self, wrapper, test_assembly_path):
|
|
"""Test listing structs from test assembly."""
|
|
request = ListTypesRequest(
|
|
assembly_path=test_assembly_path,
|
|
entity_types=[EntityType.STRUCT],
|
|
)
|
|
response = await wrapper.list_types(request)
|
|
|
|
assert response.success
|
|
|
|
type_names = [t.name for t in response.types]
|
|
assert "TestStruct" in type_names
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_types_finds_enums(self, wrapper, test_assembly_path):
|
|
"""Test listing enums from test assembly."""
|
|
request = ListTypesRequest(
|
|
assembly_path=test_assembly_path,
|
|
entity_types=[EntityType.ENUM],
|
|
)
|
|
response = await wrapper.list_types(request)
|
|
|
|
assert response.success
|
|
|
|
type_names = [t.name for t in response.types]
|
|
assert "TestEnum" in type_names
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_types_finds_delegates(self, wrapper, test_assembly_path):
|
|
"""Test listing delegates from test assembly."""
|
|
request = ListTypesRequest(
|
|
assembly_path=test_assembly_path,
|
|
entity_types=[EntityType.DELEGATE],
|
|
)
|
|
response = await wrapper.list_types(request)
|
|
|
|
assert response.success
|
|
|
|
type_names = [t.name for t in response.types]
|
|
assert "TestDelegate" in type_names
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_decompile_specific_type(self, wrapper, test_assembly_path):
|
|
"""Test decompiling a specific type."""
|
|
request = DecompileRequest(
|
|
assembly_path=test_assembly_path,
|
|
type_name="TestNamespace.TestClass",
|
|
language_version=LanguageVersion.LATEST,
|
|
)
|
|
response = await wrapper.decompile(request)
|
|
|
|
assert response.success
|
|
assert response.source_code is not None
|
|
|
|
# Check that decompiled code contains expected elements
|
|
source = response.source_code
|
|
assert "class TestClass" in source
|
|
assert "DoSomething" in source
|
|
assert "GetGreeting" in source
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_decompile_entire_assembly(self, wrapper, test_assembly_path):
|
|
"""Test decompiling the entire assembly."""
|
|
request = DecompileRequest(
|
|
assembly_path=test_assembly_path,
|
|
language_version=LanguageVersion.LATEST,
|
|
)
|
|
response = await wrapper.decompile(request)
|
|
|
|
assert response.success
|
|
assert response.source_code is not None
|
|
|
|
# Check that all types are present
|
|
source = response.source_code
|
|
assert "TestClass" in source
|
|
assert "ITestService" in source
|
|
assert "TestStruct" in source
|
|
assert "TestEnum" in source
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_decompile_to_il(self, wrapper, test_assembly_path):
|
|
"""Test decompiling to IL code."""
|
|
request = DecompileRequest(
|
|
assembly_path=test_assembly_path,
|
|
type_name="TestNamespace.TestClass",
|
|
show_il_code=True,
|
|
)
|
|
response = await wrapper.decompile(request)
|
|
|
|
assert response.success
|
|
assert response.source_code is not None
|
|
|
|
# IL code should contain IL-specific keywords
|
|
source = response.source_code
|
|
# IL typically shows .method, .field, etc.
|
|
assert ".class" in source or "IL_" in source
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_decompile_to_output_dir(self, wrapper, test_assembly_path, temp_output_dir):
|
|
"""Test decompiling to an output directory."""
|
|
request = DecompileRequest(
|
|
assembly_path=test_assembly_path,
|
|
output_dir=temp_output_dir,
|
|
)
|
|
response = await wrapper.decompile(request)
|
|
|
|
assert response.success
|
|
assert response.output_path is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_decompile_with_project_structure(
|
|
self, wrapper, test_assembly_path, temp_output_dir
|
|
):
|
|
"""Test decompiling with project structure."""
|
|
request = DecompileRequest(
|
|
assembly_path=test_assembly_path,
|
|
output_dir=temp_output_dir,
|
|
create_project=True,
|
|
)
|
|
response = await wrapper.decompile(request)
|
|
|
|
assert response.success
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_decompile_nonexistent_type(self, wrapper, test_assembly_path):
|
|
"""Test decompiling a type that doesn't exist."""
|
|
request = DecompileRequest(
|
|
assembly_path=test_assembly_path,
|
|
type_name="NonExistent.FakeClass",
|
|
)
|
|
response = await wrapper.decompile(request)
|
|
|
|
# Should still succeed but with empty or no matching output
|
|
# The actual behavior depends on ilspycmd version
|
|
assert response is not None
|
|
|
|
|
|
class TestIntegrationEndToEnd:
|
|
"""End-to-end integration tests covering complete workflows."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discover_and_decompile_workflow(
|
|
self, skip_without_ilspycmd, test_assembly_path
|
|
):
|
|
"""Test the typical workflow: list types, then decompile specific one."""
|
|
wrapper = ILSpyWrapper()
|
|
|
|
# Step 1: List all types
|
|
list_request = ListTypesRequest(
|
|
assembly_path=test_assembly_path,
|
|
entity_types=[EntityType.CLASS],
|
|
)
|
|
list_response = await wrapper.list_types(list_request)
|
|
|
|
assert list_response.success
|
|
assert len(list_response.types) > 0
|
|
|
|
# Step 2: Find TestServiceImpl
|
|
service_type = None
|
|
for t in list_response.types:
|
|
if t.name == "TestServiceImpl":
|
|
service_type = t
|
|
break
|
|
|
|
assert service_type is not None
|
|
|
|
# Step 3: Decompile it
|
|
decompile_request = DecompileRequest(
|
|
assembly_path=test_assembly_path,
|
|
type_name=service_type.full_name,
|
|
)
|
|
decompile_response = await wrapper.decompile(decompile_request)
|
|
|
|
assert decompile_response.success
|
|
assert decompile_response.source_code is not None
|
|
assert "TestServiceImpl" in decompile_response.source_code
|
|
assert "ITestService" in decompile_response.source_code
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_metadata_and_decompile_combined(
|
|
self, skip_without_ilspycmd, test_assembly_path
|
|
):
|
|
"""Test using metadata reader and ILSpy wrapper together."""
|
|
# Use metadata reader for quick discovery
|
|
with MetadataReader(test_assembly_path) as reader:
|
|
methods = reader.list_methods(type_filter="TestClass")
|
|
add_method = None
|
|
for m in methods:
|
|
if m.name == "Add":
|
|
add_method = m
|
|
break
|
|
|
|
assert add_method is not None
|
|
assert add_method.is_static
|
|
|
|
# Use ILSpy for decompilation
|
|
wrapper = ILSpyWrapper()
|
|
request = DecompileRequest(
|
|
assembly_path=test_assembly_path,
|
|
type_name="TestNamespace.TestClass",
|
|
)
|
|
response = await wrapper.decompile(request)
|
|
|
|
assert response.success
|
|
# Verify the static method is in the output
|
|
assert "static" in response.source_code
|
|
assert "Add" in response.source_code
|