stroke-deepisles-demo / tests /test_pipeline.py
VibecoderMcSwaggins's picture
fix(pipeline): copy input files to results_dir and add UI cleanup
4a455a4
"""Tests for pipeline orchestration."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from stroke_deepisles_demo.core.types import CaseFiles
from stroke_deepisles_demo.pipeline import (
PipelineResult,
get_pipeline_summary,
run_pipeline_on_batch,
run_pipeline_on_case,
)
if TYPE_CHECKING:
from collections.abc import Iterator
class TestRunPipelineOnCase:
"""Tests for run_pipeline_on_case."""
@pytest.fixture
def mock_dependencies(self, temp_dir: Path) -> Iterator[dict[str, MagicMock]]:
"""Mock all external dependencies."""
with (
patch("stroke_deepisles_demo.pipeline.load_isles_dataset") as mock_load,
patch("stroke_deepisles_demo.pipeline.stage_case_for_deepisles") as mock_stage,
patch("stroke_deepisles_demo.pipeline.run_deepisles_on_folder") as mock_inference,
patch("stroke_deepisles_demo.metrics.compute_dice") as mock_dice,
):
# Configure mocks
mock_dataset = MagicMock()
# Create real temp files (pipeline copies these to results_dir)
dwi_file = temp_dir / "dwi_mock.nii.gz"
dwi_file.write_bytes(b"fake dwi nifti")
adc_file = temp_dir / "adc_mock.nii.gz"
adc_file.write_bytes(b"fake adc nifti")
gt_file = temp_dir / "gt_mock.nii.gz"
gt_file.write_bytes(b"fake gt nifti")
mock_dataset.get_case.return_value = CaseFiles(
dwi=dwi_file,
adc=adc_file,
ground_truth=gt_file,
# flair omitted
)
# Support context manager protocol: with load_isles_dataset() as dataset:
mock_load.return_value.__enter__ = MagicMock(return_value=mock_dataset)
mock_load.return_value.__exit__ = MagicMock(return_value=None)
mock_stage.return_value = MagicMock(
input_dir=temp_dir / "staged",
dwi_path=temp_dir / "staged" / "dwi.nii.gz",
adc_path=temp_dir / "staged" / "adc.nii.gz",
flair_path=None,
)
mock_inference.return_value = MagicMock(
prediction_path=temp_dir / "results" / "pred.nii.gz",
elapsed_seconds=10.5,
)
mock_dice.return_value = 0.85
yield {
"load": mock_load,
"dataset": mock_dataset,
"stage": mock_stage,
"inference": mock_inference,
"dice": mock_dice,
}
def test_returns_pipeline_result(
self, mock_dependencies: dict[str, MagicMock], temp_dir: Path
) -> None:
"""Returns PipelineResult with expected fields."""
_ = mock_dependencies # explicit usage
_ = temp_dir
result = run_pipeline_on_case("sub-001")
assert isinstance(result, PipelineResult)
assert result.case_id == "sub-001"
def test_loads_case_from_dataset(
self,
mock_dependencies: dict[str, MagicMock],
temp_dir: Path, # noqa: ARG002
) -> None:
"""Loads case using dataset."""
run_pipeline_on_case("sub-001")
mock_dependencies["dataset"].get_case.assert_called_once_with("sub-001")
def test_stages_files_for_deepisles(
self,
mock_dependencies: dict[str, MagicMock],
temp_dir: Path, # noqa: ARG002
) -> None:
"""Stages files with correct naming."""
run_pipeline_on_case("sub-001")
mock_dependencies["stage"].assert_called_once()
def test_runs_deepisles_inference(
self,
mock_dependencies: dict[str, MagicMock],
temp_dir: Path, # noqa: ARG002
) -> None:
"""Runs DeepISLES on staged directory."""
run_pipeline_on_case("sub-001", fast=True, gpu=False)
mock_dependencies["inference"].assert_called_once()
call_kwargs = mock_dependencies["inference"].call_args.kwargs
assert call_kwargs.get("fast") is True
assert call_kwargs.get("gpu") is False
def test_computes_dice_when_ground_truth_available(
self,
mock_dependencies: dict[str, MagicMock],
temp_dir: Path, # noqa: ARG002
) -> None:
"""Computes Dice score when ground truth is available."""
result = run_pipeline_on_case("sub-001", compute_dice=True)
mock_dependencies["dice"].assert_called_once()
assert result.dice_score == 0.85
def test_skips_dice_when_disabled(
self,
mock_dependencies: dict[str, MagicMock],
temp_dir: Path, # noqa: ARG002
) -> None:
"""Skips Dice computation when compute_dice=False."""
result = run_pipeline_on_case("sub-001", compute_dice=False)
mock_dependencies["dice"].assert_not_called()
assert result.dice_score is None
def test_handles_missing_ground_truth(
self,
mock_dependencies: dict[str, MagicMock],
temp_dir: Path,
) -> None:
"""Handles cases without ground truth gracefully."""
# Create real files for DWI/ADC (pipeline copies these)
dwi_file = temp_dir / "dwi_no_gt.nii.gz"
dwi_file.write_bytes(b"fake dwi")
adc_file = temp_dir / "adc_no_gt.nii.gz"
adc_file.write_bytes(b"fake adc")
mock_dependencies["dataset"].get_case.return_value = CaseFiles(
dwi=dwi_file,
adc=adc_file,
# ground_truth omitted
)
result = run_pipeline_on_case("sub-001", compute_dice=True)
assert result.dice_score is None
assert result.ground_truth is None
def test_accepts_integer_index(
self,
mock_dependencies: dict[str, MagicMock],
temp_dir: Path, # noqa: ARG002
) -> None:
"""Accepts integer index as case identifier."""
mock_dependencies["dataset"].list_case_ids.return_value = ["sub-001"]
result = run_pipeline_on_case(0)
assert result.case_id == "sub-001"
class TestGetPipelineSummary:
"""Tests for get_pipeline_summary."""
def test_computes_mean_dice(self) -> None:
"""Computes mean Dice from results."""
from types import SimpleNamespace
results = [
SimpleNamespace(dice_score=0.8, elapsed_seconds=10.0),
SimpleNamespace(dice_score=0.9, elapsed_seconds=12.0),
SimpleNamespace(dice_score=0.7, elapsed_seconds=8.0),
]
summary = get_pipeline_summary(results) # type: ignore
assert summary.mean_dice == pytest.approx(0.8, rel=0.01)
def test_handles_none_dice_scores(self) -> None:
"""Handles results with None Dice scores."""
from types import SimpleNamespace
results = [
SimpleNamespace(dice_score=0.8, elapsed_seconds=10.0),
SimpleNamespace(dice_score=None, elapsed_seconds=12.0),
SimpleNamespace(dice_score=0.7, elapsed_seconds=8.0),
]
summary = get_pipeline_summary(results) # type: ignore
# Mean of 0.8 and 0.7 only
assert summary.mean_dice == pytest.approx(0.75, rel=0.01)
def test_counts_successful_and_failed(self) -> None:
"""Counts successful and failed runs."""
from types import SimpleNamespace
# Assuming current implementation counts all as successful
results = [
SimpleNamespace(dice_score=0.8, elapsed_seconds=10.0),
SimpleNamespace(dice_score=None, elapsed_seconds=0.0),
]
summary = get_pipeline_summary(results) # type: ignore
assert summary.num_cases == 2
assert summary.num_successful == 2
assert summary.num_failed == 0
class TestRunPipelineOnBatch:
"""Tests for run_pipeline_on_batch."""
def test_runs_multiple_cases(self) -> None:
"""Runs pipeline on multiple cases sequentially."""
with patch("stroke_deepisles_demo.pipeline.run_pipeline_on_case") as mock_run:
mock_run.side_effect = [
PipelineResult(
case_id="sub-001",
input_files=MagicMock(),
results_dir=MagicMock(),
prediction_mask=MagicMock(),
ground_truth=None,
dice_score=0.8,
elapsed_seconds=10.0,
),
PipelineResult(
case_id="sub-002",
input_files=MagicMock(),
results_dir=MagicMock(),
prediction_mask=MagicMock(),
ground_truth=None,
dice_score=0.9,
elapsed_seconds=12.0,
),
]
results = run_pipeline_on_batch(["sub-001", "sub-002"], fast=True, gpu=False)
assert len(results) == 2
assert results[0].case_id == "sub-001"
assert results[1].case_id == "sub-002"
assert mock_run.call_count == 2
def test_passes_kwargs_to_each_call(self) -> None:
"""Passes kwargs to each run_pipeline_on_case call."""
with patch("stroke_deepisles_demo.pipeline.run_pipeline_on_case") as mock_run:
mock_run.return_value = PipelineResult(
case_id="sub-001",
input_files=MagicMock(),
results_dir=MagicMock(),
prediction_mask=MagicMock(),
ground_truth=None,
dice_score=0.8,
elapsed_seconds=10.0,
)
run_pipeline_on_batch(["sub-001"], fast=False, gpu=True, compute_dice=False)
call_kwargs = mock_run.call_args.kwargs
assert call_kwargs.get("fast") is False
assert call_kwargs.get("gpu") is True
assert call_kwargs.get("compute_dice") is False
REAL_DATA_PATH = Path("data/isles24")
@pytest.mark.integration
class TestPipelineIntegration:
"""Integration tests for full pipeline."""
@pytest.mark.slow
@pytest.mark.skipif(not REAL_DATA_PATH.exists(), reason="Real data not found in data/isles24")
def test_run_on_real_case(self, temp_dir: Path) -> None:
"""Run pipeline on actual ISLES24-MR-Lite case."""
# Requires: real ISLES24 data, Docker, DeepISLES image, GPU
# Run with: pytest -m "integration and slow"
from stroke_deepisles_demo.core.exceptions import DeepISLESError
from stroke_deepisles_demo.inference.docker import check_docker_available
if not check_docker_available():
pytest.skip("Docker not available")
try:
result = run_pipeline_on_case(
0, # First case
fast=True,
gpu=False,
compute_dice=True,
output_dir=temp_dir / "pipeline_test_output",
)
except DeepISLESError as e:
# DeepISLES requires nvidia-smi even with gpu=False for model loading
if "nvidia-smi" in str(e).lower():
pytest.skip("DeepISLES requires GPU (nvidia-smi not available)")
raise
assert result.prediction_mask.exists()
# Dice might be None if no ground truth, but ISLES24 has masks
# We asserted earlier that phase 1 data has masks.
if result.ground_truth:
assert result.dice_score is not None
assert 0 <= result.dice_score <= 1