163 lines
6.6 KiB
Python
163 lines
6.6 KiB
Python
"""
|
||
Тесты приведения имён DICOM к единому виду.
|
||
|
||
Проверяют разбор имён, поиск свободного номера при конфликте, отказ от
|
||
переименования файлов без распознанной области и полный цикл
|
||
«применить → откатить» на временных файлах.
|
||
"""
|
||
import csv
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
|
||
from src.dxa.rename_files import ( # noqa: E402
|
||
apply_renames,
|
||
canonical_stem,
|
||
parse_stem,
|
||
plan_renames,
|
||
rollback,
|
||
write_mapping,
|
||
)
|
||
|
||
|
||
class TestParseStem:
|
||
@pytest.mark.parametrize("stem, expected", [
|
||
("spine_1", ("spine", 1, None)),
|
||
("spine_03_bad", ("spine", 3, "bad")),
|
||
("l_hip_2", ("hip_left", 2, None)),
|
||
("r_hip_01_good", ("hip_right", 1, "good")),
|
||
("Spine", ("spine", 1, None)),
|
||
("r_hop_02", ("hip_right", 2, None)),
|
||
("r_hip03", ("hip_right", 3, None)),
|
||
("r_spine_03", ("spine", 3, None)),
|
||
("spine-1", ("spine", 1, None)),
|
||
])
|
||
def test_recognised_names(self, stem, expected):
|
||
assert parse_stem(stem) == expected
|
||
|
||
@pytest.mark.parametrize("stem", ["bad", "good", "series_001", "A2507431060 DXA"])
|
||
def test_names_without_region_are_rejected(self, stem):
|
||
assert parse_stem(stem) is None
|
||
|
||
|
||
class TestCanonicalStem:
|
||
@pytest.mark.parametrize("stem, expected", [
|
||
("spine_1", "spine_01"),
|
||
("spine_01", "spine_01"),
|
||
("Spine_01", "spine_01"),
|
||
("spine-1", "spine_01"),
|
||
("spine", "spine_01"),
|
||
("r_hop_02", "r_hip_02"),
|
||
("r_hip03", "r_hip_03"),
|
||
("r_spine_03", "spine_03"),
|
||
("l_hip_5_good", "l_hip_05_good"),
|
||
("r_hip_1_bad", "r_hip_01_bad"),
|
||
])
|
||
def test_normalisation(self, stem, expected):
|
||
assert canonical_stem(stem) == expected
|
||
|
||
def test_region_is_never_invented(self):
|
||
assert canonical_stem("bad") is None
|
||
|
||
def test_label_is_preserved_not_added(self):
|
||
assert canonical_stem("spine_2") == "spine_02"
|
||
assert canonical_stem("spine_2_good") == "spine_02_good"
|
||
|
||
|
||
class TestPlanRenames:
|
||
def _files(self, tmp_path, names):
|
||
paths = []
|
||
for name in names:
|
||
path = tmp_path / name
|
||
path.write_bytes(b"0")
|
||
paths.append(path)
|
||
return paths
|
||
|
||
def test_canonical_names_need_no_change(self, tmp_path):
|
||
plan = plan_renames(self._files(tmp_path, ["spine_01.dcm", "l_hip_02_good.dcm"]))
|
||
assert plan == []
|
||
|
||
def test_number_width_is_fixed(self, tmp_path):
|
||
plan = plan_renames(self._files(tmp_path, ["spine_1.dcm"]))
|
||
assert [(p.source.name, p.target.name) for p in plan] == [("spine_1.dcm", "spine_01.dcm")]
|
||
|
||
def test_collision_bumps_to_free_number(self, tmp_path):
|
||
# Оба имени канонизируются в spine_01; второе должно получить свободный номер.
|
||
plan = plan_renames(self._files(tmp_path, ["spine_1.dcm", "spine_01.dcm"]))
|
||
targets = sorted(p.target.name for p in plan)
|
||
assert targets == ["spine_02.dcm"]
|
||
assert {p.source.name for p in plan} == {"spine_1.dcm"}
|
||
|
||
def test_case_only_rename_is_planned(self, tmp_path):
|
||
plan = plan_renames(self._files(tmp_path, ["Spine_01.dcm"]))
|
||
assert [(p.source.name, p.target.name) for p in plan] == [("Spine_01.dcm", "spine_01.dcm")]
|
||
|
||
def test_unrecognised_file_is_skipped(self, tmp_path):
|
||
plan = plan_renames(self._files(tmp_path, ["bad.dcm", "spine_1.dcm"]))
|
||
assert [p.source.name for p in plan] == ["spine_1.dcm"]
|
||
|
||
def test_targets_are_unique(self, tmp_path):
|
||
names = ["spine_1.dcm", "spine-1.dcm", "l_hip_1.dcm", "r_hop_02.dcm", "r_hip03.dcm"]
|
||
plan = plan_renames(self._files(tmp_path, names))
|
||
targets = [p.target.name for p in plan]
|
||
assert len(targets) == len(set(targets))
|
||
|
||
def test_plan_does_not_overwrite_a_file_that_stays(self, tmp_path):
|
||
# spine_02 канонично и остаётся на месте; spine_2 должен уйти на другой номер.
|
||
plan = plan_renames(self._files(tmp_path, ["spine_02.dcm", "spine_2.dcm"]))
|
||
assert {p.source.name for p in plan} <= {"spine_2.dcm"}
|
||
if plan:
|
||
assert plan[0].target.name != "spine_02.dcm"
|
||
|
||
|
||
class TestApplyAndRollback:
|
||
def _setup(self, tmp_path, names):
|
||
paths = []
|
||
for name in names:
|
||
path = tmp_path / name
|
||
path.write_bytes(name.encode())
|
||
paths.append(path)
|
||
return paths
|
||
|
||
def test_apply_then_rollback_restores_names(self, tmp_path):
|
||
names = ["spine_1.dcm", "l_hip-2.dcm", "Spine_03.dcm", "bad.dcm"]
|
||
paths = self._setup(tmp_path, names)
|
||
plan = plan_renames(paths)
|
||
mapping = write_mapping(plan, tmp_path / "map.csv")
|
||
|
||
assert apply_renames(plan, dry_run=False) == len(plan)
|
||
after = sorted(p.name for p in tmp_path.glob("*.dcm"))
|
||
assert after == ["bad.dcm", "l_hip_02.dcm", "spine_01.dcm", "spine_03.dcm"]
|
||
assert all(p.read_bytes() for p in tmp_path.glob("*.dcm"))
|
||
|
||
assert rollback(mapping, dry_run=False) == len(plan)
|
||
assert sorted(p.name for p in tmp_path.glob("*.dcm")) == sorted(names)
|
||
|
||
def test_dry_run_changes_nothing(self, tmp_path):
|
||
paths = self._setup(tmp_path, ["spine_1.dcm"])
|
||
plan = plan_renames(paths)
|
||
assert apply_renames(plan, dry_run=True) == 0
|
||
assert (tmp_path / "spine_1.dcm").exists()
|
||
|
||
def test_rollback_dry_run_reports_without_changing(self, tmp_path):
|
||
paths = self._setup(tmp_path, ["spine_1.dcm"])
|
||
plan = plan_renames(paths)
|
||
mapping = write_mapping(plan, tmp_path / "map.csv")
|
||
apply_renames(plan, dry_run=False)
|
||
# В режиме плана возвращается число файлов, к которым вернулось бы имя.
|
||
assert rollback(mapping, dry_run=True) == 1
|
||
assert (tmp_path / "spine_01.dcm").exists()
|
||
assert not (tmp_path / "spine_1.dcm").exists()
|
||
|
||
def test_mapping_file_is_readable_and_complete(self, tmp_path):
|
||
paths = self._setup(tmp_path, ["spine_1.dcm", "r_hop_02.dcm"])
|
||
plan = plan_renames(paths)
|
||
mapping = write_mapping(plan, tmp_path / "map.csv")
|
||
rows = list(csv.DictReader(mapping.open(encoding="utf-8")))
|
||
assert len(rows) == len(plan)
|
||
assert {"old_path", "new_path", "old_name", "new_name", "reason"} <= set(rows[0])
|
||
assert {r["reason"] for r in rows} == {"ширина номера", "опечатка hop"}
|