|
| 1 | +"""Shared fixtures and pytest configuration for pyaslreport tests. |
| 2 | +
|
| 3 | +Fixtures provided: |
| 4 | + minimal_nifti_path: Path to a tiny on-disk NIfTI file (auto-deleted after test). |
| 5 | + make_context: Factory for building ProcessingContext with sensible defaults. |
| 6 | + make_processor: Factory for building ASLProcessor without triggering validation. |
| 7 | + examples_dir: Path to the integration examples directory (pytest-configurable). |
| 8 | + minimal_asl_json: In-memory dict representing a minimal valid ASL JSON. |
| 9 | +""" |
| 10 | + |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +from pathlib import Path |
| 14 | +from typing import Any, Callable |
| 15 | + |
| 16 | +import nibabel as nib |
| 17 | +import numpy as np |
| 18 | +import pytest |
| 19 | + |
| 20 | +from pyaslreport.modalities.asl.processor import ASLProcessor, ProcessingContext |
| 21 | + |
| 22 | + |
| 23 | +# --------------------------------------------------------------------------- |
| 24 | +# CLI option for integration test directory |
| 25 | +# --------------------------------------------------------------------------- |
| 26 | +def pytest_addoption(parser: pytest.Parser) -> None: |
| 27 | + """Register the --examples-dir CLI option for the integration runner. |
| 28 | +
|
| 29 | + Args: |
| 30 | + parser: The pytest CLI parser, supplied by pytest at collection time. |
| 31 | +
|
| 32 | + Notes: |
| 33 | + Local usage: pytest --examples-dir=/path/to/examples |
| 34 | + CI usage: omit the flag; defaults to the committed set in |
| 35 | + tests/integration/examples. |
| 36 | + """ |
| 37 | + parser.addoption( |
| 38 | + "--examples-dir", |
| 39 | + action="store", |
| 40 | + default=None, |
| 41 | + help="Path to integration examples dir; falls back to the committed CI set.", |
| 42 | + ) |
| 43 | + parser.addoption( |
| 44 | + "--update-expected", |
| 45 | + action="store_true", |
| 46 | + default=False, |
| 47 | + help="Regenerate each example's expected_output.json from current tool output.", |
| 48 | + ) |
| 49 | + |
| 50 | + |
| 51 | +# --------------------------------------------------------------------------- |
| 52 | +# File-based fixtures |
| 53 | +# --------------------------------------------------------------------------- |
| 54 | +@pytest.fixture |
| 55 | +def minimal_nifti_path(tmp_path: Path) -> Path: |
| 56 | + """Create a tiny valid NIfTI file at tmp_path/asl.nii.gz. |
| 57 | +
|
| 58 | + Args: |
| 59 | + tmp_path: Pytest-provided per-test temporary directory. |
| 60 | +
|
| 61 | + Returns: |
| 62 | + Path to the written NIfTI file with shape (4, 4, 20, 2). The third |
| 63 | + axis matches a sensible default for ProcessingContext.nifti_slice_number |
| 64 | + so most tests do not need a custom shape. |
| 65 | + """ |
| 66 | + data = np.zeros((4, 4, 20, 2), dtype=np.float32) |
| 67 | + img = nib.Nifti1Image(data, affine=np.eye(4)) |
| 68 | + |
| 69 | + path = tmp_path / "asl.nii.gz" |
| 70 | + nib.save(img, str(path)) |
| 71 | + |
| 72 | + return path |
| 73 | + |
| 74 | + |
| 75 | +@pytest.fixture |
| 76 | +def examples_dir(request: pytest.FixtureRequest) -> Path: |
| 77 | + """Return the path to the integration examples directory. |
| 78 | +
|
| 79 | + Args: |
| 80 | + request: Pytest fixture request, used to read the --examples-dir flag. |
| 81 | +
|
| 82 | + Returns: |
| 83 | + Resolved path to an existing examples directory. |
| 84 | +
|
| 85 | + Raises: |
| 86 | + pytest.skip.Exception: If the resolved path does not exist. Resolution |
| 87 | + order is (1) the --examples-dir CLI flag, then (2) the committed |
| 88 | + tests/integration/examples directory beside this conftest. |
| 89 | + """ |
| 90 | + cli_value = request.config.getoption("--examples-dir") |
| 91 | + |
| 92 | + if cli_value: |
| 93 | + path = Path(cli_value).expanduser().resolve() |
| 94 | + else: |
| 95 | + path = Path(__file__).parent / "integration" / "examples" |
| 96 | + |
| 97 | + if not path.is_dir(): |
| 98 | + pytest.skip(f"Examples directory not found: {path}") |
| 99 | + |
| 100 | + return path |
| 101 | + |
| 102 | + |
| 103 | +# --------------------------------------------------------------------------- |
| 104 | +# Factory fixtures |
| 105 | +# --------------------------------------------------------------------------- |
| 106 | +@pytest.fixture |
| 107 | +def make_context() -> Callable[..., ProcessingContext]: |
| 108 | + """Return a factory for building ProcessingContext with sensible defaults. |
| 109 | +
|
| 110 | + Returns: |
| 111 | + A callable that accepts keyword overrides and returns a fully-formed |
| 112 | + ProcessingContext. All ten required fields receive defaults; pass |
| 113 | + keyword arguments to override any of them. |
| 114 | +
|
| 115 | + Example: |
| 116 | + >>> def test_something(make_context): |
| 117 | + ... ctx = make_context(m0_type="Separate", errors=["something"]) |
| 118 | + ... assert ctx.m0_type == "Separate" |
| 119 | + """ |
| 120 | + |
| 121 | + def _make(**overrides: Any) -> ProcessingContext: |
| 122 | + defaults: dict[str, Any] = { |
| 123 | + "asl_json_data": [], |
| 124 | + "m0_prep_times_collection": [], |
| 125 | + "errors": [], |
| 126 | + "warnings": [], |
| 127 | + "all_absent": True, |
| 128 | + "bs_all_off": True, |
| 129 | + "m0_type": None, |
| 130 | + "global_pattern": None, |
| 131 | + "total_acquired_pairs": None, |
| 132 | + "nifti_slice_number": 20, |
| 133 | + } |
| 134 | + |
| 135 | + defaults.update(overrides) |
| 136 | + |
| 137 | + return ProcessingContext(**defaults) |
| 138 | + |
| 139 | + return _make |
| 140 | + |
| 141 | + |
| 142 | +@pytest.fixture |
| 143 | +def make_processor(minimal_nifti_path: Path) -> Callable[..., ASLProcessor]: |
| 144 | + """Return a factory for building ASLProcessor without input validation. |
| 145 | +
|
| 146 | + Args: |
| 147 | + minimal_nifti_path: Auto-injected fixture providing a real on-disk |
| 148 | + NIfTI file. The factory uses it as the default nifti_file unless |
| 149 | + the caller overrides it. |
| 150 | +
|
| 151 | + Returns: |
| 152 | + A callable that accepts keyword overrides and returns an ASLProcessor. |
| 153 | + Useful for testing private methods like _group_files in isolation, |
| 154 | + because BaseProcessor.__init__ stores self.data without validating. |
| 155 | +
|
| 156 | + Example: |
| 157 | + >>> def test_grouping(make_processor): |
| 158 | + ... proc = make_processor(files=["/path/to/asl.json"]) |
| 159 | + ... groups = proc._group_files("nifti") |
| 160 | + """ |
| 161 | + |
| 162 | + def _make(**overrides: Any) -> ASLProcessor: |
| 163 | + defaults: dict[str, Any] = { |
| 164 | + "modality": "asl", |
| 165 | + "files": [], |
| 166 | + "dcm_files": [], |
| 167 | + "nifti_file": str(minimal_nifti_path), |
| 168 | + } |
| 169 | + |
| 170 | + defaults.update(overrides) |
| 171 | + |
| 172 | + return ASLProcessor(defaults) |
| 173 | + |
| 174 | + return _make |
| 175 | + |
| 176 | + |
| 177 | +# --------------------------------------------------------------------------- |
| 178 | +# Data fixtures (in-memory JSON dicts) |
| 179 | +# --------------------------------------------------------------------------- |
| 180 | +@pytest.fixture |
| 181 | +def minimal_asl_json() -> dict[str, Any]: |
| 182 | + """Return a minimal ASL JSON with all major-error fields valid. |
| 183 | +
|
| 184 | + Returns: |
| 185 | + Dictionary intended as a starting point for normalization and |
| 186 | + validation tests. Spread it into a new dict and override individual |
| 187 | + fields to introduce specific missing or invalid values. |
| 188 | + """ |
| 189 | + return { |
| 190 | + "ArterialSpinLabelingType": "PCASL", |
| 191 | + "MRAcquisitionType": "3D", |
| 192 | + "PulseSequenceType": "GRASE", |
| 193 | + "M0Type": "Separate", |
| 194 | + "BackgroundSuppression": False, |
| 195 | + "PostLabelingDelay": 1.8, |
| 196 | + "LabelingDuration": 1.8, |
| 197 | + "EchoTime": 0.012, |
| 198 | + "RepetitionTimePreparation": 4.0, |
| 199 | + "FlipAngle": 90, |
| 200 | + "MagneticFieldStrength": 3, |
| 201 | + "Manufacturer": "Siemens", |
| 202 | + "ManufacturersModelName": "TrioTim", |
| 203 | + "AcquisitionVoxelSize": [3, 3, 4], |
| 204 | + } |
| 205 | + |
| 206 | + |
| 207 | +# --------------------------------------------------------------------------- |
| 208 | +# Integration example discovery (parametrizes test_integration over cases) |
| 209 | +# --------------------------------------------------------------------------- |
| 210 | +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: |
| 211 | + """Parametrize integration tests over discovered example case folders. |
| 212 | +
|
| 213 | + Reads --examples-dir (registered above): when absent, falls back to the |
| 214 | + committed set at tests/integration/examples. An empty or missing dir yields |
| 215 | + zero cases, which pytest reports as a single skip rather than an error. |
| 216 | +
|
| 217 | + Args: |
| 218 | + metafunc: The pytest metafunc for the test being collected. |
| 219 | + """ |
| 220 | + if "integration_case" in metafunc.fixturenames: |
| 221 | + from tests.integration.runner import discover_examples |
| 222 | + |
| 223 | + cli = metafunc.config.getoption("--examples-dir") |
| 224 | + base = ( |
| 225 | + Path(cli).expanduser().resolve() |
| 226 | + if cli |
| 227 | + else Path(__file__).parent / "integration" / "examples" |
| 228 | + ) |
| 229 | + cases = discover_examples(base) |
| 230 | + metafunc.parametrize("integration_case", cases, ids=[p.name for p in cases]) |
0 commit comments