|
12 | 12 | import pytest |
13 | 13 |
|
14 | 14 | from bids_utils._dataset import BIDSDataset |
| 15 | +from bids_utils.merge import merge_datasets |
| 16 | +from bids_utils.metadata import aggregate_metadata, audit_metadata, segregate_metadata |
15 | 17 | from bids_utils.migrate import migrate_dataset |
16 | 18 | from bids_utils.rename import rename_file |
17 | | -from bids_utils.subject import rename_subject |
| 19 | +from bids_utils.run import remove_run |
| 20 | +from bids_utils.session import rename_session |
| 21 | +from bids_utils.subject import remove_subject, rename_subject |
18 | 22 | from tests.conftest import BIDS_EXAMPLES_DIR, requires_bids_examples |
19 | 23 |
|
20 | 24 |
|
@@ -186,3 +190,293 @@ def test_rename_preserves_file_count(self, tmp_path: Path) -> None: |
186 | 190 | assert len(after) == len(before), ( |
187 | 191 | f"File count changed: {len(before)} -> {len(after)}" |
188 | 192 | ) |
| 193 | + |
| 194 | + |
| 195 | +def _find_session_dataset_ids() -> list[str]: |
| 196 | + """Return dataset names that contain at least one ses-* directory.""" |
| 197 | + ids = [] |
| 198 | + for d in _iter_datasets(): |
| 199 | + sub_dirs = [ |
| 200 | + s for s in d.iterdir() if s.is_dir() and s.name.startswith("sub-") |
| 201 | + ] |
| 202 | + for s in sub_dirs: |
| 203 | + if any( |
| 204 | + ses.is_dir() and ses.name.startswith("ses-") |
| 205 | + for ses in s.iterdir() |
| 206 | + ): |
| 207 | + ids.append(d.name) |
| 208 | + break |
| 209 | + return ids |
| 210 | + |
| 211 | + |
| 212 | +def _find_sessionless_dataset_ids() -> list[str]: |
| 213 | + """Return dataset names that have subjects but NO ses-* directories.""" |
| 214 | + ids = [] |
| 215 | + for d in _iter_datasets(): |
| 216 | + sub_dirs = [ |
| 217 | + s for s in d.iterdir() if s.is_dir() and s.name.startswith("sub-") |
| 218 | + ] |
| 219 | + if not sub_dirs: |
| 220 | + continue |
| 221 | + has_session = False |
| 222 | + for s in sub_dirs: |
| 223 | + if any( |
| 224 | + ses.is_dir() and ses.name.startswith("ses-") |
| 225 | + for ses in s.iterdir() |
| 226 | + ): |
| 227 | + has_session = True |
| 228 | + break |
| 229 | + if not has_session: |
| 230 | + ids.append(d.name) |
| 231 | + return ids |
| 232 | + |
| 233 | + |
| 234 | +@requires_bids_examples |
| 235 | +@pytest.mark.integration |
| 236 | +class TestSessionRenameSweep: |
| 237 | + """Rename a session in each multi-session dataset (dry-run).""" |
| 238 | + |
| 239 | + @pytest.mark.ai_generated |
| 240 | + @pytest.mark.parametrize("ds_name", _find_session_dataset_ids()) |
| 241 | + def test_session_rename_dry_run(self, ds_name: str) -> None: |
| 242 | + ds_path = BIDS_EXAMPLES_DIR / ds_name |
| 243 | + try: |
| 244 | + ds = BIDSDataset.from_path(ds_path) |
| 245 | + except (FileNotFoundError, ValueError) as exc: |
| 246 | + pytest.skip(reason=f"cannot load {ds_name}: {exc}") |
| 247 | + |
| 248 | + # Find first session in first subject |
| 249 | + sub_dirs = sorted( |
| 250 | + d |
| 251 | + for d in ds_path.iterdir() |
| 252 | + if d.is_dir() and d.name.startswith("sub-") |
| 253 | + ) |
| 254 | + ses_dir = None |
| 255 | + for s in sub_dirs: |
| 256 | + for child in sorted(s.iterdir()): |
| 257 | + if child.is_dir() and child.name.startswith("ses-"): |
| 258 | + ses_dir = child |
| 259 | + break |
| 260 | + if ses_dir is not None: |
| 261 | + break |
| 262 | + |
| 263 | + if ses_dir is None: |
| 264 | + pytest.skip(reason=f"no ses-* directory in {ds_name}") |
| 265 | + |
| 266 | + old_label = ses_dir.name.removeprefix("ses-") |
| 267 | + result = rename_session(ds, old_label, "TESTZZ99", dry_run=True) |
| 268 | + |
| 269 | + assert result.success, ( |
| 270 | + f"Dry-run session rename failed in {ds_name}: {result.errors}" |
| 271 | + ) |
| 272 | + assert result.dry_run |
| 273 | + |
| 274 | + @pytest.mark.ai_generated |
| 275 | + @pytest.mark.parametrize("ds_name", _find_sessionless_dataset_ids()) |
| 276 | + def test_move_into_session_dry_run(self, ds_name: str) -> None: |
| 277 | + """Dry-run introducing a session to sessionless datasets.""" |
| 278 | + ds_path = BIDS_EXAMPLES_DIR / ds_name |
| 279 | + try: |
| 280 | + ds = BIDSDataset.from_path(ds_path) |
| 281 | + except (FileNotFoundError, ValueError) as exc: |
| 282 | + pytest.skip(reason=f"cannot load {ds_name}: {exc}") |
| 283 | + |
| 284 | + result = rename_session(ds, "", "baseline", dry_run=True) |
| 285 | + |
| 286 | + assert result.dry_run |
| 287 | + # Either creates changes or warns about subjects without datatype dirs |
| 288 | + assert result.success |
| 289 | + |
| 290 | + |
| 291 | +@requires_bids_examples |
| 292 | +@pytest.mark.integration |
| 293 | +class TestMetadataSweep: |
| 294 | + """Run metadata operations on each dataset (dry-run).""" |
| 295 | + |
| 296 | + @pytest.mark.ai_generated |
| 297 | + @pytest.mark.parametrize("ds_name", _dataset_ids()) |
| 298 | + def test_aggregate_dry_run(self, ds_name: str) -> None: |
| 299 | + ds_path = BIDS_EXAMPLES_DIR / ds_name |
| 300 | + try: |
| 301 | + ds = BIDSDataset.from_path(ds_path) |
| 302 | + except (FileNotFoundError, ValueError) as exc: |
| 303 | + pytest.skip(reason=f"cannot load {ds_name}: {exc}") |
| 304 | + |
| 305 | + result = aggregate_metadata(ds, dry_run=True) |
| 306 | + assert result.dry_run |
| 307 | + assert result.success |
| 308 | + |
| 309 | + @pytest.mark.ai_generated |
| 310 | + @pytest.mark.parametrize("ds_name", _dataset_ids()) |
| 311 | + def test_segregate_dry_run(self, ds_name: str) -> None: |
| 312 | + ds_path = BIDS_EXAMPLES_DIR / ds_name |
| 313 | + try: |
| 314 | + ds = BIDSDataset.from_path(ds_path) |
| 315 | + except (FileNotFoundError, ValueError) as exc: |
| 316 | + pytest.skip(reason=f"cannot load {ds_name}: {exc}") |
| 317 | + |
| 318 | + result = segregate_metadata(ds, dry_run=True) |
| 319 | + assert result.dry_run |
| 320 | + assert result.success |
| 321 | + |
| 322 | + @pytest.mark.ai_generated |
| 323 | + @pytest.mark.parametrize("ds_name", _dataset_ids()) |
| 324 | + def test_audit_no_crash(self, ds_name: str) -> None: |
| 325 | + ds_path = BIDS_EXAMPLES_DIR / ds_name |
| 326 | + try: |
| 327 | + ds = BIDSDataset.from_path(ds_path) |
| 328 | + except (FileNotFoundError, ValueError) as exc: |
| 329 | + pytest.skip(reason=f"cannot load {ds_name}: {exc}") |
| 330 | + |
| 331 | + result = audit_metadata(ds) |
| 332 | + # Should never crash — just reports inconsistencies |
| 333 | + assert isinstance(result.total_files, int) |
| 334 | + |
| 335 | + |
| 336 | +def _find_run_file(ds_path: Path) -> tuple[str, str] | None: |
| 337 | + """Find a subject and run label from a dataset. |
| 338 | +
|
| 339 | + Returns (subject_label, run_label) or None. |
| 340 | + """ |
| 341 | + import re |
| 342 | + |
| 343 | + for f in sorted(ds_path.rglob("sub-*_*run-*_*")): |
| 344 | + if not f.is_file(): |
| 345 | + continue |
| 346 | + m_sub = re.search(r"(sub-[^_/]+)", f.name) |
| 347 | + m_run = re.search(r"(run-\d+)", f.name) |
| 348 | + if m_sub and m_run: |
| 349 | + return m_sub.group(1), m_run.group(1) |
| 350 | + return None |
| 351 | + |
| 352 | + |
| 353 | +@requires_bids_examples |
| 354 | +@pytest.mark.integration |
| 355 | +class TestRemoveSweep: |
| 356 | + """Dry-run remove operations on bids-examples datasets.""" |
| 357 | + |
| 358 | + @pytest.mark.ai_generated |
| 359 | + @pytest.mark.parametrize("ds_name", _dataset_ids()) |
| 360 | + def test_remove_subject_dry_run(self, ds_name: str) -> None: |
| 361 | + ds_path = BIDS_EXAMPLES_DIR / ds_name |
| 362 | + try: |
| 363 | + ds = BIDSDataset.from_path(ds_path) |
| 364 | + except (FileNotFoundError, ValueError) as exc: |
| 365 | + pytest.skip(reason=f"cannot load {ds_name}: {exc}") |
| 366 | + |
| 367 | + sub_dirs = sorted( |
| 368 | + d |
| 369 | + for d in ds_path.iterdir() |
| 370 | + if d.is_dir() and d.name.startswith("sub-") |
| 371 | + ) |
| 372 | + if not sub_dirs: |
| 373 | + pytest.skip(reason=f"no sub-* directories in {ds_name}") |
| 374 | + |
| 375 | + result = remove_subject(ds, sub_dirs[0].name, dry_run=True, force=True) |
| 376 | + assert result.dry_run |
| 377 | + assert result.success, ( |
| 378 | + f"Dry-run remove subject failed in {ds_name}: {result.errors}" |
| 379 | + ) |
| 380 | + assert len(result.changes) >= 1 |
| 381 | + |
| 382 | + @pytest.mark.ai_generated |
| 383 | + @pytest.mark.parametrize("ds_name", _dataset_ids()) |
| 384 | + def test_remove_run_dry_run(self, ds_name: str) -> None: |
| 385 | + ds_path = BIDS_EXAMPLES_DIR / ds_name |
| 386 | + try: |
| 387 | + ds = BIDSDataset.from_path(ds_path) |
| 388 | + except (FileNotFoundError, ValueError) as exc: |
| 389 | + pytest.skip(reason=f"cannot load {ds_name}: {exc}") |
| 390 | + |
| 391 | + hit = _find_run_file(ds_path) |
| 392 | + if hit is None: |
| 393 | + pytest.skip(reason=f"no run-* files in {ds_name}") |
| 394 | + |
| 395 | + sub_label, run_label = hit |
| 396 | + result = remove_run(ds, sub_label, run_label, dry_run=True) |
| 397 | + assert result.dry_run |
| 398 | + assert result.success, ( |
| 399 | + f"Dry-run remove run failed in {ds_name}: {result.errors}" |
| 400 | + ) |
| 401 | + assert len(result.changes) >= 1 |
| 402 | + |
| 403 | + |
| 404 | +@requires_bids_examples |
| 405 | +@pytest.mark.integration |
| 406 | +class TestMergeSweep: |
| 407 | + """Dry-run merge of bids-examples dataset pairs.""" |
| 408 | + |
| 409 | + @pytest.mark.ai_generated |
| 410 | + def test_merge_two_datasets_dry_run(self, tmp_path: Path) -> None: |
| 411 | + """Pick two datasets with non-overlapping subjects, dry-run merge.""" |
| 412 | + datasets = _iter_datasets() |
| 413 | + if len(datasets) < 2: |
| 414 | + pytest.skip(reason="need at least 2 bids-examples datasets") |
| 415 | + |
| 416 | + # Find two datasets that each have subjects |
| 417 | + candidates = [] |
| 418 | + for d in datasets: |
| 419 | + subs = [ |
| 420 | + s.name |
| 421 | + for s in d.iterdir() |
| 422 | + if s.is_dir() and s.name.startswith("sub-") |
| 423 | + ] |
| 424 | + if subs: |
| 425 | + candidates.append((d, set(subs))) |
| 426 | + if len(candidates) >= 2: |
| 427 | + break |
| 428 | + |
| 429 | + if len(candidates) < 2: |
| 430 | + pytest.skip(reason="need at least 2 datasets with subjects") |
| 431 | + |
| 432 | + ds1_path, ds1_subs = candidates[0] |
| 433 | + ds2_path, ds2_subs = candidates[1] |
| 434 | + |
| 435 | + target = tmp_path / "merged" |
| 436 | + |
| 437 | + if ds1_subs & ds2_subs: |
| 438 | + # Overlapping subjects — use into_sessions to avoid conflict |
| 439 | + result = merge_datasets( |
| 440 | + [ds1_path, ds2_path], |
| 441 | + target, |
| 442 | + into_sessions=["ses-A", "ses-B"], |
| 443 | + dry_run=True, |
| 444 | + ) |
| 445 | + else: |
| 446 | + result = merge_datasets( |
| 447 | + [ds1_path, ds2_path], |
| 448 | + target, |
| 449 | + dry_run=True, |
| 450 | + ) |
| 451 | + |
| 452 | + assert result.dry_run |
| 453 | + assert result.success, f"Dry-run merge failed: {result.errors}" |
| 454 | + assert len(result.changes) >= 1 |
| 455 | + |
| 456 | + @pytest.mark.ai_generated |
| 457 | + @pytest.mark.parametrize("ds_name", _dataset_ids()) |
| 458 | + def test_merge_single_dataset_into_sessions_dry_run( |
| 459 | + self, ds_name: str, tmp_path: Path |
| 460 | + ) -> None: |
| 461 | + """Merge a single dataset into a new target with a session label.""" |
| 462 | + ds_path = BIDS_EXAMPLES_DIR / ds_name |
| 463 | + sub_dirs = [ |
| 464 | + d |
| 465 | + for d in ds_path.iterdir() |
| 466 | + if d.is_dir() and d.name.startswith("sub-") |
| 467 | + ] |
| 468 | + if not sub_dirs: |
| 469 | + pytest.skip(reason=f"no subjects in {ds_name}") |
| 470 | + |
| 471 | + target = tmp_path / "merged" |
| 472 | + result = merge_datasets( |
| 473 | + [ds_path], |
| 474 | + target, |
| 475 | + into_sessions=["ses-orig"], |
| 476 | + dry_run=True, |
| 477 | + ) |
| 478 | + |
| 479 | + assert result.dry_run |
| 480 | + assert result.success, ( |
| 481 | + f"Dry-run single-dataset merge failed for {ds_name}: {result.errors}" |
| 482 | + ) |
0 commit comments