Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Tests

# Runs on every PR and on pushes to main.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:

jobs:
unit-tests:
name: Unit tests (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false # run both Python versions even if one fails, so we see both
matrix:
# 3.11+ only: the package uses `match` and `int | float` unions (3.10+)
# and requires-python is >=3.11. Older versions fail on import.
python-version: ["3.11", "3.12"]

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
cache-dependency-path: package/pyproject.toml

# The workflow lives at repo root, but the package is in ./package,
# so every step below runs from there (matches local dev from package/).
- name: Install package with test dependencies
working-directory: ./package
run: |
python -m pip install --upgrade pip
pip install -e ".[test]"

# --check / --check-only report only; they never edit. CI fails if code
# was committed unformatted. Mirrors the local pre-commit checks.
- name: Check formatting (Black)
working-directory: ./package
run: black --check tests/

- name: Check import order (isort)
working-directory: ./package
run: isort --check-only tests/

# -m "not integration": runs everything except tests marked @pytest.mark.integration.
# (no integration tests yet)
- name: Run unit tests
working-directory: ./package
run: pytest -v -m "not integration"
37 changes: 35 additions & 2 deletions package/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description = "A Python package for generating methods sections in for ASL param
authors = [{ name="Ibrahim Abdelazim", email="ibrahim.abdelazim@fau.de" }]
license = "MIT"
readme = "README.md"
requires-python = ">=3.7"
requires-python = ">=3.11"
dependencies = [
"PyYAML~=6.0.2",
"numpy~=2.2.6",
Expand All @@ -26,4 +26,37 @@ include-package-data = true
where = ["src"]

[tool.setuptools.package-data]
"pyaslreport" = ["*.yaml", "**/*.yaml"]
"pyaslreport" = ["*.yaml", "**/*.yaml"]

[project.optional-dependencies]
test = [
"pytest>=7.0",
"pytest-cov>=4.0",
"black==26.5.1",
"isort==8.0.1",
]

[tool.pytest.ini_options]
minversion = "7.0"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-ra",
"--strict-markers",
"--strict-config",
]
markers = [
"integration: integration tests requiring file fixtures (deselect with '-m \"not integration\"')",
"slow: tests that take noticeably longer to run",
]

[tool.black]
line-length = 88
target-version = ["py311"]

[tool.isort]
profile = "black"
line_length = 88
known_first_party = ["pyaslreport"]
2 changes: 1 addition & 1 deletion package/src/pyaslreport/modalities/asl/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def compare_params(params_asl, params_m0, asl_filename, m0_filename):
f"Discrepancy in '{param}' for ASL file '{asl_filename}' and M0 file '{m0_filename}': "
f"ASL value = {asl_value}, M0 value = {m0_value}")
elif validation_type == "floatOrArray":
if isinstance(asl_value, float) and isinstance(m0_value, float):
if isinstance(asl_value, (int, float)) and isinstance(m0_value, (int, float)):
difference = abs(asl_value - m0_value)
difference_formatted = f"{difference:.2f}"
if difference > error_variation:
Expand Down
File renamed without changes.
198 changes: 198 additions & 0 deletions package/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
"""Shared fixtures and pytest configuration for pyaslreport tests.

Fixtures provided:
minimal_nifti_path: Path to a tiny on-disk NIfTI file (auto-deleted after test).
make_context: Factory for building ProcessingContext with sensible defaults.
make_processor: Factory for building ASLProcessor without triggering validation.
examples_dir: Path to the integration examples directory (pytest-configurable).
minimal_asl_json: In-memory dict representing a minimal valid ASL JSON.
"""

from __future__ import annotations

from pathlib import Path
from typing import Any, Callable

import nibabel as nib
import numpy as np
import pytest

from pyaslreport.modalities.asl.processor import ASLProcessor, ProcessingContext


# ---------------------------------------------------------------------------
# CLI option for integration test directory
# ---------------------------------------------------------------------------
def pytest_addoption(parser: pytest.Parser) -> None:
"""Register the --examples-dir CLI option for the integration runner.

Args:
parser: The pytest CLI parser, supplied by pytest at collection time.

Notes:
Local usage: pytest --examples-dir=/path/to/examples
CI usage: omit the flag; defaults to the committed set in
tests/integration/examples.
"""
parser.addoption(
"--examples-dir",
action="store",
default=None,
help="Path to integration examples dir; falls back to the committed CI set.",
)


# ---------------------------------------------------------------------------
# File-based fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def minimal_nifti_path(tmp_path: Path) -> Path:
"""Create a tiny valid NIfTI file at tmp_path/asl.nii.gz.

Args:
tmp_path: Pytest-provided per-test temporary directory.

Returns:
Path to the written NIfTI file with shape (4, 4, 20, 2). The third
axis matches a sensible default for ProcessingContext.nifti_slice_number
so most tests do not need a custom shape.
"""
data = np.zeros((4, 4, 20, 2), dtype=np.float32)
img = nib.Nifti1Image(data, affine=np.eye(4))

path = tmp_path / "asl.nii.gz"
nib.save(img, str(path))

return path


@pytest.fixture
def examples_dir(request: pytest.FixtureRequest) -> Path:
"""Return the path to the integration examples directory.

Args:
request: Pytest fixture request, used to read the --examples-dir flag.

Returns:
Resolved path to an existing examples directory.

Raises:
pytest.skip.Exception: If the resolved path does not exist. Resolution
order is (1) the --examples-dir CLI flag, then (2) the committed
tests/integration/examples directory beside this conftest.
"""
cli_value = request.config.getoption("--examples-dir")

if cli_value:
path = Path(cli_value).expanduser().resolve()
else:
path = Path(__file__).parent / "integration" / "examples"

if not path.is_dir():
pytest.skip(f"Examples directory not found: {path}")

return path


# ---------------------------------------------------------------------------
# Factory fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def make_context() -> Callable[..., ProcessingContext]:
"""Return a factory for building ProcessingContext with sensible defaults.

Returns:
A callable that accepts keyword overrides and returns a fully-formed
ProcessingContext. All ten required fields receive defaults; pass
keyword arguments to override any of them.

Example:
>>> def test_something(make_context):
... ctx = make_context(m0_type="Separate", errors=["something"])
... assert ctx.m0_type == "Separate"
"""

def _make(**overrides: Any) -> ProcessingContext:
defaults: dict[str, Any] = {
"asl_json_data": [],
"m0_prep_times_collection": [],
"errors": [],
"warnings": [],
"all_absent": True,
"bs_all_off": True,
"m0_type": None,
"global_pattern": None,
"total_acquired_pairs": None,
"nifti_slice_number": 20,
}

defaults.update(overrides)

return ProcessingContext(**defaults)

return _make


@pytest.fixture
def make_processor(minimal_nifti_path: Path) -> Callable[..., ASLProcessor]:
"""Return a factory for building ASLProcessor without input validation.

Args:
minimal_nifti_path: Auto-injected fixture providing a real on-disk
NIfTI file. The factory uses it as the default nifti_file unless
the caller overrides it.

Returns:
A callable that accepts keyword overrides and returns an ASLProcessor.
Useful for testing private methods like _group_files in isolation,
because BaseProcessor.__init__ stores self.data without validating.

Example:
>>> def test_grouping(make_processor):
... proc = make_processor(files=["/path/to/asl.json"])
... groups = proc._group_files("nifti")
"""

def _make(**overrides: Any) -> ASLProcessor:
defaults: dict[str, Any] = {
"modality": "asl",
"files": [],
"dcm_files": [],
"nifti_file": str(minimal_nifti_path),
}

defaults.update(overrides)

return ASLProcessor(defaults)

return _make


# ---------------------------------------------------------------------------
# Data fixtures (in-memory JSON dicts)
# ---------------------------------------------------------------------------
@pytest.fixture
def minimal_asl_json() -> dict[str, Any]:
"""Return a minimal ASL JSON with all major-error fields valid.

Returns:
Dictionary intended as a starting point for normalization and
validation tests. Spread it into a new dict and override individual
fields to introduce specific missing or invalid values.
"""
return {
"ArterialSpinLabelingType": "PCASL",
"MRAcquisitionType": "3D",
"PulseSequenceType": "GRASE",
"M0Type": "Separate",
"BackgroundSuppression": False,
"PostLabelingDelay": 1.8,
"LabelingDuration": 1.8,
"EchoTime": 0.012,
"RepetitionTimePreparation": 4.0,
"FlipAngle": 90,
"MagneticFieldStrength": 3,
"Manufacturer": "Siemens",
"ManufacturersModelName": "TrioTim",
"AcquisitionVoxelSize": [3, 3, 4],
}
Loading
Loading