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