Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
4498dec
Refactor e2e project fixture helpers
C-Achard Sep 15, 2026
8591cb9
Add folder switch integrity E2E tests
C-Achard Sep 15, 2026
1b920cf
Warn on layers left on previous dataset
C-Achard Sep 15, 2026
1de2098
Test remap rejection metadata handling
C-Achard Sep 15, 2026
bec2496
Route dataset mismatch warnings via signal
C-Achard Sep 15, 2026
f4ba1f8
Adjust warning phrasing
C-Achard Sep 15, 2026
c601e46
Scope path remap by dataset across folders
C-Achard Sep 15, 2026
f1fb49c
Reject basename-only dataset remaps
C-Achard Sep 15, 2026
61b4d39
Add e2e test for cross-project layer rebinding
C-Achard Sep 15, 2026
6ac933f
Track dataset identity for layer remapping
C-Achard Sep 15, 2026
c4ea165
Refocus remap tests on dataset_key identity
C-Achard Sep 15, 2026
1dd8292
Refine dataset mismatch layer warnings
C-Achard Sep 15, 2026
f554984
Resolve dataset key aliases across paths
C-Achard Sep 15, 2026
6a19779
Bind adopted datasets for unbound layers
C-Achard Sep 15, 2026
c83ee8d
Stop rewriting points roots from image metadata
C-Achard Sep 15, 2026
26d0c34
Clarify warning message
C-Achard Sep 15, 2026
593233f
Guard layer dataset adoption
C-Achard Sep 17, 2026
bf3edf8
Tighten dataset context inheritance
C-Achard Sep 17, 2026
3796898
Add dataset inheritance layer tests
C-Achard Sep 17, 2026
0dd3304
Track dataset keys across image folders
C-Achard Sep 17, 2026
7891c2c
Fix dataset-key matching for video contexts
C-Achard Sep 17, 2026
9165bbc
Fix keyless image context test
C-Achard Sep 18, 2026
30ffdba
Avoid layer clearing advice for same-folder remap
C-Achard Sep 18, 2026
ca7f51c
Document dataset path samefile fallback
C-Achard Sep 18, 2026
a50eef0
Fix dataset mismatch layer warnings
C-Achard Sep 21, 2026
5b755ae
Bind adopted point layers to dataset
C-Achard Sep 21, 2026
d5edcbe
Add TODO for next refactor
C-Achard Sep 21, 2026
65137e6
Document FALLBACK_H5_KEYS legacy support
C-Achard Sep 23, 2026
679723b
Rename dataset keys to dataset folders
C-Achard Sep 23, 2026
1a88ba9
Prevent other potential root/paths mismatch
C-Achard Sep 28, 2026
88af72d
Reject colliding basenames earlier
C-Achard Sep 28, 2026
575cf3b
Fix stale-root mismatch warnings
C-Achard Sep 28, 2026
fbdb1fa
Fix dataset mismatch warning dedupe
C-Achard Sep 28, 2026
445c0f8
Normalize dataset paths in save check
C-Achard Sep 28, 2026
b3abeec
Warn when remapped frames lose paths
C-Achard Sep 28, 2026
4b54a1f
Prevent duplicate annotation layers
C-Achard Sep 28, 2026
746aaa0
Refuse remaps for missing annotated frames
C-Achard Sep 28, 2026
0fa814f
Add folder switch integrity tests
C-Achard Sep 28, 2026
c0bfa9d
Stabilize deleted-frame integrity test
C-Achard Sep 28, 2026
cc58c90
Add tests for bodypart removal regressions
C-Achard Sep 28, 2026
c0fbcb0
Lock mismatched layers during remap
C-Achard Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
592 changes: 591 additions & 1 deletion src/napari_deeplabcut/_tests/core/layer_manager/test_manager.py

Large diffs are not rendered by default.

84 changes: 27 additions & 57 deletions src/napari_deeplabcut/_tests/core/test_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,42 +39,6 @@ def __init__(self, metadata=None, name="dummy-layer"):
# -----------------------------------------------------------------------------


@pytest.mark.parametrize(
("path_str", "expected"),
[
("project/labeled-data/mouse1", True),
("project/LABELED-DATA/mouse1", True),
("project/labeled-data", False),
("project/images/mouse1", False),
],
)
def test_is_dlc_dataset_root(path_str: str, expected: bool):
assert metadata_mod._is_dlc_dataset_root(Path(path_str)) is expected


@pytest.mark.parametrize(
("paths", "expected"),
[
(None, False),
([], False),
(["images/img001.png"], False),
(["labeled-data/test/img001.png"], True),
([r"labeled-data\test\img001.png"], True),
],
)
def test_paths_look_like_labeled_data(paths, expected):
assert metadata_mod._paths_look_like_labeled_data(paths) is expected


def test_looks_like_project_root_true_when_same_path(tmp_path: Path):
assert metadata_mod._looks_like_project_root(str(tmp_path), str(tmp_path)) is True


def test_looks_like_project_root_false_when_different(tmp_path: Path):
other = tmp_path / "other"
assert metadata_mod._looks_like_project_root(str(tmp_path), str(other)) is False


def test_infer_image_root_prefers_explicit_root(tmp_path: Path):
p = tmp_path / "images" / "img001.png"
p.parent.mkdir(parents=True)
Expand Down Expand Up @@ -188,43 +152,49 @@ def test_sync_points_from_image_fills_missing_fields():
assert synced.name == "images"


def test_sync_points_from_image_overrides_project_root_with_dataset_root(tmp_path: Path):
def test_sync_points_from_image_never_rewrites_a_root_that_is_already_set(tmp_path: Path):
project_root = tmp_path / "project"
dataset_root = project_root / "labeled-data" / "mouse1"
dataset_root.mkdir(parents=True)
good_points_root = project_root / "labeled-data" / "mouse1"
other_dataset_root = project_root / "labeled-data" / "mouse2"
good_points_root.mkdir(parents=True)
other_dataset_root.mkdir(parents=True)

image_meta = ImageMetadata(
root=str(dataset_root),
paths=[str(dataset_root / "img001.png")],
name="images",
)
image_meta = ImageMetadata(root=str(other_dataset_root))
points_meta = PointsMetadata(
root=str(project_root), # stale / wrong
root=str(good_points_root),
project=str(project_root),
)

synced = metadata_mod.sync_points_from_image(image_meta, points_meta)

assert synced.root == str(dataset_root)
# Which dataset a layer belongs to is settled by dataset_folder, not re-derived here.
assert synced.root == str(good_points_root)


def test_sync_points_from_image_keeps_existing_dataset_root_when_already_good(tmp_path: Path):
def test_sync_points_from_image_does_not_seed_root_beside_existing_paths(tmp_path: Path):
project_root = tmp_path / "project"
good_points_root = project_root / "labeled-data" / "mouse1"
other_dataset_root = project_root / "labeled-data" / "mouse2"
good_points_root.mkdir(parents=True)
other_dataset_root.mkdir(parents=True)
layer_dataset = project_root / "labeled-data" / "mouse1"
opened_dataset = project_root / "labeled-data" / "mouse2"
layer_dataset.mkdir(parents=True)
opened_dataset.mkdir(parents=True)

image_meta = ImageMetadata(root=str(other_dataset_root))
points_meta = PointsMetadata(
root=str(good_points_root),
project=str(project_root),
image_meta = ImageMetadata(
root=str(opened_dataset),
paths=[str(opened_dataset / "img001.png")],
shape=[100, 200],
name="images",
)
points_meta = PointsMetadata(paths=[str(layer_dataset / "img001.png")])

synced = metadata_mod.sync_points_from_image(image_meta, points_meta)

# already a valid dataset root -> do not overwrite
assert synced.root == str(good_points_root)
# root routes the save, paths says what it is indexed against: seeding one beside the
# other is the split that sends annotations to a folder they did not come from.
assert synced.root is None
assert synced.paths == [str(layer_dataset / "img001.png")]
# Fields that do not route a save are still seeded.
assert tuple(synced.shape) == (100, 200)
assert synced.name == "images"


def test_ensure_metadata_models_accepts_dicts_and_models():
Expand Down
65 changes: 65 additions & 0 deletions src/napari_deeplabcut/_tests/core/test_project_paths.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import inspect
import os
from pathlib import Path
from types import SimpleNamespace

Expand Down Expand Up @@ -87,6 +88,70 @@ def test_path_match_policy_ordered_depths():
assert paths_mod.PathMatchPolicy.ORDERED_DEPTHS.depths == (3, 2, 1)


def test_dataset_folder_is_absolute_two_projects_stay_distinct(tmp_path: Path):
"""The dataset folder name alone repeats across projects; the resolved path does not."""
a = tmp_path / "project-A" / "labeled-data" / "mouse1"
b = tmp_path / "project-B" / "labeled-data" / "mouse1"
a.mkdir(parents=True)
b.mkdir(parents=True)

key_a = paths_mod.resolve_dataset_folder(a)
key_b = paths_mod.resolve_dataset_folder(b)

assert key_a != key_b
assert key_a == paths_mod.resolve_dataset_folder(str(a))
assert paths_mod.resolve_dataset_folder(None) is None


def test_points_metadata_round_trip_preserves_dataset_folder():
"""Identity must survive the metadata sync that runs on every image insert."""
from napari_deeplabcut.config.models import PointsMetadata

meta = PointsMetadata(root="C:/p/labeled-data/videoA", dataset_folder="C:/p/labeled-data/videoA")

assert PointsMetadata(**meta.model_dump()).dataset_folder == "C:/p/labeled-data/videoA"


def test_is_same_dataset_matches_identical_and_rejects_distinct(tmp_path: Path):
a = tmp_path / "labeled-data" / "videoA"
b = tmp_path / "labeled-data" / "videoB"
a.mkdir(parents=True)
b.mkdir(parents=True)

assert paths_mod.is_same_dataset(str(a), str(a)) is True
assert paths_mod.is_same_dataset(str(a), str(b)) is False
assert paths_mod.is_same_dataset(None, str(a)) is False
assert paths_mod.is_same_dataset(str(a), None) is False


def test_is_same_dataset_fails_closed_for_missing_folders(tmp_path: Path):
"""Two spellings that cannot be compared on disk are not assumed to be the same."""
assert paths_mod.is_same_dataset(str(tmp_path / "gone-a"), str(tmp_path / "gone-b")) is False


def test_is_same_dataset_sees_through_a_second_route_to_one_folder(tmp_path: Path):
"""A mapped drive against its UNC path is the real case; a symlink stands in for it."""
real = tmp_path / "labeled-data" / "videoA"
real.mkdir(parents=True)
link = tmp_path / "via-link"
try:
link.symlink_to(real, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlink creation not permitted here")

# Deliberately unresolved, so the strings differ and the on-disk check is what decides.
assert str(link) != str(real)
assert paths_mod.is_same_dataset(str(link), str(real)) is True


@pytest.mark.skipif(os.name != "nt", reason="path case is only insensitive on Windows")
def test_is_same_dataset_ignores_case_on_windows(tmp_path: Path):
a = tmp_path / "labeled-data" / "videoA"
a.mkdir(parents=True)

assert paths_mod.is_same_dataset(str(a), str(a).upper()) is True


def test_find_matching_depth_prefers_deepest_first_match():
old_paths = [
"/project/labeled-data/mouse1/img001.png",
Expand Down
120 changes: 120 additions & 0 deletions src/napari_deeplabcut/_tests/core/test_remap.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import logging

import numpy as np
import pytest

from napari_deeplabcut.core.project_paths import PathMatchPolicy
from napari_deeplabcut.core.remap import (
LOST_ANNOTATED_FRAMES,
AnnotationFramesMissingError,
build_frame_index_map,
remap_layer_data_by_paths,
remap_time_indices,
Expand Down Expand Up @@ -281,6 +284,123 @@ def test_remap_warns_on_duplicate_canonical_keys(caplog):
assert any("Duplicate canonical keys" in w for w in res.warnings)


def test_ambiguous_depth1_remap_is_rejected_and_refuses_paths_update():
"""A basename-only match that is not bijective must not be trusted.

Callers key further metadata updates off ``accept_paths_update``, so it has to stay
False here even though a depth was found.
"""
# No shared 3- or 2-level suffix, so matching falls back to bare basenames, where
# both old paths collapse onto the same key.
old_paths = ["A/a/img0.png", "B/b/img0.png"]
new_paths = ["X/x/img0.png", "Y/y/img1.png"]
Comment thread
C-Achard marked this conversation as resolved.

data = np.array([[0.0, 1.0, 2.0], [1.0, 3.0, 4.0]], dtype=float)

res = remap_layer_data_by_paths(
data=data,
old_paths=old_paths,
new_paths=new_paths,
time_col=0,
policy=PathMatchPolicy.ORDERED_DEPTHS,
)

assert res.depth_used == 1
assert res.is_ambiguous is True
assert res.accept_paths_update is False
assert res.applied is False
assert res.changed is False


def test_fully_colliding_basenames_are_rejected_not_read_as_aligned():
"""Equal key lists at depth=1 are a name collision, not a match.

Both sides canonicalize to ["img0.png", "img0.png"], so equality alone cannot tell
two folders of identically named frames from one folder in its original order.
"""
old_paths = ["projA/labeled-data/mouse1/img0.png", "projA/labeled-data/mouse2/img0.png"]
new_paths = ["projB/labeled-data/catX/img0.png", "projB/labeled-data/catY/img0.png"]

data = np.array([[0.0, 1.0, 2.0], [1.0, 3.0, 4.0]], dtype=float)

res = remap_layer_data_by_paths(
data=data,
old_paths=old_paths,
new_paths=new_paths,
time_col=0,
policy=PathMatchPolicy.ORDERED_DEPTHS,
)

assert res.depth_used == 1
assert res.is_ambiguous is True
assert res.accept_paths_update is False


def test_annotated_frame_losing_its_path_is_refused(caplog):
"""A keypoint on a vanished frame has no correct destination, so the remap refuses.

Frame association is positional: re-keying the layer would move those keypoints onto
whichever path took their position, and the next save would write them there.
"""
old_paths = [f"p/labeled-data/vidA/img{i:03d}.png" for i in range(5)]
new_paths = [p for p in old_paths if not p.endswith("img002.png")]

with caplog.at_level(logging.WARNING, logger="napari_deeplabcut.core.remap"):
with pytest.raises(AnnotationFramesMissingError) as excinfo:
remap_layer_data_by_paths(
data=np.array([[2.0, 10.0, 10.0]]),
old_paths=old_paths,
new_paths=new_paths,
time_col=0,
)

assert str(excinfo.value).startswith(LOST_ANNOTATED_FRAMES)
assert "lost their path" in caplog.text


def test_frames_vanishing_without_keypoints_are_not_refused():
"""Only frames carrying keypoints have anything to lose."""
old_paths = [f"p/labeled-data/vidA/img{i:03d}.png" for i in range(5)]
new_paths = [p for p in old_paths if not p.endswith("img002.png")]

res = remap_layer_data_by_paths(
data=np.array([[0.0, 10.0, 10.0]]),
old_paths=old_paths,
new_paths=new_paths,
time_col=0,
)

assert res.accept_paths_update is True


def test_basename_only_match_is_accepted_and_cannot_prove_identity():
"""`LayerLifecycleManager` gates on `dataset_folder` for that reason."""
res = remap_layer_data_by_paths(
data=np.array([[0.0, 1.0, 2.0], [1.0, 3.0, 4.0]], dtype=float),
old_paths=["p/labeled-data/videoA/img000.png", "p/labeled-data/videoA/img001.png"],
new_paths=["p/labeled-data/videoB/img000.png", "p/labeled-data/videoB/img001.png"],
time_col=0,
policy=PathMatchPolicy.ORDERED_DEPTHS,
)

assert res.depth_used == 1
assert res.accept_paths_update is True


def test_no_overlap_remap_refuses_paths_update():
"""The other rejection path callers depend on: nothing matched at any depth."""
res = remap_layer_data_by_paths(
data=np.array([[0.0, 1.0, 2.0]], dtype=float),
old_paths=["A/a/img0.png"],
new_paths=["B/b/other.png"],
time_col=0,
policy=PathMatchPolicy.ORDERED_DEPTHS,
)

assert res.depth_used is None
assert res.accept_paths_update is False


def test_remap_warns_on_low_overlap_ratio(caplog):
caplog.set_level(logging.WARNING)

Expand Down
Loading
Loading