#!/usr/bin/env python3 """ Pytest suite for DICOM MCP Server Run with: pytest test_dicom_mcp.py -v """ import json import sys from pathlib import Path import numpy as np import pydicom import pytest from PIL import Image from pydicom.dataset import FileMetaDataset from pydicom.sequence import Sequence as DicomSequence from pydicom.uid import ( ExplicitVRLittleEndian, MRImageStorage, SegmentationStorage, UID, generate_uid, ) # --------------------------------------------------------------------------- # Shared DICOM factory helpers # --------------------------------------------------------------------------- def _make_base_dataset( *, sop_class=MRImageStorage, sop_instance_uid=None, study_instance_uid=None, series_instance_uid=None, rows=4, columns=4, bits_stored=12, pixel_data=None, **attrs, ): """Create a minimal valid DICOM Dataset with correct typing. Handles boilerplate: FileMetaDataset, transfer syntax, pixel encoding, and UID generation. Pass additional DICOM attributes as keyword arguments (e.g. ``Modality="MR"``, ``Manufacturer="SIEMENS"``). Returns an in-memory Dataset (not saved to disk). """ ds = pydicom.Dataset() ds.file_meta = FileMetaDataset() ds.file_meta.TransferSyntaxUID = ExplicitVRLittleEndian sop_uid = UID(sop_instance_uid) if sop_instance_uid else generate_uid() ds.file_meta.MediaStorageSOPClassUID = sop_class ds.file_meta.MediaStorageSOPInstanceUID = sop_uid ds.SOPClassUID = sop_class ds.SOPInstanceUID = sop_uid ds.StudyInstanceUID = ( UID(study_instance_uid) if study_instance_uid else generate_uid() ) ds.SeriesInstanceUID = ( UID(series_instance_uid) if series_instance_uid else generate_uid() ) high_bit = bits_stored - 1 ds.Rows = rows ds.Columns = columns ds.BitsAllocated = 16 ds.BitsStored = bits_stored ds.HighBit = high_bit ds.PixelRepresentation = 0 ds.SamplesPerPixel = 1 ds.PhotometricInterpretation = "MONOCHROME2" if pixel_data is not None: ds.PixelData = pixel_data.astype(np.uint16).tobytes() else: ds.PixelData = np.zeros((rows, columns), dtype=np.uint16).tobytes() for attr, value in attrs.items(): setattr(ds, attr, value) return ds def _save_dicom(ds, file_path): """Save a Dataset to disk with DICOM file format enforcement.""" Path(file_path).parent.mkdir(parents=True, exist_ok=True) ds.save_as(str(file_path), enforce_file_format=True) return file_path def _make_pii_dataset(): """Create a Dataset with known patient PII fields (shared by PII test classes).""" return _make_base_dataset( Modality="MR", Manufacturer="SIEMENS", SeriesDescription="T1_VIBE_Dixon", SeriesNumber=1, PatientName="Doe^Jane", PatientID="PII-TEST-001", PatientBirthDate="19850315", PatientSex="F", ) # --------------------------------------------------------------------------- # Module-scoped fixtures # --------------------------------------------------------------------------- @pytest.fixture(scope="module") def dicom_mcp_module(): """Import and return the dicom_mcp module.""" # Add the current directory to the path if needed current_dir = Path(__file__).parent if str(current_dir) not in sys.path: sys.path.insert(0, str(current_dir)) import dicom_mcp return dicom_mcp # --------------------------------------------------------------------------- # Test classes # --------------------------------------------------------------------------- class TestImports: """Test that all required modules can be imported.""" def test_mcp_import(self): """Test that mcp can be imported.""" import mcp # local: validates dependency is installed assert mcp is not None def test_pydicom_import(self): """Test that pydicom can be imported.""" assert pydicom is not None def test_pydantic_import(self): """Test that pydantic can be imported.""" import pydantic # local: validates dependency is installed assert pydantic is not None def test_fastmcp_import(self): """Test that FastMCP can be imported.""" from mcp.server.fastmcp import FastMCP # local: validates submodule path assert FastMCP is not None class TestServerModule: """Test the DICOM MCP server module.""" def test_module_import(self, dicom_mcp_module): """Test that dicom_mcp module can be imported.""" assert dicom_mcp_module is not None def test_mcp_instance_exists(self, dicom_mcp_module): """Test that mcp instance exists in module.""" assert hasattr(dicom_mcp_module, "mcp") def test_mcp_instance_name(self, dicom_mcp_module): """Test that MCP instance has correct name.""" assert dicom_mcp_module.mcp.name == "dicom_mcp" class TestToolRegistration: """Test that all tools are properly registered.""" EXPECTED_TOOLS = [ "dicom_list_files", "dicom_get_metadata", "dicom_compare_headers", "dicom_find_dixon_series", "dicom_validate_sequence", "dicom_analyze_series", "dicom_summarize_directory", "dicom_query", "dicom_search", "dicom_query_philips_private", "dicom_read_pixels", "dicom_compute_snr", "dicom_render_image", "dicom_dump_tree", "dicom_compare_uids", "dicom_verify_segmentations", "dicom_analyze_ti", ] def test_all_tools_exist(self, dicom_mcp_module): """Test that all expected tool functions exist.""" for tool_name in self.EXPECTED_TOOLS: assert hasattr( dicom_mcp_module, tool_name ), f"Tool {tool_name} not found in module" def test_tool_count(self, dicom_mcp_module): """Test that correct number of tools are registered.""" registered_count = sum( 1 for tool in self.EXPECTED_TOOLS if hasattr(dicom_mcp_module, tool) ) assert registered_count == len( self.EXPECTED_TOOLS ), f"Expected {len(self.EXPECTED_TOOLS)} tools, found {registered_count}" @pytest.mark.parametrize("tool_name", EXPECTED_TOOLS) def test_tool_is_callable(self, dicom_mcp_module, tool_name): """Test that each tool is callable.""" tool_func = getattr(dicom_mcp_module, tool_name) assert callable(tool_func), f"{tool_name} is not callable" class TestHelperFunctions: """Test helper functions.""" def test_safe_get_tag_exists(self, dicom_mcp_module): """Test that _safe_get_tag helper exists.""" assert hasattr(dicom_mcp_module, "_safe_get_tag") def test_identify_sequence_type_exists(self, dicom_mcp_module): """Test that _identify_sequence_type helper exists.""" assert hasattr(dicom_mcp_module, "_identify_sequence_type") def test_find_dicom_files_exists(self, dicom_mcp_module): """Test that _find_dicom_files helper exists.""" assert hasattr(dicom_mcp_module, "_find_dicom_files") def test_format_markdown_table_exists(self, dicom_mcp_module): """Test that _format_markdown_table helper exists.""" assert hasattr(dicom_mcp_module, "_format_markdown_table") def test_resolve_tag_exists(self, dicom_mcp_module): """Test that _resolve_tag helper exists.""" assert hasattr(dicom_mcp_module, "_resolve_tag") def test_parse_filter_exists(self, dicom_mcp_module): """Test that _parse_filter helper exists.""" assert hasattr(dicom_mcp_module, "_parse_filter") def test_apply_filter_exists(self, dicom_mcp_module): """Test that _apply_filter helper exists.""" assert hasattr(dicom_mcp_module, "_apply_filter") def test_get_pixel_array_exists(self, dicom_mcp_module): """Test that _get_pixel_array helper exists.""" assert hasattr(dicom_mcp_module, "_get_pixel_array") def test_extract_roi_exists(self, dicom_mcp_module): """Test that _extract_roi helper exists.""" assert hasattr(dicom_mcp_module, "_extract_roi") def test_compute_stats_exists(self, dicom_mcp_module): """Test that _compute_stats helper exists.""" assert hasattr(dicom_mcp_module, "_compute_stats") def test_apply_windowing_exists(self, dicom_mcp_module): """Test that _apply_windowing helper exists.""" assert hasattr(dicom_mcp_module, "_apply_windowing") def test_resolve_philips_private_tag_exists(self, dicom_mcp_module): """Test that _resolve_philips_private_tag helper exists.""" assert hasattr(dicom_mcp_module, "_resolve_philips_private_tag") def test_list_philips_private_creators_exists(self, dicom_mcp_module): """Test that _list_philips_private_creators helper exists.""" assert hasattr(dicom_mcp_module, "_list_philips_private_creators") class TestEnums: """Test enum definitions.""" def test_response_format_enum_exists(self, dicom_mcp_module): """Test that ResponseFormat enum exists.""" assert hasattr(dicom_mcp_module, "ResponseFormat") def test_response_format_values(self, dicom_mcp_module): """Test ResponseFormat has expected values.""" ResponseFormat = dicom_mcp_module.ResponseFormat assert hasattr(ResponseFormat, "MARKDOWN") assert hasattr(ResponseFormat, "JSON") def test_sequence_type_enum_exists(self, dicom_mcp_module): """Test that SequenceType enum exists.""" assert hasattr(dicom_mcp_module, "SequenceType") def test_sequence_type_values(self, dicom_mcp_module): """Test SequenceType has expected values including liver analysis types.""" SequenceType = dicom_mcp_module.SequenceType assert hasattr(SequenceType, "DIXON") assert hasattr(SequenceType, "T1_MAPPING") assert hasattr(SequenceType, "MULTI_ECHO_GRE") assert hasattr(SequenceType, "SPIN_ECHO_IR") assert hasattr(SequenceType, "T1") assert hasattr(SequenceType, "T2") assert hasattr(SequenceType, "FLAIR") assert hasattr(SequenceType, "DWI") assert hasattr(SequenceType, "LOCALIZER") assert hasattr(SequenceType, "UNKNOWN") class TestConstants: """Test constant definitions.""" def test_common_tags_exists(self, dicom_mcp_module): """Test that COMMON_TAGS constant exists.""" assert hasattr(dicom_mcp_module, "COMMON_TAGS") def test_common_tags_structure(self, dicom_mcp_module): """Test that COMMON_TAGS has expected structure.""" COMMON_TAGS = dicom_mcp_module.COMMON_TAGS assert isinstance(COMMON_TAGS, dict) expected_groups = [ "patient_info", "study_info", "series_info", "image_info", "acquisition", "manufacturer", ] for group in expected_groups: assert group in COMMON_TAGS, f"Missing tag group: {group}" assert isinstance(COMMON_TAGS[group], list) class TestNewImports: """Test that new pixel tool dependencies can be imported.""" def test_numpy_import(self): """Test that numpy can be imported.""" assert np is not None def test_pillow_import(self): """Test that Pillow can be imported.""" from PIL import ImageDraw, ImageFont assert Image is not None assert ImageDraw is not None assert ImageFont is not None class TestExtractRoi: """Test _extract_roi with synthetic pixel arrays.""" def test_valid_roi_centre(self, dicom_mcp_module): """Test extracting a valid central ROI.""" pixels = np.arange(100).reshape(10, 10).astype(np.float64) roi = dicom_mcp_module._extract_roi(pixels, [2, 3, 4, 5]) assert roi.shape == (5, 4) # Top-left corner of ROI: row=3, col=2 → value = 3*10 + 2 = 32 assert roi[0, 0] == 32.0 def test_valid_roi_full_image(self, dicom_mcp_module): """Test ROI covering the entire image.""" pixels = np.ones((8, 12), dtype=np.float64) roi = dicom_mcp_module._extract_roi(pixels, [0, 0, 12, 8]) assert roi.shape == (8, 12) def test_roi_out_of_bounds(self, dicom_mcp_module): """Test that out-of-bounds ROI raises ValueError.""" pixels = np.zeros((10, 10), dtype=np.float64) with pytest.raises(ValueError, match="exceeds image bounds"): dicom_mcp_module._extract_roi(pixels, [8, 8, 5, 5]) def test_roi_zero_width(self, dicom_mcp_module): """Test that zero-width ROI raises ValueError.""" pixels = np.zeros((10, 10), dtype=np.float64) with pytest.raises(ValueError, match="must be positive"): dicom_mcp_module._extract_roi(pixels, [0, 0, 0, 5]) def test_roi_negative_height(self, dicom_mcp_module): """Test that negative-dimension ROI raises ValueError.""" pixels = np.zeros((10, 10), dtype=np.float64) with pytest.raises(ValueError, match="must be positive"): dicom_mcp_module._extract_roi(pixels, [0, 0, 5, -3]) def test_roi_wrong_length(self, dicom_mcp_module): """Test that ROI with wrong number of elements raises ValueError.""" pixels = np.zeros((10, 10), dtype=np.float64) with pytest.raises(ValueError, match="must be \\[x, y, width, height\\]"): dicom_mcp_module._extract_roi(pixels, [0, 0, 5]) class TestComputeStats: """Test _compute_stats with known distributions.""" def test_uniform_array(self, dicom_mcp_module): """Test stats on a constant array.""" pixels = np.full((10, 10), 42.0) stats = dicom_mcp_module._compute_stats(pixels) assert stats["min"] == 42.0 assert stats["max"] == 42.0 assert stats["mean"] == 42.0 assert stats["std"] == 0.0 assert stats["median"] == 42.0 assert stats["pixel_count"] == 100 def test_known_sequence(self, dicom_mcp_module): """Test stats on a predictable sequence.""" # 0 through 99 inclusive pixels = np.arange(100).reshape(10, 10).astype(np.float64) stats = dicom_mcp_module._compute_stats(pixels) assert stats["min"] == 0.0 assert stats["max"] == 99.0 assert stats["mean"] == pytest.approx(49.5) assert stats["median"] == pytest.approx(49.5) assert stats["pixel_count"] == 100 # std of uniform discrete dist assert stats["std"] == pytest.approx(28.866, abs=0.01) def test_all_expected_keys(self, dicom_mcp_module): """Test that all expected statistic keys are returned.""" pixels = np.ones((5, 5), dtype=np.float64) stats = dicom_mcp_module._compute_stats(pixels) expected_keys = { "min", "max", "mean", "std", "median", "p5", "p25", "p75", "p95", "pixel_count", } assert set(stats.keys()) == expected_keys def test_percentile_ordering(self, dicom_mcp_module): """Test that percentiles are in expected order.""" pixels = np.random.RandomState(42).rand(50, 50).astype(np.float64) stats = dicom_mcp_module._compute_stats(pixels) assert stats["min"] <= stats["p5"] assert stats["p5"] <= stats["p25"] assert stats["p25"] <= stats["median"] assert stats["median"] <= stats["p75"] assert stats["p75"] <= stats["p95"] assert stats["p95"] <= stats["max"] class TestApplyWindowing: """Test _apply_windowing produces correct 8-bit output.""" def test_full_range(self, dicom_mcp_module): """Test windowing that maps full range to 0-255.""" pixels = np.array([[0.0, 50.0], [100.0, 200.0]]) result = dicom_mcp_module._apply_windowing(pixels, 100.0, 200.0) assert result.dtype == np.uint8 assert result[0, 0] == 0 # at lower bound assert result[1, 1] == 255 # at upper bound assert result[0, 1] == 63 # 50/200 * 255 = 63.75, truncated to 63 def test_clipping_below(self, dicom_mcp_module): """Test that values below window are clipped to 0.""" pixels = np.array([[-100.0, -50.0]]) result = dicom_mcp_module._apply_windowing(pixels, 100.0, 100.0) assert result[0, 0] == 0 assert result[0, 1] == 0 def test_clipping_above(self, dicom_mcp_module): """Test that values above window are clipped to 255.""" pixels = np.array([[500.0, 1000.0]]) result = dicom_mcp_module._apply_windowing(pixels, 100.0, 100.0) assert result[0, 0] == 255 assert result[0, 1] == 255 def test_narrow_window(self, dicom_mcp_module): """Test a very narrow window (high contrast).""" pixels = np.array([[99.0, 100.0, 101.0]]) result = dicom_mcp_module._apply_windowing(pixels, 100.0, 2.0) assert result[0, 0] == 0 assert result[0, 1] == 127 # centre maps to 127.5, truncated to 127 assert result[0, 2] == 255 def test_output_shape_preserved(self, dicom_mcp_module): """Test that output has same shape as input.""" pixels = np.random.rand(64, 128).astype(np.float64) * 1000 result = dicom_mcp_module._apply_windowing(pixels, 500.0, 800.0) assert result.shape == (64, 128) class TestGetPixelArray: """Test _get_pixel_array with synthetic DICOM datasets.""" def _make_dataset(self, pixel_values, slope=1.0, intercept=0.0): """Create a minimal pydicom Dataset with pixel data.""" ds = _make_base_dataset( rows=pixel_values.shape[0], columns=pixel_values.shape[1], bits_stored=16, pixel_data=pixel_values, RescaleSlope=slope, RescaleIntercept=intercept, ) return ds def test_identity_rescale(self, dicom_mcp_module): """Test pixel extraction with slope=1, intercept=0.""" raw = np.array([[100, 200], [300, 400]], dtype=np.uint16) ds = self._make_dataset(raw) result = dicom_mcp_module._get_pixel_array(ds) assert result.dtype == np.float64 np.testing.assert_array_equal(result, raw.astype(np.float64)) def test_slope_applied(self, dicom_mcp_module): """Test that RescaleSlope is applied correctly.""" raw = np.array([[10, 20]], dtype=np.uint16) ds = self._make_dataset(raw, slope=2.5, intercept=0.0) result = dicom_mcp_module._get_pixel_array(ds) np.testing.assert_array_almost_equal(result, [[25.0, 50.0]]) def test_intercept_applied(self, dicom_mcp_module): """Test that RescaleIntercept is applied correctly.""" raw = np.array([[100, 200]], dtype=np.uint16) ds = self._make_dataset(raw, slope=1.0, intercept=-100.0) result = dicom_mcp_module._get_pixel_array(ds) np.testing.assert_array_almost_equal(result, [[0.0, 100.0]]) def test_slope_and_intercept_combined(self, dicom_mcp_module): """Test slope and intercept applied together: value * slope + intercept.""" raw = np.array([[10, 20]], dtype=np.uint16) ds = self._make_dataset(raw, slope=2.0, intercept=5.0) result = dicom_mcp_module._get_pixel_array(ds) # 10*2+5=25, 20*2+5=45 np.testing.assert_array_almost_equal(result, [[25.0, 45.0]]) class TestPixelToolsWithSyntheticDicom: """Functional tests for pixel tools using synthetic DICOM files on disk. Creates temporary DICOM files with known pixel data to verify the full tool pipeline including file I/O. """ @pytest.fixture def synthetic_dicom(self, tmp_path): """Create a synthetic DICOM file with a gradient pattern. The image is 64x64 with values 0-4095 (12-bit range), arranged so that pixel intensity increases linearly left-to-right, with a known signal region (centre) and noise region (top-left corner). """ # Gradient pattern: each row is the same, columns go 0..4095 row = np.linspace(0, 4095, 64).astype(np.uint16) pixels = np.tile(row, (64, 1)) # Add a bright "signal" block in centre (rows 24-40, cols 24-40) pixels[24:40, 24:40] = 3000 # Add a low "noise" block in top-left (rows 0-16, cols 0-16) # with small random variation around a low mean rng = np.random.RandomState(42) pixels[0:16, 0:16] = (50 + rng.randint(0, 20, size=(16, 16))).astype(np.uint16) ds = _make_base_dataset( rows=64, columns=64, pixel_data=pixels, FrameOfReferenceUID=generate_uid(), Modality="MR", Manufacturer="TestVendor", SeriesDescription="LMS MOST Test", SeriesNumber=1, InstanceNumber=1, ImageType=["ORIGINAL", "PRIMARY", "M_FFE"], RescaleSlope=1.0, RescaleIntercept=0.0, WindowCenter=500, WindowWidth=1000, ) file_path = tmp_path / "test_image.dcm" _save_dicom(ds, file_path) return file_path, pixels @pytest.mark.asyncio async def test_read_pixels_full_image(self, dicom_mcp_module, synthetic_dicom): """Test dicom_read_pixels returns correct stats for full image.""" file_path, pixels = synthetic_dicom result = await dicom_mcp_module.dicom_read_pixels( file_path=str(file_path), response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert data["image_info"]["rows"] == 64 assert data["image_info"]["columns"] == 64 assert data["region"] == "Full image" assert data["statistics"]["pixel_count"] == 64 * 64 assert data["statistics"]["min"] >= 0 assert data["statistics"]["max"] <= 4095 @pytest.mark.asyncio async def test_read_pixels_with_roi(self, dicom_mcp_module, synthetic_dicom): """Test dicom_read_pixels with ROI restricts to correct region.""" file_path, pixels = synthetic_dicom # Signal block: cols 24-40, rows 24-40 → all 3000 result = await dicom_mcp_module.dicom_read_pixels( file_path=str(file_path), roi=[24, 24, 16, 16], response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert data["statistics"]["pixel_count"] == 16 * 16 assert data["statistics"]["mean"] == 3000.0 assert data["statistics"]["std"] == 0.0 @pytest.mark.asyncio async def test_read_pixels_with_histogram(self, dicom_mcp_module, synthetic_dicom): """Test dicom_read_pixels includes histogram when requested.""" file_path, _ = synthetic_dicom result = await dicom_mcp_module.dicom_read_pixels( file_path=str(file_path), include_histogram=True, histogram_bins=10, response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert "histogram" in data assert data["histogram"]["bins"] == 10 assert len(data["histogram"]["counts"]) == 10 assert len(data["histogram"]["bin_edges"]) == 11 assert sum(data["histogram"]["counts"]) == 64 * 64 @pytest.mark.asyncio async def test_read_pixels_invalid_roi(self, dicom_mcp_module, synthetic_dicom): """Test dicom_read_pixels returns error for out-of-bounds ROI.""" file_path, _ = synthetic_dicom result = await dicom_mcp_module.dicom_read_pixels( file_path=str(file_path), roi=[60, 60, 10, 10], ) assert "Error" in result assert "exceeds image bounds" in result @pytest.mark.asyncio async def test_read_pixels_file_not_found(self, dicom_mcp_module, tmp_path): """Test dicom_read_pixels returns error for missing file.""" result = await dicom_mcp_module.dicom_read_pixels( file_path=str(tmp_path / "nonexistent.dcm"), ) assert "Error" in result assert "not found" in result @pytest.mark.asyncio async def test_read_pixels_markdown_format(self, dicom_mcp_module, synthetic_dicom): """Test dicom_read_pixels markdown output contains expected sections.""" file_path, _ = synthetic_dicom result = await dicom_mcp_module.dicom_read_pixels( file_path=str(file_path), response_format=dicom_mcp_module.ResponseFormat.MARKDOWN, ) assert "# Pixel Statistics" in result assert "## Statistics" in result assert "Mean" in result assert "Std Dev" in result @pytest.mark.asyncio async def test_compute_snr_known_values(self, dicom_mcp_module, synthetic_dicom): """Test dicom_compute_snr with signal block and noise block.""" file_path, _ = synthetic_dicom result = await dicom_mcp_module.dicom_compute_snr( file_path=str(file_path), signal_roi=[24, 24, 16, 16], # bright block, mean=3000, std=0 noise_roi=[0, 0, 16, 16], # noisy low block response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert data["signal_roi"]["statistics"]["mean"] == 3000.0 assert data["noise_roi"]["statistics"]["mean"] < 100.0 assert data["noise_roi"]["statistics"]["std"] > 0 # SNR should be high (3000 / small_std) assert isinstance(data["snr"], (int, float)) assert data["snr"] > 100 @pytest.mark.asyncio async def test_compute_snr_zero_noise(self, dicom_mcp_module, synthetic_dicom): """Test dicom_compute_snr handles zero noise std gracefully.""" file_path, _ = synthetic_dicom # Use the signal block as both regions (std=0 in constant block) result = await dicom_mcp_module.dicom_compute_snr( file_path=str(file_path), signal_roi=[24, 24, 8, 8], noise_roi=[28, 28, 8, 8], # also in the constant block response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert data["snr"] == "infinite" assert "snr_note" in data @pytest.mark.asyncio async def test_compute_snr_invalid_roi(self, dicom_mcp_module, synthetic_dicom): """Test dicom_compute_snr returns error for bad ROI.""" file_path, _ = synthetic_dicom result = await dicom_mcp_module.dicom_compute_snr( file_path=str(file_path), signal_roi=[0, 0, 10, 10], noise_roi=[60, 60, 10, 10], # out of bounds ) assert "Error in noise ROI" in result @pytest.mark.asyncio async def test_compute_snr_markdown_format(self, dicom_mcp_module, synthetic_dicom): """Test dicom_compute_snr markdown output.""" file_path, _ = synthetic_dicom result = await dicom_mcp_module.dicom_compute_snr( file_path=str(file_path), signal_roi=[24, 24, 16, 16], noise_roi=[0, 0, 16, 16], response_format=dicom_mcp_module.ResponseFormat.MARKDOWN, ) assert "# SNR Analysis" in result assert "## Signal ROI" in result assert "## Noise ROI" in result assert "## Result" in result assert "SNR =" in result @pytest.mark.asyncio async def test_render_image_default_windowing( self, dicom_mcp_module, synthetic_dicom, tmp_path ): """Test dicom_render_image creates a PNG with DICOM header windowing.""" file_path, _ = synthetic_dicom output = tmp_path / "rendered.png" result = await dicom_mcp_module.dicom_render_image( file_path=str(file_path), output_path=str(output), response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert output.exists() assert data["windowing"]["source"] == "DICOM header" assert data["windowing"]["center"] == 500.0 assert data["windowing"]["width"] == 1000.0 # Verify it's a valid image img = Image.open(output) assert img.size == (64, 64) @pytest.mark.asyncio async def test_render_image_auto_windowing( self, dicom_mcp_module, synthetic_dicom, tmp_path ): """Test dicom_render_image auto windowing uses percentiles.""" file_path, _ = synthetic_dicom output = tmp_path / "auto.png" result = await dicom_mcp_module.dicom_render_image( file_path=str(file_path), output_path=str(output), auto_window=True, response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert output.exists() assert data["windowing"]["source"] == "auto (p5-p95)" @pytest.mark.asyncio async def test_render_image_manual_windowing( self, dicom_mcp_module, synthetic_dicom, tmp_path ): """Test dicom_render_image manual windowing overrides header.""" file_path, _ = synthetic_dicom output = tmp_path / "manual.png" result = await dicom_mcp_module.dicom_render_image( file_path=str(file_path), output_path=str(output), window_center=2000.0, window_width=4000.0, response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert data["windowing"]["source"] == "manual" assert data["windowing"]["center"] == 2000.0 assert data["windowing"]["width"] == 4000.0 @pytest.mark.asyncio async def test_render_image_with_roi_overlays( self, dicom_mcp_module, synthetic_dicom, tmp_path ): """Test dicom_render_image draws ROI overlays.""" file_path, _ = synthetic_dicom output = tmp_path / "overlays.png" result = await dicom_mcp_module.dicom_render_image( file_path=str(file_path), output_path=str(output), auto_window=True, overlay_rois=[ {"roi": [24, 24, 16, 16], "label": "Signal", "color": "green"}, {"roi": [0, 0, 16, 16], "label": "Noise", "color": "red"}, ], response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert data["overlays"] == 2 img = Image.open(output) # With overlays, image should be RGB assert img.mode == "RGB" @pytest.mark.asyncio async def test_render_image_no_info_bar( self, dicom_mcp_module, synthetic_dicom, tmp_path ): """Test dicom_render_image without info bar stays greyscale.""" file_path, _ = synthetic_dicom output = tmp_path / "no_info.png" await dicom_mcp_module.dicom_render_image( file_path=str(file_path), output_path=str(output), show_info=False, auto_window=True, ) img = Image.open(output) assert img.mode == "L" # greyscale, no overlays or info text @pytest.mark.asyncio async def test_render_image_file_not_found(self, dicom_mcp_module, tmp_path): """Test dicom_render_image returns error for missing file.""" result = await dicom_mcp_module.dicom_render_image( file_path=str(tmp_path / "nonexistent.dcm"), output_path=str(tmp_path / "out.png"), ) assert "Error" in result assert "not found" in result @pytest.mark.asyncio async def test_render_image_creates_parent_dirs( self, dicom_mcp_module, synthetic_dicom, tmp_path ): """Test dicom_render_image creates output directories if needed.""" file_path, _ = synthetic_dicom output = tmp_path / "sub" / "dir" / "image.png" await dicom_mcp_module.dicom_render_image( file_path=str(file_path), output_path=str(output), auto_window=True, show_info=False, ) assert output.exists() class TestPhilipsPrivateTagHelpers: """Test Philips private tag resolution helpers with synthetic datasets.""" def _make_philips_dataset(self): """Create a synthetic Philips DICOM dataset with private creator tags.""" ds = _make_base_dataset( bits_stored=16, Manufacturer="Philips Medical Systems", ManufacturerModelName="Ingenia", SeriesDescription="Test Series", ) # Simulate Philips Private Creator tags in group 0x2005 # Slot 0x10 -> DD 001 ds.add_new((0x2005, 0x0010), "LO", "Philips MR Imaging DD 001") # Slot 0x11 -> DD 002 ds.add_new((0x2005, 0x0011), "LO", "Philips MR Imaging DD 002") # Slot 0x12 -> DD 004 ds.add_new((0x2005, 0x0012), "LO", "Philips MR Imaging DD 004") # Add some private data elements in each block # DD 001, block 0x10: element at offset 0x85 -> tag (2005, 1085) ds.add_new((0x2005, 0x1085), "LO", "ShimValue_42") # DD 001, block 0x10: element at offset 0x63 -> tag (2005, 1063) ds.add_new((0x2005, 0x1063), "LO", "StackID_1") # DD 002, block 0x11: element at offset 0x30 -> tag (2005, 1130) ds.add_new((0x2005, 0x1130), "LO", "DD002_Value") return ds def test_resolve_known_tag(self, dicom_mcp_module): """Test resolving DD 001 offset 0x85 -> (2005,1085).""" ds = self._make_philips_dataset() resolved_tag, creator_str, value_str = ( dicom_mcp_module._resolve_philips_private_tag( ds, dd_number=1, element_offset=0x85 ) ) assert resolved_tag == (0x2005, 0x1085) assert "DD 001" in creator_str assert value_str == "ShimValue_42" def test_resolve_different_dd(self, dicom_mcp_module): """Test resolving DD 002 offset 0x30 -> (2005,1130).""" ds = self._make_philips_dataset() resolved_tag, creator_str, value_str = ( dicom_mcp_module._resolve_philips_private_tag( ds, dd_number=2, element_offset=0x30 ) ) assert resolved_tag == (0x2005, 0x1130) assert "DD 002" in creator_str assert value_str == "DD002_Value" def test_resolve_missing_dd(self, dicom_mcp_module): """Test resolving a DD number that doesn't exist returns None.""" ds = self._make_philips_dataset() resolved_tag, creator_str, value_str = ( dicom_mcp_module._resolve_philips_private_tag( ds, dd_number=99, element_offset=0x85 ) ) assert resolved_tag is None assert creator_str is None assert value_str is None def test_resolve_tag_no_value(self, dicom_mcp_module): """Test resolving a valid DD but offset with no data returns None value.""" ds = self._make_philips_dataset() # DD 004 exists at slot 0x12, but no element at offset 0xFF resolved_tag, creator_str, value_str = ( dicom_mcp_module._resolve_philips_private_tag( ds, dd_number=4, element_offset=0xFF ) ) assert resolved_tag == (0x2005, 0x12FF) assert "DD 004" in creator_str assert value_str is None def test_resolve_second_offset_same_dd(self, dicom_mcp_module): """Test resolving DD 001 offset 0x63 -> (2005,1063).""" ds = self._make_philips_dataset() resolved_tag, creator_str, value_str = ( dicom_mcp_module._resolve_philips_private_tag( ds, dd_number=1, element_offset=0x63 ) ) assert resolved_tag == (0x2005, 0x1063) assert value_str == "StackID_1" def test_list_creators(self, dicom_mcp_module): """Test listing all Private Creator tags.""" ds = self._make_philips_dataset() creators = dicom_mcp_module._list_philips_private_creators(ds) assert len(creators) == 3 dd_numbers = [c["dd_number"] for c in creators] assert 1 in dd_numbers assert 2 in dd_numbers assert 4 in dd_numbers def test_list_creators_empty(self, dicom_mcp_module): """Test listing creators on a dataset with no private tags.""" ds = pydicom.Dataset() creators = dicom_mcp_module._list_philips_private_creators(ds) assert creators == [] def test_list_creators_slots(self, dicom_mcp_module): """Test that creator slot numbers are correct.""" ds = self._make_philips_dataset() creators = dicom_mcp_module._list_philips_private_creators(ds) slots = {c["dd_number"]: c["slot"] for c in creators} assert slots[1] == 0x10 assert slots[2] == 0x11 assert slots[4] == 0x12 def test_resolve_custom_group(self, dicom_mcp_module): """Test resolving private tags in a non-default group.""" ds = pydicom.Dataset() # Use group 0x2001 instead of 0x2005 ds.add_new((0x2001, 0x0010), "LO", "Philips MR Imaging DD 005") ds.add_new((0x2001, 0x1042), "LO", "CustomGroupValue") resolved_tag, creator_str, value_str = ( dicom_mcp_module._resolve_philips_private_tag( ds, dd_number=5, element_offset=0x42, private_group=0x2001 ) ) assert resolved_tag == (0x2001, 0x1042) assert value_str == "CustomGroupValue" class TestPhilipsPrivateToolWithSyntheticDicom: """Functional tests for dicom_query_philips_private using synthetic DICOM files.""" @pytest.fixture def philips_dicom(self, tmp_path): """Create a synthetic Philips DICOM file with private creator tags.""" ds = _make_base_dataset( bits_stored=16, Modality="MR", Manufacturer="Philips Medical Systems", ManufacturerModelName="Ingenia", SeriesDescription="Philips Test Series", SeriesNumber=1, InstanceNumber=1, FrameOfReferenceUID=generate_uid(), ) # Private Creator tags ds.add_new((0x2005, 0x0010), "LO", "Philips MR Imaging DD 001") ds.add_new((0x2005, 0x0011), "LO", "Philips MR Imaging DD 002") # Private data elements ds.add_new((0x2005, 0x1085), "LO", "ShimCalcValue") ds.add_new((0x2005, 0x1130), "LO", "DD002_TestVal") file_path = tmp_path / "philips_test.dcm" _save_dicom(ds, file_path) return file_path @pytest.mark.asyncio async def test_list_creators_tool(self, dicom_mcp_module, philips_dicom): """Test dicom_query_philips_private with list_creators=True.""" result = await dicom_mcp_module.dicom_query_philips_private( file_path=str(philips_dicom), list_creators=True, response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert len(data["creators"]) == 2 dd_nums = [c["dd_number"] for c in data["creators"]] assert 1 in dd_nums assert 2 in dd_nums @pytest.mark.asyncio async def test_resolve_tag_tool(self, dicom_mcp_module, philips_dicom): """Test dicom_query_philips_private resolving DD 001 offset 0x85.""" result = await dicom_mcp_module.dicom_query_philips_private( file_path=str(philips_dicom), dd_number=1, element_offset=0x85, response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert data["resolution"]["resolved_tag"] == "(2005,1085)" assert data["resolution"]["value"] == "ShimCalcValue" assert "DD 001" in data["resolution"]["creator_string"] @pytest.mark.asyncio async def test_resolve_missing_dd_tool(self, dicom_mcp_module, philips_dicom): """Test dicom_query_philips_private with non-existent DD number.""" result = await dicom_mcp_module.dicom_query_philips_private( file_path=str(philips_dicom), dd_number=99, element_offset=0x85, ) assert "Error" in result assert "DD 099" in result @pytest.mark.asyncio async def test_resolve_tag_markdown(self, dicom_mcp_module, philips_dicom): """Test dicom_query_philips_private markdown output.""" result = await dicom_mcp_module.dicom_query_philips_private( file_path=str(philips_dicom), dd_number=1, element_offset=0x85, response_format=dicom_mcp_module.ResponseFormat.MARKDOWN, ) assert "Philips Private Tag Lookup" in result assert "(2005,1085)" in result assert "ShimCalcValue" in result @pytest.mark.asyncio async def test_list_creators_markdown(self, dicom_mcp_module, philips_dicom): """Test dicom_query_philips_private list_creators markdown output.""" result = await dicom_mcp_module.dicom_query_philips_private( file_path=str(philips_dicom), list_creators=True, response_format=dicom_mcp_module.ResponseFormat.MARKDOWN, ) assert "Philips Private Creators" in result assert "DD 001" in result assert "DD 002" in result @pytest.mark.asyncio async def test_no_args_error(self, dicom_mcp_module, philips_dicom): """Test dicom_query_philips_private with no mode-specific args.""" result = await dicom_mcp_module.dicom_query_philips_private( file_path=str(philips_dicom), ) assert "Error" in result assert "list_creators" in result @pytest.mark.asyncio async def test_file_not_found(self, dicom_mcp_module, tmp_path): """Test dicom_query_philips_private with missing file.""" result = await dicom_mcp_module.dicom_query_philips_private( file_path=str(tmp_path / "nonexistent.dcm"), list_creators=True, ) assert "Error" in result assert "not found" in result class TestGetMetadataPhilipsPrivate: """Test dicom_get_metadata with philips_private_tags parameter.""" @pytest.fixture def philips_dicom(self, tmp_path): """Create a synthetic Philips DICOM file with private creator tags.""" ds = _make_base_dataset( bits_stored=16, Modality="MR", Manufacturer="Philips Medical Systems", SeriesDescription="Test", SeriesNumber=1, PatientName="TestPatient", PatientID="12345", ) # Private Creator tags ds.add_new((0x2005, 0x0010), "LO", "Philips MR Imaging DD 001") ds.add_new((0x2005, 0x1085), "LO", "ShimTestValue") file_path = tmp_path / "philips_meta_test.dcm" _save_dicom(ds, file_path) return file_path @pytest.mark.asyncio async def test_metadata_with_philips_private(self, dicom_mcp_module, philips_dicom): """Test dicom_get_metadata with philips_private_tags resolves tags.""" result = await dicom_mcp_module.dicom_get_metadata( file_path=str(philips_dicom), tag_groups=["manufacturer"], philips_private_tags=[{"dd_number": 1, "element_offset": 0x85}], response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert "philips_private" in data["metadata"] private = data["metadata"]["philips_private"] # Key is DD001_0x85 assert "DD001_0x85" in private assert private["DD001_0x85"]["value"] == "ShimTestValue" assert private["DD001_0x85"]["resolved_tag"] == "(2005,1085)" @pytest.mark.asyncio async def test_metadata_philips_private_missing_dd( self, dicom_mcp_module, philips_dicom ): """Test dicom_get_metadata with non-existent DD number.""" result = await dicom_mcp_module.dicom_get_metadata( file_path=str(philips_dicom), tag_groups=["manufacturer"], philips_private_tags=[{"dd_number": 99, "element_offset": 0x01}], response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert "philips_private" in data["metadata"] private = data["metadata"]["philips_private"] assert "DD099_0x01" in private assert "error" in private["DD099_0x01"] @pytest.mark.asyncio async def test_metadata_philips_private_markdown( self, dicom_mcp_module, philips_dicom ): """Test dicom_get_metadata philips_private_tags in markdown format.""" result = await dicom_mcp_module.dicom_get_metadata( file_path=str(philips_dicom), tag_groups=["manufacturer"], philips_private_tags=[{"dd_number": 1, "element_offset": 0x85}], response_format=dicom_mcp_module.ResponseFormat.MARKDOWN, ) assert "Philips Private" in result assert "(2005,1085)" in result assert "ShimTestValue" in result class TestPIIFiltering: """Test PII filtering when DICOM_MCP_PII_FILTER is enabled.""" @pytest.fixture def pii_dicom(self, tmp_path): """Create a synthetic DICOM with known patient data.""" ds = _make_pii_dataset() ds.StudyDate = "20240101" file_path = tmp_path / "pii_test.dcm" _save_dicom(ds, file_path) return file_path @pytest.fixture(autouse=True) def enable_pii_filter(self, monkeypatch): """Enable the PII filter for every test in this class.""" import importlib import dicom_mcp.config monkeypatch.setenv("DICOM_MCP_PII_FILTER", "true") importlib.reload(dicom_mcp.config) yield monkeypatch.delenv("DICOM_MCP_PII_FILTER", raising=False) importlib.reload(dicom_mcp.config) @pytest.mark.asyncio async def test_get_metadata_redacts_patient_info(self, dicom_mcp_module, pii_dicom): """dicom_get_metadata should redact patient tags when filter is on.""" result = await dicom_mcp_module.dicom_get_metadata( file_path=str(pii_dicom), tag_groups=["patient_info"], response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) patient = data["metadata"]["patient_info"] assert patient["PatientName"] == "[REDACTED]" assert patient["PatientID"] == "[REDACTED]" assert patient["PatientBirthDate"] == "[REDACTED]" assert patient["PatientSex"] == "[REDACTED]" @pytest.mark.asyncio async def test_get_metadata_does_not_redact_non_pii( self, dicom_mcp_module, pii_dicom ): """Non-patient tags should NOT be redacted even when filter is on.""" result = await dicom_mcp_module.dicom_get_metadata( file_path=str(pii_dicom), tag_groups=["manufacturer"], response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) manufacturer = data["metadata"]["manufacturer"] assert manufacturer["Manufacturer"] == "SIEMENS" assert "[REDACTED]" not in json.dumps(manufacturer) @pytest.mark.asyncio async def test_get_metadata_redacts_in_markdown(self, dicom_mcp_module, pii_dicom): """Markdown output should also show [REDACTED] for patient tags.""" result = await dicom_mcp_module.dicom_get_metadata( file_path=str(pii_dicom), tag_groups=["patient_info"], response_format=dicom_mcp_module.ResponseFormat.MARKDOWN, ) assert "[REDACTED]" in result assert "Doe^Jane" not in result assert "PII-TEST-001" not in result class TestPIIFilteringDisabled: """Test that PII is NOT filtered when DICOM_MCP_PII_FILTER is off.""" @pytest.fixture def pii_dicom(self, tmp_path): """Create a synthetic DICOM with known patient data.""" ds = _make_pii_dataset() file_path = tmp_path / "pii_disabled_test.dcm" _save_dicom(ds, file_path) return file_path @pytest.fixture(autouse=True) def disable_pii_filter(self, monkeypatch): """Ensure the PII filter is OFF for every test in this class.""" import importlib import dicom_mcp.config monkeypatch.delenv("DICOM_MCP_PII_FILTER", raising=False) importlib.reload(dicom_mcp.config) yield importlib.reload(dicom_mcp.config) @pytest.mark.asyncio async def test_get_metadata_shows_patient_data(self, dicom_mcp_module, pii_dicom): """Patient data should be visible when filter is off.""" result = await dicom_mcp_module.dicom_get_metadata( file_path=str(pii_dicom), tag_groups=["patient_info"], response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) patient = data["metadata"]["patient_info"] assert patient["PatientName"] == "Doe^Jane" assert patient["PatientID"] == "PII-TEST-001" assert patient["PatientBirthDate"] == "19850315" assert patient["PatientSex"] == "F" @pytest.mark.asyncio async def test_no_redaction_markers_present(self, dicom_mcp_module, pii_dicom): """No [REDACTED] markers should appear when filter is off.""" result = await dicom_mcp_module.dicom_get_metadata( file_path=str(pii_dicom), response_format=dicom_mcp_module.ResponseFormat.JSON, ) assert "[REDACTED]" not in result class TestDicomDumpTree: """Tests for dicom_dump_tree tool.""" @pytest.fixture def tree_dicom(self, tmp_path): """Create a synthetic DICOM with nested sequences.""" ds = _make_base_dataset( Modality="MR", PatientName="TreeTest^Patient", PatientID="TREE-001", SeriesDescription="Tree Test Series", ) # Add a nested sequence using standard DICOM tags code_item = pydicom.Dataset() code_item.CodeValue = "12345" code_item.CodingSchemeDesignator = "DCM" code_item.CodeMeaning = "Test Code" ref_item = pydicom.Dataset() ref_item.ReferencedSOPClassUID = MRImageStorage ref_item.ReferencedSOPInstanceUID = generate_uid() ref_item.PurposeOfReferenceCodeSequence = DicomSequence([code_item]) ds.ReferencedStudySequence = DicomSequence([ref_item]) # Add a private tag ds.add_new((0x0009, 0x0010), "LO", "TEST PRIVATE") ds.add_new((0x0009, 0x1001), "LO", "PrivateValue42") file_path = tmp_path / "tree_test.dcm" _save_dicom(ds, file_path) return file_path @pytest.mark.asyncio async def test_tree_markdown_output(self, dicom_mcp_module, tree_dicom): """Test markdown tree output contains expected elements.""" result = await dicom_mcp_module.dicom_dump_tree( file_path=str(tree_dicom), response_format=dicom_mcp_module.ResponseFormat.MARKDOWN, ) assert "# DICOM Tree:" in result assert "PatientName" in result assert "TreeTest^Patient" in result assert "SeriesDescription" in result @pytest.mark.asyncio async def test_tree_json_output(self, dicom_mcp_module, tree_dicom): """Test JSON tree output has correct structure.""" result = await dicom_mcp_module.dicom_dump_tree( file_path=str(tree_dicom), response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert "tree" in data assert isinstance(data["tree"], list) assert data["file"] == "tree_test.dcm" assert len(data["tree"]) > 0 @pytest.mark.asyncio async def test_tree_includes_sequences(self, dicom_mcp_module, tree_dicom): """Test that nested sequences appear in tree output.""" result = await dicom_mcp_module.dicom_dump_tree( file_path=str(tree_dicom), response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) # Find the sequence element seq_nodes = [n for n in data["tree"] if n["vr"] == "SQ"] assert len(seq_nodes) > 0 # Check it has items with children assert "items" in seq_nodes[0] assert len(seq_nodes[0]["items"]) > 0 @pytest.mark.asyncio async def test_tree_max_depth(self, dicom_mcp_module, tree_dicom): """Test max_depth=0 prevents sequence recursion.""" result = await dicom_mcp_module.dicom_dump_tree( file_path=str(tree_dicom), max_depth=0, response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) # Sequences should exist but have no items expanded seq_nodes = [n for n in data["tree"] if n["vr"] == "SQ"] for node in seq_nodes: assert "items" not in node @pytest.mark.asyncio async def test_tree_hide_private(self, dicom_mcp_module, tree_dicom): """Test show_private=False excludes private tags.""" result = await dicom_mcp_module.dicom_dump_tree( file_path=str(tree_dicom), show_private=False, response_format=dicom_mcp_module.ResponseFormat.MARKDOWN, ) assert "PrivateValue42" not in result # Standard tags should still be present assert "PatientName" in result @pytest.mark.asyncio async def test_tree_show_private(self, dicom_mcp_module, tree_dicom): """Test show_private=True includes private tags.""" result = await dicom_mcp_module.dicom_dump_tree( file_path=str(tree_dicom), show_private=True, response_format=dicom_mcp_module.ResponseFormat.MARKDOWN, ) assert "PrivateValue42" in result @pytest.mark.asyncio async def test_tree_file_not_found(self, dicom_mcp_module): """Test error for non-existent file.""" result = await dicom_mcp_module.dicom_dump_tree( file_path="/nonexistent/file.dcm", ) assert "Error" in result class TestDicomCompareUids: """Tests for dicom_compare_uids tool.""" def _make_dicom_file(self, tmp_path, subdir, filename, series_uid, study_uid=None): """Create a minimal DICOM file with specified UIDs.""" ds = _make_base_dataset( series_instance_uid=series_uid, study_instance_uid=study_uid, Modality="MR", FrameOfReferenceUID=generate_uid(), ) file_path = tmp_path / subdir / filename _save_dicom(ds, file_path) return file_path @pytest.mark.asyncio async def test_identical_uids(self, dicom_mcp_module, tmp_path): """Two directories with same SeriesInstanceUID should match.""" uid = "1.2.3.4.5.6.7.8.9" self._make_dicom_file(tmp_path, "dir1", "a.dcm", uid) self._make_dicom_file(tmp_path, "dir2", "b.dcm", uid) result = await dicom_mcp_module.dicom_compare_uids( directory1=str(tmp_path / "dir1"), directory2=str(tmp_path / "dir2"), response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert data["match"] is True assert data["shared"] == 1 assert data["only_in_directory1"] == 0 assert data["only_in_directory2"] == 0 @pytest.mark.asyncio async def test_disjoint_uids(self, dicom_mcp_module, tmp_path): """Two directories with different UIDs should report mismatches.""" self._make_dicom_file(tmp_path, "dir1", "a.dcm", "1.2.3.4.5") self._make_dicom_file(tmp_path, "dir2", "b.dcm", "9.8.7.6.5") result = await dicom_mcp_module.dicom_compare_uids( directory1=str(tmp_path / "dir1"), directory2=str(tmp_path / "dir2"), response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert data["match"] is False assert data["shared"] == 0 assert data["only_in_directory1"] == 1 assert data["only_in_directory2"] == 1 @pytest.mark.asyncio async def test_partial_overlap(self, dicom_mcp_module, tmp_path): """Test partial overlap between directories.""" shared_uid = "1.2.3.4.5" self._make_dicom_file(tmp_path, "dir1", "a.dcm", shared_uid) self._make_dicom_file(tmp_path, "dir1", "b.dcm", "1.2.3.4.6") self._make_dicom_file(tmp_path, "dir2", "c.dcm", shared_uid) self._make_dicom_file(tmp_path, "dir2", "d.dcm", "9.8.7.6.5") result = await dicom_mcp_module.dicom_compare_uids( directory1=str(tmp_path / "dir1"), directory2=str(tmp_path / "dir2"), response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert data["match"] is False assert data["shared"] == 1 assert data["only_in_directory1"] == 1 assert data["only_in_directory2"] == 1 @pytest.mark.asyncio async def test_custom_compare_tag(self, dicom_mcp_module, tmp_path): """Test comparing by StudyInstanceUID instead of SeriesInstanceUID.""" study_uid = "1.2.3.100" self._make_dicom_file(tmp_path, "dir1", "a.dcm", "1.2.3.101", study_uid) self._make_dicom_file(tmp_path, "dir2", "b.dcm", "1.2.3.102", study_uid) result = await dicom_mcp_module.dicom_compare_uids( directory1=str(tmp_path / "dir1"), directory2=str(tmp_path / "dir2"), compare_tag="StudyInstanceUID", response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert data["match"] is True assert data["shared"] == 1 @pytest.mark.asyncio async def test_markdown_output(self, dicom_mcp_module, tmp_path): """Test markdown output format.""" self._make_dicom_file(tmp_path, "dir1", "a.dcm", "1.2.3") self._make_dicom_file(tmp_path, "dir2", "b.dcm", "1.2.3") result = await dicom_mcp_module.dicom_compare_uids( directory1=str(tmp_path / "dir1"), directory2=str(tmp_path / "dir2"), response_format=dicom_mcp_module.ResponseFormat.MARKDOWN, ) assert "# UID Comparison" in result assert "MATCH" in result @pytest.mark.asyncio async def test_directory_not_found(self, dicom_mcp_module, tmp_path): """Test error for non-existent directory.""" result = await dicom_mcp_module.dicom_compare_uids( directory1="/nonexistent/dir1", directory2=str(tmp_path), ) assert "Error" in result @pytest.mark.asyncio async def test_empty_directory(self, dicom_mcp_module, tmp_path): """Test with an empty directory.""" (tmp_path / "dir1").mkdir() (tmp_path / "dir2").mkdir() result = await dicom_mcp_module.dicom_compare_uids( directory1=str(tmp_path / "dir1"), directory2=str(tmp_path / "dir2"), ) assert "No DICOM files" in result class TestDicomVerifySegmentations: """Tests for dicom_verify_segmentations tool.""" def _make_source_dicom(self, dir_path, filename, sop_uid): """Create a minimal source DICOM with a known SOPInstanceUID.""" ds = _make_base_dataset( sop_instance_uid=sop_uid, Modality="MR", ) file_path = dir_path / filename _save_dicom(ds, file_path) return file_path def _make_seg_dicom(self, dir_path, filename, ref_sop_uids): """Create a minimal segmentation DICOM referencing source UIDs.""" ds = _make_base_dataset( sop_class=SegmentationStorage, SeriesDescription="Segmentation", Modality="SEG", ) # Build SourceImageSequence source_items = [] for ref_uid in ref_sop_uids: item = pydicom.Dataset() item.ReferencedSOPClassUID = MRImageStorage item.ReferencedSOPInstanceUID = UID(ref_uid) source_items.append(item) ds.SourceImageSequence = DicomSequence(source_items) file_path = dir_path / filename _save_dicom(ds, file_path) return file_path @pytest.mark.asyncio async def test_all_matched(self, dicom_mcp_module, tmp_path): """Test with segmentations that all reference valid source files.""" source_uid = "1.2.3.4.5.100" d = tmp_path / "seg_test" self._make_source_dicom(d, "source.dcm", source_uid) self._make_seg_dicom(d, "seg.dcm", [source_uid]) result = await dicom_mcp_module.dicom_verify_segmentations( directory=str(d), response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert data["all_matched"] is True assert data["matched_references"] == 1 assert data["unmatched_references"] == 0 @pytest.mark.asyncio async def test_unmatched_reference(self, dicom_mcp_module, tmp_path): """Test with a dangling reference in the segmentation.""" d = tmp_path / "seg_test" self._make_source_dicom(d, "source.dcm", "1.2.3.200") self._make_seg_dicom(d, "seg.dcm", ["1.2.3.999"]) result = await dicom_mcp_module.dicom_verify_segmentations( directory=str(d), response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert data["all_matched"] is False assert data["unmatched_references"] == 1 @pytest.mark.asyncio async def test_multiple_references(self, dicom_mcp_module, tmp_path): """Test segmentation with multiple source references.""" uid1 = "1.2.3.4.5.201" uid2 = "1.2.3.4.5.202" d = tmp_path / "seg_test" self._make_source_dicom(d, "source1.dcm", uid1) self._make_source_dicom(d, "source2.dcm", uid2) self._make_seg_dicom(d, "seg.dcm", [uid1, uid2]) result = await dicom_mcp_module.dicom_verify_segmentations( directory=str(d), response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert data["all_matched"] is True assert data["matched_references"] == 2 @pytest.mark.asyncio async def test_no_segmentations(self, dicom_mcp_module, tmp_path): """Test with no segmentation files in directory.""" d = tmp_path / "seg_test" self._make_source_dicom(d, "source.dcm", "1.2.3.4.5") result = await dicom_mcp_module.dicom_verify_segmentations( directory=str(d), ) assert "No segmentation files" in result @pytest.mark.asyncio async def test_markdown_output(self, dicom_mcp_module, tmp_path): """Test markdown output format.""" d = tmp_path / "seg_test" self._make_source_dicom(d, "source.dcm", "1.2.3.400") self._make_seg_dicom(d, "seg.dcm", ["1.2.3.400"]) result = await dicom_mcp_module.dicom_verify_segmentations( directory=str(d), response_format=dicom_mcp_module.ResponseFormat.MARKDOWN, ) assert "# Segmentation Verification" in result assert "ALL MATCHED" in result @pytest.mark.asyncio async def test_markdown_unmatched_details(self, dicom_mcp_module, tmp_path): """Test markdown output shows unmatched reference details.""" d = tmp_path / "seg_test" self._make_source_dicom(d, "source.dcm", "1.2.3.200") self._make_seg_dicom(d, "seg.dcm", ["1.2.3.999"]) result = await dicom_mcp_module.dicom_verify_segmentations( directory=str(d), response_format=dicom_mcp_module.ResponseFormat.MARKDOWN, ) assert "UNMATCHED REFERENCES FOUND" in result assert "NOT FOUND" in result @pytest.mark.asyncio async def test_directory_not_found(self, dicom_mcp_module): """Test error for non-existent directory.""" result = await dicom_mcp_module.dicom_verify_segmentations( directory="/nonexistent/dir", ) assert "Error" in result class TestDicomAnalyzeTi: """Tests for dicom_analyze_ti tool.""" def _make_molli_file( self, dir_path, filename, instance_num, inversion_time=None, series_num=601, series_desc="LMS MOLLI", manufacturer="SIEMENS", philips_private_ti=None, ): """Create a synthetic MOLLI DICOM file.""" ds = _make_base_dataset( Modality="MR", Manufacturer=manufacturer, SeriesNumber=series_num, SeriesDescription=series_desc, InstanceNumber=instance_num, ScanningSequence="GR", SequenceVariant="MP", MRAcquisitionType="2D", RepetitionTime=2.42, EchoTime=1.05, FlipAngle=35, ) if inversion_time is not None: ds.InversionTime = inversion_time # Add Philips private TI tag if requested if philips_private_ti is not None and "philips" in manufacturer.lower(): # Add DD 006 private creator at group 2005 ds.add_new((0x2005, 0x0015), "LO", "Philips MR Imaging DD 006") # Add the TI value at offset 0x72 from block 0x15 ds.add_new((0x2005, 0x1572), "FL", philips_private_ti) file_path = dir_path / filename _save_dicom(ds, file_path) return file_path @pytest.mark.asyncio async def test_siemens_standard_ti(self, dicom_mcp_module, tmp_path): """Test TI extraction from standard InversionTime tag (Siemens/GE).""" d = tmp_path / "molli" tis = [100.0, 200.0, 350.0, 500.0, 750.0] for i, ti in enumerate(tis, 1): self._make_molli_file(d, f"img{i:02d}.dcm", i, inversion_time=ti) result = await dicom_mcp_module.dicom_analyze_ti( directory=str(d), response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert data["t1_mapping_files"] == 5 assert data["series_count"] == 1 stats = data["series"][0]["statistics"] assert stats["non_zero_ti_count"] == 5 assert stats["ti_min"] == 100.0 assert stats["ti_max"] == 750.0 @pytest.mark.asyncio async def test_philips_private_ti(self, dicom_mcp_module, tmp_path): """Test TI extraction from Philips private tag.""" d = tmp_path / "molli_philips" tis = [78.0, 159.0, 856.0, 1034.0, 1656.0] for i, ti in enumerate(tis, 1): self._make_molli_file( d, f"img{i:02d}.dcm", i, manufacturer="Philips Medical Systems", philips_private_ti=ti, ) result = await dicom_mcp_module.dicom_analyze_ti( directory=str(d), response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert data["t1_mapping_files"] == 5 stats = data["series"][0]["statistics"] assert stats["non_zero_ti_count"] == 5 assert stats["ti_min"] == 78.0 assert stats["ti_max"] == 1656.0 @pytest.mark.asyncio async def test_zero_ti_filtering(self, dicom_mcp_module, tmp_path): """Test that zero TIs are counted separately as output maps.""" d = tmp_path / "molli" self._make_molli_file(d, "img01.dcm", 1, inversion_time=100.0) self._make_molli_file(d, "img02.dcm", 2, inversion_time=200.0) self._make_molli_file(d, "img03.dcm", 3, inversion_time=0.0) result = await dicom_mcp_module.dicom_analyze_ti( directory=str(d), response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) stats = data["series"][0]["statistics"] assert stats["non_zero_ti_count"] == 2 assert stats["zero_ti_count"] == 1 @pytest.mark.asyncio async def test_gap_threshold_exceeded(self, dicom_mcp_module, tmp_path): """Test gap threshold warning is triggered.""" d = tmp_path / "molli" tis = [100.0, 200.0, 3500.0] # gap of 3300 exceeds default 2500 for i, ti in enumerate(tis, 1): self._make_molli_file(d, f"img{i:02d}.dcm", i, inversion_time=ti) result = await dicom_mcp_module.dicom_analyze_ti( directory=str(d), response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) stats = data["series"][0]["statistics"] assert stats["gap_threshold_exceeded"] is True assert stats["max_gap"] == 3300.0 @pytest.mark.asyncio async def test_gap_threshold_not_exceeded(self, dicom_mcp_module, tmp_path): """Test gap threshold warning is not triggered for small gaps.""" d = tmp_path / "molli" tis = [100.0, 200.0, 350.0] for i, ti in enumerate(tis, 1): self._make_molli_file(d, f"img{i:02d}.dcm", i, inversion_time=ti) result = await dicom_mcp_module.dicom_analyze_ti( directory=str(d), response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) stats = data["series"][0]["statistics"] assert stats["gap_threshold_exceeded"] is False @pytest.mark.asyncio async def test_custom_gap_threshold(self, dicom_mcp_module, tmp_path): """Test custom gap_threshold parameter.""" d = tmp_path / "molli" tis = [100.0, 200.0, 500.0] # gap of 300 for i, ti in enumerate(tis, 1): self._make_molli_file(d, f"img{i:02d}.dcm", i, inversion_time=ti) result = await dicom_mcp_module.dicom_analyze_ti( directory=str(d), gap_threshold=200.0, response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) stats = data["series"][0]["statistics"] assert stats["gap_threshold_exceeded"] is True @pytest.mark.asyncio async def test_no_t1_mapping_files(self, dicom_mcp_module, tmp_path): """Test with no T1 mapping sequences in directory.""" d = tmp_path / "not_molli" ds = _make_base_dataset( Modality="MR", Manufacturer="SIEMENS", SeriesDescription="LMS IDEAL", ScanningSequence="GR", SequenceVariant="SP", ) _save_dicom(ds, d / "ideal.dcm") result = await dicom_mcp_module.dicom_analyze_ti( directory=str(d), ) assert "No T1 mapping" in result @pytest.mark.asyncio async def test_markdown_output(self, dicom_mcp_module, tmp_path): """Test markdown output format.""" d = tmp_path / "molli" self._make_molli_file(d, "img01.dcm", 1, inversion_time=100.0) self._make_molli_file(d, "img02.dcm", 2, inversion_time=200.0) result = await dicom_mcp_module.dicom_analyze_ti( directory=str(d), response_format=dicom_mcp_module.ResponseFormat.MARKDOWN, ) assert "# Inversion Time Analysis" in result assert "LMS MOLLI" in result assert "100.0" in result @pytest.mark.asyncio async def test_multiple_series(self, dicom_mcp_module, tmp_path): """Test with multiple MOLLI series in one directory.""" d = tmp_path / "molli" self._make_molli_file( d, "s601_01.dcm", 1, inversion_time=100.0, series_num=601, series_desc="LMS MOLLI", ) self._make_molli_file( d, "s801_01.dcm", 1, inversion_time=100.0, series_num=801, series_desc="LMS MOLLI simulated", ) result = await dicom_mcp_module.dicom_analyze_ti( directory=str(d), response_format=dicom_mcp_module.ResponseFormat.JSON, ) data = json.loads(result) assert data["series_count"] == 2 @pytest.mark.asyncio async def test_directory_not_found(self, dicom_mcp_module): """Test error for non-existent directory.""" result = await dicom_mcp_module.dicom_analyze_ti( directory="/nonexistent/dir", ) assert "Error" in result if __name__ == "__main__": pytest.main([__file__, "-v", "--tb=short"])