bone_2026/tests/test_rename_files.py

163 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Тесты приведения имён 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"}