diff --git a/Dockerfile b/Dockerfile index b3bfd108..5ada87ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,7 @@ RUN uv sync --frozen --no-dev # Copy source code COPY src/ ./src/ COPY scripts/ ./scripts/ +COPY sample_images/ ./sample_images/ # Set environment variables with defaults ENV PACS_AET=SCREENING_PACS \ diff --git a/compose.yml b/compose.yml index 425ed3da..0b9bf655 100644 --- a/compose.yml +++ b/compose.yml @@ -92,6 +92,29 @@ services: pacs: condition: service_healthy restart: unless-stopped + network_mode: host + + modality_emulator: + build: + context: . + dockerfile: Dockerfile + container_name: modality-emulator + command: ["uv", "run", "python", "-m", "src.modality_emulator"] + volumes: + - pacs-storage:/var/lib/pacs/storage + - pacs-db:/var/lib/pacs + environment: + - PACS_DB_PATH=/var/lib/pacs/pacs.db + - PACS_STORAGE_PATH=/var/lib/pacs/storage + - MWL_DB_PATH=/var/lib/pacs/worklist.db + - LOG_LEVEL=INFO + depends_on: + mwl: + condition: service_healthy + pacs: + condition: service_healthy + restart: unless-stopped + network_mode: host volumes: pacs-storage: diff --git a/docs/adr/ADR-006_Modality_emulator.md b/docs/adr/ADR-006_Modality_emulator.md new file mode 100644 index 00000000..e99cbc2a --- /dev/null +++ b/docs/adr/ADR-006_Modality_emulator.md @@ -0,0 +1,36 @@ +# ADR-006: Use a modality emulator for non-production environments + +Date: 2026-06-04 + +Status: Accepted + +## Context + +The Manage Breast Screening service sends worklist items corresponding to screening appointments as the appointment starts. These are received by the gateway via Azure Relay and processed to trigger DICOM API uploads of the screening study data. The worklist items are queried via DICOM C-FIND requests to the gateway MWL server and stored on the gateway PACS server using the DICOM C-STORE protocol. + +In production this query is performed by the modality, which is a physical machine in the hospital network that runs software to query the MWL and display results to radiographers. The modality then sends the resulting study DICOM files to the Gateway PACS server using C-STORE. The PACS server then compresses the image data in the DICOM files and uploads it to the DICOM API hosted on Manage. + +In non-production environments, there is no modality available to perform this query. This means that testing the full flow of worklist items through the gateway and into the DICOM API is difficult without deploying to production or using complex stubs/mocks. + +## Decision + +To facilitate end-to-end testing of the worklist flow in non-production environments, we will implement a simple modality emulator. This will be a lightweight service that simulates the behavior of a real modality by performing queries against the MWL server and storing the emulated study data on the Gateway PACS server, mimicking the behavior of a real modality. + +The emulator runs on the same host as the MWL and PACS servers in non-production environments. It runs as a Python process that makes a C-FIND request at a configurable polling interval, checking for new worklist items. When it finds a new item, it creates a DICOM dataset from the worklist details, includes some sample image data and sends them to the PACS server via C-STORE. + +The sample image data corresponds to the 4 main laterality and view position combinations (LCC, LMLO, RCC, RMLO) to allow testing of the laterality and view position logic in the gateway images screens as the DICOM files are uploaded to Manage. + +## Consequences + +We no longer have to use a third party emulator or do any UI work to test the happy path of worklist items flowing through the gateway and into the DICOM API in non-production environments. This also allows us to make more comprehensive end-to-end tests that cover the full flow, including the C-FIND and C-STORE interactions with the MWL and PACS servers. + +### Positive Consequences + +- **End-to-end testing:** Enables testing of the full worklist flow in non-production environments without needing a physical modality or complex stubs/mocks. +- **Simplified test setup:** Developers can run the emulator locally or in test environments to simulate modality behavior without additional infrastructure. +- **Improved test coverage:** Allows for more comprehensive tests that cover the interactions with the MWL and PACS servers, as well as the gateway's processing logic for worklist items. +- **Configurable behavior:** The emulator can be configured to simulate different scenarios, such as varying polling intervals or different worklist item details, to test edge cases and error handling in the gateway. + +### Negative Consequences + +- The emulator and sample images are packaged with every release. This increases the size of the release artifact and may have implications for storage and distribution. diff --git a/sample_images/LCC.jpg b/sample_images/LCC.jpg new file mode 100644 index 00000000..bd91c0c3 Binary files /dev/null and b/sample_images/LCC.jpg differ diff --git a/sample_images/LMLO.jpg b/sample_images/LMLO.jpg new file mode 100644 index 00000000..f6675fee Binary files /dev/null and b/sample_images/LMLO.jpg differ diff --git a/scripts/bash/deploy_arc_ring.sh b/scripts/bash/deploy_arc_ring.sh index 13f46e37..cad143fc 100755 --- a/scripts/bash/deploy_arc_ring.sh +++ b/scripts/bash/deploy_arc_ring.sh @@ -82,7 +82,8 @@ PACS_AET=SCREENING_PACS PACS_PORT=4244 PACS_STORAGE_PATH=${BASE_PATH}/data/storage PACS_DB_PATH=${BASE_PATH}/data/pacs.db -LOG_LEVEL=INFO" +LOG_LEVEL=INFO +SAMPLE_IMAGES_PATH=${BASE_PATH}/current/sample_images" # Cross-platform base64 encoding (works on macOS and Linux) ENV_CONTENT_B64=$(printf '%s' "$ENV_CONTENT" | base64 | tr -d '\n') @@ -105,6 +106,7 @@ LOG_LEVEL=INFO" --arg pyver "$PYTHON_VERSION" \ --arg envb64 "$ENV_CONTENT_B64" \ --arg token "$GITHUB_TOKEN" \ + --arg env "$ENVIRONMENT" \ '{ location: $loc, properties: { @@ -113,7 +115,8 @@ LOG_LEVEL=INFO" { name: "ReleaseTag", value: $tag }, { name: "PythonVersion", value: $pyver }, { name: "EnvContentB64", value: $envb64 }, - { name: "GitHubToken", value: $token } + { name: "GitHubToken", value: $token }, + { name: "Environment", value: $env } ], runAsSystem: true, timeoutInSeconds: 1800 diff --git a/scripts/bash/package_release.sh b/scripts/bash/package_release.sh index 3318d61b..215f2239 100755 --- a/scripts/bash/package_release.sh +++ b/scripts/bash/package_release.sh @@ -61,7 +61,7 @@ echo "" # ── Validate required files ─────────────────────────────────────────────────── -REQUIRED_FILES=("src/" "scripts/python/" "pyproject.toml" "uv.lock" "README.md") +REQUIRED_FILES=("src/" "sample_images/" "scripts/python/" "pyproject.toml" "uv.lock" "README.md" "LICENCE.md") for item in "${REQUIRED_FILES[@]}"; do if [[ ! -e "${REPO_ROOT}/${item}" ]]; then diff --git a/scripts/powershell/deploy.ps1 b/scripts/powershell/deploy.ps1 index a880ae48..45d22327 100644 --- a/scripts/powershell/deploy.ps1 +++ b/scripts/powershell/deploy.ps1 @@ -43,7 +43,10 @@ param( [string]$GitHubToken, [Parameter()] - [string]$EnvContentB64 = "" + [string]$EnvContentB64 = "", + + [Parameter()] + [string]$Environment = "prod" ) Set-StrictMode -Version Latest @@ -337,6 +340,9 @@ $services = @( @{ Name = "Gateway-MWL"; Script = "mwl_main.py" }, @{ Name = "Gateway-Upload"; Script = "upload_main.py" } ) +if (($Environment -ne "prod") -and ($Environment -ne "preprod")) { + $services += @{ Name = "Gateway-Emulator"; Script = "modality_emulator.py" } +} # -- Extraction --------------------------------------------------------------- diff --git a/scripts/powershell/rollback.ps1 b/scripts/powershell/rollback.ps1 index 6d352c54..8af9e3a3 100644 --- a/scripts/powershell/rollback.ps1 +++ b/scripts/powershell/rollback.ps1 @@ -61,7 +61,8 @@ $services = @( @{ Name = "Gateway-Relay"; Script = "relay_listener.py" }, @{ Name = "Gateway-PACS"; Script = "pacs_main.py" }, @{ Name = "Gateway-MWL"; Script = "mwl_main.py" }, - @{ Name = "Gateway-Upload"; Script = "upload_main.py" } + @{ Name = "Gateway-Upload"; Script = "upload_main.py" }, + @{ Name = "Gateway-Emulator"; Script = "modality_emulator.py" } ) # -- Resolve current version -------------------------------------------------- diff --git a/src/modality_emulator.py b/src/modality_emulator.py new file mode 100644 index 00000000..bc5f709e --- /dev/null +++ b/src/modality_emulator.py @@ -0,0 +1,215 @@ +import datetime +import logging +import os +import time + +import numpy as np +from PIL import Image +from pydicom.dataset import Dataset, FileMetaDataset +from pydicom.uid import ExplicitVRLittleEndian, generate_uid +from pynetdicom import AE +from pynetdicom.sop_class import ( + DigitalMammographyXRayImageStorageForPresentation, # type: ignore + ModalityWorklistInformationFind, # type: ignore +) + +from environment import Environment +from services.dicom import PENDING, PENDING_WARNING, SUCCESS +from services.mwl import MWLStatus +from services.storage import MWLStorage + +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO").upper(), + format=os.getenv("LOG_FORMAT", "%(asctime)s - %(name)s - %(levelname)s - %(message)s"), +) + +logger = logging.getLogger(__name__) + + +DICOM_LATERALITIES = ["L", "R"] +DICOM_VIEWS = ["CC", "MLO"] +EMULATED_PROCEDURE_DURATION_SECONDS = int(os.getenv("EMULATED_PROCEDURE_DURATION_SECONDS", "5")) +MODALITY = "MG" +MWL_AET = os.getenv("MWL_AET", "SCREENING_MWL") +MWL_DB_PATH = os.getenv("MWL_DB_PATH", "/var/lib/pacs/worklist.db") +MWL_HOST = os.getenv("MWL_HOST", "localhost") +MWL_PORT = int(os.getenv("MWL_PORT", "4243")) +PACS_AET = os.getenv("PACS_AET", "SCREENING_PACS") +PACS_DB_PATH = os.getenv("PACS_DB_PATH", "/var/lib/pacs/pacs.db") +PACS_HOST = os.getenv("PACS_HOST", "localhost") +PACS_PORT = int(os.getenv("PACS_PORT", "4244")) +SAMPLE_IMAGES_PATH = os.getenv("SAMPLE_IMAGES_PATH", "sample_images") + + +class DicomExample: + def __init__( + self, dataset: Dataset | None, laterality: str, view: str, study_instance_uid: str, series_number: int + ): + self.dataset = dataset + self.laterality = laterality + self.view = view + self.study_instance_uid = study_instance_uid + self.series_number = series_number + self.data = self.generate_dicom() + + def generate_dicom(self) -> Dataset: + ds = Dataset() + if not self.dataset: + logger.error("No dataset provided for DICOM generation") + return ds + + img_path = f"{SAMPLE_IMAGES_PATH}/L{self.view}.jpg" + img = Image.open(img_path).convert("L") + if self.laterality == "R": + img = img.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + columns, rows = img.size + pixel_array = np.array(img, dtype=np.uint8) + pixel_bytes = pixel_array.tobytes() + if len(pixel_bytes) % 2 != 0: + pixel_bytes += b"\x00" + + file_meta = FileMetaDataset() + file_meta.MediaStorageSOPClassUID = DigitalMammographyXRayImageStorageForPresentation + file_meta.MediaStorageSOPInstanceUID = generate_uid() + file_meta.ImplementationClassUID = generate_uid() + file_meta.TransferSyntaxUID = ExplicitVRLittleEndian + + ds.SOPClassUID = file_meta.MediaStorageSOPClassUID + ds.SOPInstanceUID = file_meta.MediaStorageSOPInstanceUID + ds.SamplesPerPixel = 1 + ds.PhotometricInterpretation = "MONOCHROME2" + ds.Rows = rows + ds.Columns = columns + ds.BitsAllocated = 8 + ds.BitsStored = 8 + ds.HighBit = 7 + ds.PixelRepresentation = 0 + ds.PixelData = pixel_bytes + ds.ImageLaterality = self.laterality + ds.ViewPosition = self.view + + ds.AccessionNumber = self.dataset.AccessionNumber + ds.PatientID = self.dataset.PatientID + ds.PatientName = self.dataset.PatientName + ds.PatientBirthDate = self.dataset.PatientBirthDate + ds.PatientSex = self.dataset.PatientSex + scheduled_step = self.dataset.ScheduledProcedureStepSequence[0] + ds.StudyDate = scheduled_step.ScheduledProcedureStepStartDate + ds.StudyTime = scheduled_step.ScheduledProcedureStepStartTime + ds.StudyInstanceUID = self.study_instance_uid + ds.StudyID = f"STUDY{self.study_instance_uid[-8:]}" + ds.SeriesInstanceUID = generate_uid() + ds.SeriesNumber = self.series_number + ds.Modality = MODALITY + ds.InstanceNumber = 1 + ds.file_meta = file_meta + + logger.debug(f"Generated DICOM for worklist item {self.dataset.AccessionNumber} - {self.laterality}{self.view}") + logger.debug(f"{ds}") + + return ds + + +class ModalityEmulator: + def __init__(self, mwl_storage: MWLStorage): + self.mwl_storage = mwl_storage + self.processed_items = set() + + def process_worklist_items(self, ae: AE): + """ + Queries the MWL for items scheduled for today and sends generated DICOM files to the PACS server for each item. + """ + mwl_assoc = ae.associate(MWL_HOST, MWL_PORT, ae_title=MWL_AET) + pacs_assoc = ae.associate(PACS_HOST, PACS_PORT, ae_title=PACS_AET) + + if mwl_assoc.is_established and pacs_assoc.is_established: + logger.info(f"Connected to MWL server {MWL_HOST}:{MWL_PORT} ({MWL_AET})") + logger.info(f"Connected to PACS server {PACS_HOST}:{PACS_PORT} ({PACS_AET})") + + logger.info("Querying MWL for scheduled items...") + responses = mwl_assoc.send_c_find(self.c_find_dataset, query_model=ModalityWorklistInformationFind) + for status, ds in responses: + status_code = getattr(status, "Status", SUCCESS) + + if status_code in (PENDING, PENDING_WARNING): + accession_number = getattr(ds, "AccessionNumber", "UNKNOWN") + + if accession_number in self.processed_items: + logger.info(f"Skipping already processed worklist item {accession_number}") + continue + + self.processed_items.add(accession_number) + study_instance_uid = generate_uid() + series_number = 1 + for laterality in DICOM_LATERALITIES: + for view in DICOM_VIEWS: + logger.info( + f"Processing worklist item {accession_number} - generating DICOM for {laterality}{view}" + ) + dicom_example = DicomExample(ds, laterality, view, study_instance_uid, series_number) + dataset = dicom_example.data + + if getattr(dataset, "SOPInstanceUID", None) is None: + logger.error( + f"Skipping DICOM generation for {laterality}{view} of worklist item {accession_number}" + ) + continue + + pacs_assoc.send_c_store(dataset) + logger.info( + f"Sent DICOM for {laterality}{view} of worklist item {accession_number}. Series# {series_number}" + ) + series_number += 1 + + time.sleep(1) # Allow C-STORE operations to complete before updating status + + self.mwl_storage.update_status(accession_number, MWLStatus.COMPLETED.value) + logger.info(f"Completed processing for worklist item {accession_number}") + elif status_code == SUCCESS: + logger.info("C-FIND query completed successfully") + else: + logger.error(f"C-FIND query failed with status: 0x{status_code:04X}") + + else: + logger.error("Failed to make MWL and PACS associations") + + mwl_assoc.release() + pacs_assoc.release() + + @property + def c_find_dataset(self) -> Dataset: + date_today = datetime.date.today() + ds = Dataset() + sps_dataset = Dataset() + sps_dataset.Modality = MODALITY + sps_dataset.ScheduledProcedureStepStartDate = date_today.strftime("%Y%m%d") + sps_dataset.ScheduledProcedureStepStartTime = "000000-" + ds.ScheduledProcedureStepSequence = [sps_dataset] + return ds + + +def main(): + if Environment().production: + raise RuntimeError("Modality Emulator should not be run in production environment") + + logger.info("Modality Emulator Starting...") + mwl_storage = MWLStorage(db_path=MWL_DB_PATH) + emulator = ModalityEmulator(mwl_storage) + ae = AE(ae_title="ModalityEmulator") + ae.add_requested_context(DigitalMammographyXRayImageStorageForPresentation) + ae.add_requested_context(ModalityWorklistInformationFind) + + while True: + try: + time.sleep(EMULATED_PROCEDURE_DURATION_SECONDS) + emulator.process_worklist_items(ae) + except KeyboardInterrupt: + logger.warning("\n Modality Emulator shutting down...") + break + except Exception: + logger.exception("Error in Modality Emulator") + time.sleep(5) # Wait before retrying to avoid tight error loop + + +if __name__ == "__main__": + main() diff --git a/src/services/dicom/__init__.py b/src/services/dicom/__init__.py index 98080670..727bd750 100644 --- a/src/services/dicom/__init__.py +++ b/src/services/dicom/__init__.py @@ -7,6 +7,7 @@ SUCCESS = 0x0000 FAILURE = 0xC000 PENDING = 0xFF00 +PENDING_WARNING = 0xFF01 INVALID_ATTRIBUTE = 0x0106 DUPLICATE_SOP_INSTANCE = 0x0111 UNKNOWN_SOP_INSTANCE = 0x0112 diff --git a/src/services/storage.py b/src/services/storage.py index 76d8f072..9e4b2267 100644 --- a/src/services/storage.py +++ b/src/services/storage.py @@ -367,7 +367,7 @@ def find_worklist_items( "source_message_id, study_description, study_instance_uid, status, mpps_instance_uid " "FROM worklist_items" ) - where_clauses = [] + where_clauses = ["status NOT IN ('COMPLETED', 'DISCONTINUED')"] params = [] if accession_number: diff --git a/tests/services/test_storage.py b/tests/services/test_storage.py index 1f22c22b..adde3a6b 100644 --- a/tests/services/test_storage.py +++ b/tests/services/test_storage.py @@ -229,6 +229,34 @@ def test_find_worklist_items_patient_name_wildcard_conversion(self, mwl_storage, assert len(results) == 1 assert results[0].patient_name == "SMITH^JANE" + def test_find_worklist_items_with_filters_completed_items(self, mwl_storage, result): + item = self._insert_item(mwl_storage, result) + mwl_storage.update_status(item.accession_number, "IN PROGRESS") + mwl_storage.update_status(item.accession_number, "COMPLETED") + + results = mwl_storage.find_worklist_items( + accession_number="ACC123456", + modality="MG", + scheduled_date="20240101", + patient_id="999123456", + ) + + assert len(results) == 0 + + def test_find_worklist_items_with_filters_discontinued_items(self, mwl_storage, result): + item = self._insert_item(mwl_storage, result) + mwl_storage.update_status(item.accession_number, "IN PROGRESS") + mwl_storage.update_status(item.accession_number, "DISCONTINUED") + + results = mwl_storage.find_worklist_items( + accession_number="ACC123456", + modality="MG", + scheduled_date="20240101", + patient_id="999123456", + ) + + assert len(results) == 0 + def test_get_worklist_item(self, mwl_storage, result): item = self._insert_item(mwl_storage, result) diff --git a/tests/test_modality_emulator.py b/tests/test_modality_emulator.py new file mode 100644 index 00000000..7cfee2b9 --- /dev/null +++ b/tests/test_modality_emulator.py @@ -0,0 +1,213 @@ +from unittest.mock import MagicMock, patch + +import pytest +from pydicom.dataset import Dataset +from pydicom.uid import DigitalMammographyXRayImageStorageForPresentation, ExplicitVRLittleEndian, generate_uid + +from modality_emulator import ( + DICOM_LATERALITIES, + DICOM_VIEWS, + DicomExample, + ModalityEmulator, + main, +) + + +class TestDicomExample: + @patch("modality_emulator.Image.open") + @patch("modality_emulator.generate_uid") + def test_generate_dicom_creates_valid_dataset( + self, + mock_generate_uid, + mock_image_open, + ): + study_instance_uid = generate_uid() + sop_instance_uid = generate_uid() + implementation_class_uid = generate_uid() + series_instance_uid = generate_uid() + + mock_generate_uid.side_effect = [ + sop_instance_uid, + implementation_class_uid, + series_instance_uid, + ] + + mock_image = MagicMock() + mock_image.convert.return_value = mock_image + mock_image.size = (100, 200) + + mock_image_open.return_value = mock_image + + ds = Dataset() + ds.AccessionNumber = "ACC123" + ds.PatientID = "PAT001" + ds.PatientName = "Jane^Doe" + ds.PatientBirthDate = "19800101" + ds.PatientSex = "F" + sps = Dataset() + sps.ScheduledProcedureStepStartDate = "20260514" + sps.ScheduledProcedureStepStartTime = "090000" + ds.ScheduledProcedureStepSequence = [sps] + + with patch("modality_emulator.np.array") as mock_np_array: + mock_pixel_array = MagicMock() + mock_pixel_array.tobytes.return_value = b"\x01\x02" + mock_np_array.return_value = mock_pixel_array + + dicom = DicomExample( + dataset=ds, + laterality="L", + view="CC", + study_instance_uid=study_instance_uid, + series_number=1, + ) + + assert isinstance(dicom.data, Dataset) + assert dicom.data.PatientID == "PAT001" + assert dicom.data.PatientName == "Jane^Doe" + assert dicom.data.ImageLaterality == "L" + assert dicom.data.ViewPosition == "CC" + assert dicom.data.Rows == 200 + assert dicom.data.Columns == 100 + assert dicom.data.StudyInstanceUID == study_instance_uid + assert dicom.data.SOPInstanceUID == sop_instance_uid + assert dicom.data.SeriesInstanceUID == series_instance_uid + assert dicom.data.file_meta.TransferSyntaxUID == ExplicitVRLittleEndian + assert dicom.data.file_meta.ImplementationClassUID == implementation_class_uid + assert dicom.data.file_meta.MediaStorageSOPClassUID == DigitalMammographyXRayImageStorageForPresentation + assert dicom.data.file_meta.MediaStorageSOPInstanceUID == sop_instance_uid + assert dicom.data.StudyDate == "20260514" + assert dicom.data.StudyTime == "090000" + + +class TestModalityEmulator: + @pytest.fixture + def pending_status(self): + status = Dataset() + status.Status = 0xFF00 + return status + + @pytest.fixture + def success_status(self): + status = Dataset() + status.Status = 0x0000 + return status + + @patch("modality_emulator.time.sleep") + @patch("modality_emulator.generate_uid") + @patch("modality_emulator.DicomExample") + def test_process_worklist_items_sends_all_dicoms( + self, + mock_dicom_example, + mock_generate_uid, + _, + pending_status, + success_status, + ): + study_instance_uid = generate_uid() + mock_generate_uid.return_value = study_instance_uid + + mwl_storage = MagicMock() + + emulator = ModalityEmulator(mwl_storage) + + ds = Dataset() + ds.SOPInstanceUID = generate_uid() + ds.AccessionNumber = "ACC123" + ds.PatientID = "PAT001" + ds.PatientName = "Jane^Doe" + ds.PatientSex = "F" + sps = Dataset() + sps.ScheduledProcedureStepStartDate = "20260514" + sps.ScheduledProcedureStepStartTime = "090000" + ds.ScheduledProcedureStepSequence = [sps] + + mock_dicom_example.return_value.data = ds + + mwl_assoc = MagicMock() + mwl_assoc.is_established = True + mwl_assoc.send_c_find.return_value = [(pending_status, ds), (success_status, None)] + + pacs_assoc = MagicMock() + pacs_assoc.is_established = True + + ae = MagicMock() + ae.associate.side_effect = [mwl_assoc, pacs_assoc] + + emulator.process_worklist_items(ae) + + mock_dicom_example.assert_any_call(ds, "L", "CC", study_instance_uid, 1) + mock_dicom_example.assert_any_call(ds, "L", "MLO", study_instance_uid, 2) + mock_dicom_example.assert_any_call(ds, "R", "CC", study_instance_uid, 3) + mock_dicom_example.assert_any_call(ds, "R", "MLO", study_instance_uid, 4) + + expected_send_count = len(DICOM_LATERALITIES) * len(DICOM_VIEWS) + + assert mwl_assoc.send_c_find.call_count == 1 + assert pacs_assoc.send_c_store.call_count == expected_send_count + + mwl_storage.update_status.assert_called_once_with( + "ACC123", + "COMPLETED", + ) + + mwl_assoc.release.assert_called_once() + pacs_assoc.release.assert_called_once() + + @patch("modality_emulator.time.sleep") + def test_process_worklist_items_returns_when_no_items( + self, + mock_sleep, + success_status, + ): + mwl_storage = MagicMock() + emulator = ModalityEmulator(mwl_storage) + ae = MagicMock() + + mwl_assoc = MagicMock() + mwl_assoc.is_established = True + mwl_assoc.send_c_find.return_value = [(success_status, None)] + + pacs_assoc = MagicMock() + pacs_assoc.is_established = True + + ae = MagicMock() + ae.associate.side_effect = [mwl_assoc, pacs_assoc] + + emulator.process_worklist_items(ae) + + mock_sleep.assert_not_called() + pacs_assoc.send_c_store.assert_not_called() + mwl_storage.update_status.assert_not_called() + mwl_assoc.release.assert_called_once() + pacs_assoc.release.assert_called_once() + + @patch("modality_emulator.time.sleep") + def test_process_worklist_items_handles_failed_association( + self, + _, + ): + mwl_storage = MagicMock() + emulator = ModalityEmulator(mwl_storage) + + mwl_assoc = MagicMock() + mwl_assoc.is_established = True + + pacs_assoc = MagicMock() + pacs_assoc.is_established = False + + ae = MagicMock() + ae.associate.side_effect = [mwl_assoc, pacs_assoc] + + emulator.process_worklist_items(ae) + + mwl_assoc.send_c_find.assert_not_called() + pacs_assoc.send_c_store.assert_not_called() + mwl_storage.update_status.assert_not_called() + mwl_assoc.release.assert_called_once() + pacs_assoc.release.assert_called_once() + + @patch("modality_emulator.Environment", production=True) + def test_main_raises_in_production(self, _): + with pytest.raises(RuntimeError, match="Modality Emulator should not be run in production environment"): + main()