Skip to content

Commit f441d73

Browse files
authored
Merge pull request #66 from vtorlima/main
unit test suite and CI
2 parents 4ade3c5 + 48df7c4 commit f441d73

13 files changed

Lines changed: 1627 additions & 12 deletions

File tree

‎.github/workflows/tests.yml‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
name: Tests
2+
3+
# Runs on every PR and on pushes to main.
4+
on:
5+
push:
6+
branches: [main]
7+
pull_request:
8+
workflow_dispatch:
9+
10+
jobs:
11+
unit-tests:
12+
name: Unit tests (Python ${{ matrix.python-version }})
13+
runs-on: ubuntu-latest
14+
strategy:
15+
fail-fast: false # run both Python versions even if one fails, so we see both
16+
matrix:
17+
# 3.11+ only: the package uses `match` and `int | float` unions (3.10+)
18+
# and requires-python is >=3.11. Older versions fail on import.
19+
python-version: ["3.11", "3.12"]
20+
21+
steps:
22+
- uses: actions/checkout@v4
23+
24+
- name: Set up Python ${{ matrix.python-version }}
25+
uses: actions/setup-python@v5
26+
with:
27+
python-version: ${{ matrix.python-version }}
28+
cache: "pip"
29+
cache-dependency-path: package/pyproject.toml
30+
31+
# The workflow lives at repo root, but the package is in ./package,
32+
# so every step below runs from there (matches local dev from package/).
33+
- name: Install package with test dependencies
34+
working-directory: ./package
35+
run: |
36+
python -m pip install --upgrade pip
37+
pip install -e ".[test]"
38+
39+
# --check / --check-only report only; they never edit. CI fails if code
40+
# was committed unformatted. Mirrors the local pre-commit checks.
41+
- name: Check formatting (Black)
42+
working-directory: ./package
43+
run: black --check tests/
44+
45+
- name: Check import order (isort)
46+
working-directory: ./package
47+
run: isort --check-only tests/
48+
49+
# -m "not integration": runs everything except tests marked @pytest.mark.integration.
50+
# (no integration tests yet)
51+
- name: Run unit tests
52+
working-directory: ./package
53+
run: pytest -v -m "not integration"
54+
55+
integration-tests:
56+
name: Integration tests (Python ${{ matrix.python-version }})
57+
runs-on: ubuntu-latest
58+
strategy:
59+
fail-fast: false
60+
matrix:
61+
python-version: ["3.11", "3.12"]
62+
63+
steps:
64+
- uses: actions/checkout@v4
65+
66+
- name: Set up Python ${{ matrix.python-version }}
67+
uses: actions/setup-python@v5
68+
with:
69+
python-version: ${{ matrix.python-version }}
70+
cache: "pip"
71+
cache-dependency-path: package/pyproject.toml
72+
73+
- name: Install package with test dependencies
74+
working-directory: ./package
75+
run: |
76+
python -m pip install --upgrade pip
77+
pip install -e ".[test]"
78+
79+
# Runs only @pytest.mark.integration tests against the committed example set
80+
# (tests/integration/examples). With no examples yet this is a clean skip.
81+
- name: Run integration tests
82+
working-directory: ./package
83+
run: pytest -v -m integration

‎package/pyproject.toml‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description = "A Python package for generating methods sections in for ASL param
55
authors = [{ name="Ibrahim Abdelazim", email="ibrahim.abdelazim@fau.de" }]
66
license = "MIT"
77
readme = "README.md"
8-
requires-python = ">=3.7"
8+
requires-python = ">=3.11"
99
dependencies = [
1010
"PyYAML~=6.0.2",
1111
"numpy~=2.2.6",
@@ -26,4 +26,37 @@ include-package-data = true
2626
where = ["src"]
2727

2828
[tool.setuptools.package-data]
29-
"pyaslreport" = ["*.yaml", "**/*.yaml"]
29+
"pyaslreport" = ["*.yaml", "**/*.yaml"]
30+
31+
[project.optional-dependencies]
32+
test = [
33+
"pytest>=7.0",
34+
"pytest-cov>=4.0",
35+
"black==26.5.1",
36+
"isort==8.0.1",
37+
]
38+
39+
[tool.pytest.ini_options]
40+
minversion = "7.0"
41+
testpaths = ["tests"]
42+
python_files = ["test_*.py"]
43+
python_classes = ["Test*"]
44+
python_functions = ["test_*"]
45+
addopts = [
46+
"-ra",
47+
"--strict-markers",
48+
"--strict-config",
49+
]
50+
markers = [
51+
"integration: integration tests requiring file fixtures (deselect with '-m \"not integration\"')",
52+
"slow: tests that take noticeably longer to run",
53+
]
54+
55+
[tool.black]
56+
line-length = 88
57+
target-version = ["py311"]
58+
59+
[tool.isort]
60+
profile = "black"
61+
line_length = 88
62+
known_first_party = ["pyaslreport"]

‎package/src/pyaslreport/modalities/asl/utils.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def compare_params(params_asl, params_m0, asl_filename, m0_filename):
4848
f"Discrepancy in '{param}' for ASL file '{asl_filename}' and M0 file '{m0_filename}': "
4949
f"ASL value = {asl_value}, M0 value = {m0_value}")
5050
elif validation_type == "floatOrArray":
51-
if isinstance(asl_value, float) and isinstance(m0_value, float):
51+
if isinstance(asl_value, (int, float)) and isinstance(m0_value, (int, float)):
5252
difference = abs(asl_value - m0_value)
5353
difference_formatted = f"{difference:.2f}"
5454
if difference > error_variation:

‎package/tests/conftest.py‎

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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])

‎package/tests/integration/__init__.py‎

Whitespace-only changes.

0 commit comments

Comments
 (0)