Skip to content

Commit f608653

Browse files
fepegarCopilot
andauthored
Improve coverage for external and utils modules (#1432)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3f4cac9 commit f608653

5 files changed

Lines changed: 302 additions & 0 deletions

File tree

tests/external/__init__.py

Whitespace-only changes.

tests/external/test_due.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Tests for external.due module (duecredit stub)."""
2+
3+
from torchio.external.due import InactiveDueCreditCollector
4+
5+
6+
class TestInactiveDueCreditCollector:
7+
"""Tests for the duecredit stub collector."""
8+
9+
def test_dcite_returns_identity_decorator(self):
10+
"""dcite() returns a decorator that does not modify the function."""
11+
collector = InactiveDueCreditCollector()
12+
13+
@collector.dcite(description='test')
14+
def my_function():
15+
return 42
16+
17+
assert my_function() == 42
18+
19+
def test_repr(self):
20+
"""__repr__ returns class name with parens."""
21+
collector = InactiveDueCreditCollector()
22+
assert repr(collector) == 'InactiveDueCreditCollector()'
23+
24+
def test_active_is_false(self):
25+
"""Stub collector is not active."""
26+
collector = InactiveDueCreditCollector()
27+
assert collector.active is False
28+
29+
def test_donothing_methods(self):
30+
"""activate, add, cite, dump, load are all no-ops."""
31+
collector = InactiveDueCreditCollector()
32+
collector.activate()
33+
collector.add()
34+
collector.cite()
35+
collector.dump()
36+
collector.load()

tests/external/test_imports.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Tests for external.imports module."""
2+
3+
from unittest.mock import patch
4+
5+
import pytest
6+
7+
from torchio.external.imports import _check_executable
8+
from torchio.external.imports import _check_module
9+
10+
11+
class TestCheckModule:
12+
"""Tests for _check_module function."""
13+
14+
def test_missing_module_raises_import_error(self):
15+
"""_check_module raises ImportError when module is not found."""
16+
with pytest.raises(ImportError, match='torchio_nonexistent_pkg'):
17+
_check_module(
18+
module='torchio_nonexistent_pkg',
19+
extra='test',
20+
)
21+
22+
def test_missing_module_uses_package_name(self):
23+
"""ImportError message uses package name when provided."""
24+
with pytest.raises(ImportError, match='my-custom-package'):
25+
_check_module(
26+
module='torchio_nonexistent_pkg',
27+
extra='test',
28+
package='my-custom-package',
29+
)
30+
31+
def test_existing_module_passes(self):
32+
"""_check_module does not raise for installed modules."""
33+
_check_module(module='torch', extra='test')
34+
35+
36+
class TestCheckExecutable:
37+
"""Tests for _check_executable function."""
38+
39+
def test_missing_executable_raises(self):
40+
"""Missing executable raises FileNotFoundError."""
41+
with pytest.raises(FileNotFoundError, match='nonexistent_binary_xyz'):
42+
_check_executable('nonexistent_binary_xyz')
43+
44+
def test_existing_executable_passes(self):
45+
"""Existing executable does not raise."""
46+
_check_executable('python3')
47+
48+
def test_which_returns_none(self):
49+
"""FileNotFoundError raised when which() returns None."""
50+
with patch('torchio.external.imports.which', return_value=None):
51+
with pytest.raises(FileNotFoundError, match='ffmpeg'):
52+
_check_executable('ffmpeg')
53+
54+
55+
class TestGetHelpers:
56+
"""Tests for get_* import helpers."""
57+
58+
def test_get_pandas_missing(self):
59+
"""get_pandas raises ImportError when pandas not installed."""
60+
from torchio.external.imports import get_pandas
61+
62+
with patch('torchio.external.imports.find_spec', return_value=None):
63+
with pytest.raises(ImportError, match='pandas'):
64+
get_pandas()
65+
66+
def test_get_colorcet_missing(self):
67+
"""get_colorcet raises ImportError when not installed."""
68+
from torchio.external.imports import get_colorcet
69+
70+
with patch('torchio.external.imports.find_spec', return_value=None):
71+
with pytest.raises(ImportError, match='colorcet'):
72+
get_colorcet()
73+
74+
def test_get_ffmpeg_missing_module(self):
75+
"""get_ffmpeg raises ImportError when ffmpeg module not found."""
76+
from torchio.external.imports import get_ffmpeg
77+
78+
with patch('torchio.external.imports.find_spec', return_value=None):
79+
with pytest.raises(ImportError, match='ffmpeg-python'):
80+
get_ffmpeg()

tests/test_utils.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import copy
2+
import os
3+
import tempfile
4+
from pathlib import Path
5+
from unittest.mock import patch
26

37
import pytest
48
import torch
@@ -85,3 +89,139 @@ def test_add_images_from_batch(self):
8589
def test_empty_batch(self):
8690
with pytest.raises(RuntimeError):
8791
tio.utils.get_batch_images_and_size({})
92+
93+
def test_compress_default_output(self):
94+
"""compress() creates .nii.gz from input with default output."""
95+
with tempfile.TemporaryDirectory() as tmp:
96+
input_path = Path(tmp) / 'test.nii'
97+
input_path.write_bytes(b'fake nifti data')
98+
result = tio.utils.compress(input_path)
99+
assert result.exists()
100+
assert result.suffix == '.gz'
101+
102+
def test_compress_explicit_output(self):
103+
"""compress() writes to the specified output path."""
104+
with tempfile.TemporaryDirectory() as tmp:
105+
input_path = Path(tmp) / 'test.nii'
106+
input_path.write_bytes(b'fake nifti data')
107+
output_path = Path(tmp) / 'compressed.nii.gz'
108+
result = tio.utils.compress(input_path, output_path)
109+
assert result == output_path
110+
assert result.exists()
111+
112+
def test_history_collate_non_subject(self):
113+
"""history_collate returns empty dict for non-Subject batches."""
114+
result = tio.utils.history_collate([{'a': 1}])
115+
assert result == {}
116+
117+
def test_create_dummy_dataset_force(self):
118+
"""create_dummy_dataset with force=True recreates directories."""
119+
with tempfile.TemporaryDirectory() as tmp:
120+
subjects = tio.utils.create_dummy_dataset(
121+
num_images=2,
122+
size_range=(5, 10),
123+
directory=tmp,
124+
)
125+
assert len(subjects) == 2
126+
# Call again with force to recreate
127+
subjects = tio.utils.create_dummy_dataset(
128+
num_images=2,
129+
size_range=(5, 10),
130+
directory=tmp,
131+
force=True,
132+
)
133+
assert len(subjects) == 2
134+
135+
def test_create_dummy_dataset_existing(self):
136+
"""create_dummy_dataset loads from existing dirs without force."""
137+
with tempfile.TemporaryDirectory() as tmp:
138+
tio.utils.create_dummy_dataset(
139+
num_images=2,
140+
size_range=(5, 10),
141+
directory=tmp,
142+
)
143+
# Call again without force — loads from existing paths
144+
subjects = tio.utils.create_dummy_dataset(
145+
num_images=2,
146+
size_range=(5, 10),
147+
directory=tmp,
148+
)
149+
assert len(subjects) == 2
150+
151+
def test_create_dummy_dataset_verbose(self):
152+
"""create_dummy_dataset with verbose=True prints a message."""
153+
with tempfile.TemporaryDirectory() as tmp:
154+
subjects = tio.utils.create_dummy_dataset(
155+
num_images=1,
156+
size_range=(5, 10),
157+
directory=tmp,
158+
verbose=True,
159+
)
160+
assert len(subjects) == 1
161+
162+
def test_parse_spatial_shape_wrong_length(self):
163+
"""parse_spatial_shape raises ValueError for non-3-element shapes."""
164+
with pytest.raises(ValueError, match='3 elements'):
165+
tio.utils.parse_spatial_shape((10, 20))
166+
167+
def test_normalize_path(self):
168+
"""normalize_path expands ~ and resolves to absolute path."""
169+
result = tio.utils.normalize_path('~/test')
170+
assert result.is_absolute()
171+
assert '~' not in str(result)
172+
173+
def test_guess_external_viewer_env_var(self):
174+
"""guess_external_viewer returns SITK_SHOW_COMMAND if set."""
175+
with patch.dict(os.environ, {'SITK_SHOW_COMMAND': '/usr/bin/viewer'}):
176+
result = tio.utils.guess_external_viewer()
177+
assert result == Path('/usr/bin/viewer')
178+
179+
def test_guess_external_viewer_linux_itksnap(self):
180+
"""guess_external_viewer finds itksnap on Linux."""
181+
with (
182+
patch('torchio.utils.sys') as mock_sys,
183+
patch('torchio.utils.shutil') as mock_shutil,
184+
patch.dict(os.environ, {}, clear=False),
185+
):
186+
# Remove SITK_SHOW_COMMAND if present
187+
os.environ.pop('SITK_SHOW_COMMAND', None)
188+
mock_sys.platform = 'linux'
189+
mock_shutil.which = lambda x: '/usr/bin/itksnap' if x == 'itksnap' else None
190+
result = tio.utils.guess_external_viewer()
191+
assert result == Path('/usr/bin/itksnap')
192+
193+
def test_guess_external_viewer_none(self):
194+
"""guess_external_viewer returns None when no viewer found."""
195+
with (
196+
patch('torchio.utils.sys') as mock_sys,
197+
patch('torchio.utils.shutil') as mock_shutil,
198+
patch.dict(os.environ, {}, clear=False),
199+
):
200+
os.environ.pop('SITK_SHOW_COMMAND', None)
201+
mock_sys.platform = 'unknown_platform'
202+
mock_shutil.which = lambda x: None
203+
result = tio.utils.guess_external_viewer()
204+
assert result is None
205+
206+
def test_guess_external_viewer_linux_slicer(self):
207+
"""guess_external_viewer finds Slicer on Linux when itksnap absent."""
208+
with (
209+
patch('torchio.utils.sys') as mock_sys,
210+
patch('torchio.utils.shutil') as mock_shutil,
211+
patch.dict(os.environ, {}, clear=False),
212+
):
213+
os.environ.pop('SITK_SHOW_COMMAND', None)
214+
mock_sys.platform = 'linux'
215+
mock_shutil.which = lambda x: '/usr/bin/Slicer' if x == 'Slicer' else None
216+
result = tio.utils.guess_external_viewer()
217+
assert result == Path('/usr/bin/Slicer')
218+
219+
def test_apply_transform_verbose_history(self):
220+
"""apply_transform_to_file with verbose prints history."""
221+
transform = tio.RandomFlip()
222+
tio.utils.apply_transform_to_file(
223+
self.get_image_path('input_v'),
224+
transform,
225+
self.get_image_path('output_v'),
226+
verbose=True,
227+
)

tests/transforms/test_monai_adapter.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,3 +368,49 @@ def __call__(self, data):
368368
assert not isinstance(transformed['mean'], tio.Image)
369369
assert isinstance(transformed['indices'], torch.Tensor)
370370
assert not isinstance(transformed['indices'], tio.Image)
371+
372+
def test_repr(self):
373+
"""__repr__ includes the adapter name and wrapped transform."""
374+
transform = tio.MonaiAdapter(NormalizeIntensityd(keys=['t1']))
375+
result = repr(transform)
376+
assert 'MonaiAdapter' in result
377+
assert 'NormalizeIntensityd' in result
378+
379+
def test_to_hydra_config_raises(self):
380+
"""to_hydra_config raises NotImplementedError."""
381+
transform = tio.MonaiAdapter(NormalizeIntensityd(keys=['t1']))
382+
with pytest.raises(NotImplementedError, match='Hydra'):
383+
transform.to_hydra_config()
384+
385+
def test_array_transform_non_tensor_result_raises(self):
386+
"""Array transform returning non-tensor raises TypeError."""
387+
388+
class BadArrayTransform:
389+
def __call__(self, x):
390+
return 'not a tensor'
391+
392+
subject = tio.Subject(
393+
t1=tio.ScalarImage(tensor=torch.randn(1, 10, 10, 10)),
394+
)
395+
transform = tio.MonaiAdapter(BadArrayTransform())
396+
with pytest.raises(TypeError, match='Expected a torch.Tensor'):
397+
transform(subject)
398+
399+
def test_dict_transform_non_tensor_for_image_raises(self):
400+
"""Dict transform returning non-tensor for image key raises TypeError."""
401+
402+
class BadDictTransform(MapTransform):
403+
def __init__(self):
404+
super().__init__(keys=['t1'])
405+
406+
def __call__(self, data):
407+
data = dict(data)
408+
data['t1'] = 'not a tensor'
409+
return data
410+
411+
subject = tio.Subject(
412+
t1=tio.ScalarImage(tensor=torch.randn(1, 10, 10, 10)),
413+
)
414+
transform = tio.MonaiAdapter(BadDictTransform())
415+
with pytest.raises(TypeError, match='Expected a torch.Tensor'):
416+
transform(subject)

0 commit comments

Comments
 (0)