Skip to content

Commit db11d79

Browse files
authored
Merge pull request #182 from DeepLabCut/cy/refactor-ux-tweaks
Plugin refactor [6]: small UX improvements (dotsize, % labeled, folder name)
2 parents 0f62f80 + ffbe7df commit db11d79

File tree

14 files changed

+1863
-90
lines changed

14 files changed

+1863
-90
lines changed

src/napari_deeplabcut/_tests/core/test_config_sync.py

Lines changed: 437 additions & 0 deletions
Large diffs are not rendered by default.

src/napari_deeplabcut/_tests/e2e/test_routing_and_provenance.py

Lines changed: 211 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,33 @@ def forbid_project_config_dialog(monkeypatch):
3636
)
3737

3838

39+
@pytest.fixture
40+
def skip_project_config_dialog(monkeypatch):
41+
"""
42+
Simulate the new promotion policy when no config.yaml exists.
43+
44+
The save flow now asks whether the user wants to locate a DLC config.yaml
45+
before falling back to sidecar/manual scorer entry. In these no-config e2e
46+
scenarios, emulate the user explicitly choosing to continue without config.
47+
"""
48+
from napari_deeplabcut.ui import dialogs as ui_dialogs
49+
50+
calls = {"count": 0, "kwargs": None}
51+
52+
def _skip(*args, **kwargs):
53+
calls["count"] += 1
54+
calls["kwargs"] = kwargs
55+
return ui_dialogs.ProjectConfigPromptResult(
56+
action=ui_dialogs.ProjectConfigPromptAction.SKIP,
57+
)
58+
59+
monkeypatch.setattr(
60+
"napari_deeplabcut._widgets.ui_dialogs.prompt_for_project_config_for_save",
61+
_skip,
62+
)
63+
return calls
64+
65+
3966
@pytest.mark.usefixtures("qtbot")
4067
def test_save_routes_to_correct_gt_when_multiple_gt_exist(
4168
viewer, keypoint_controls, qtbot, tmp_path, overwrite_confirm
@@ -302,12 +329,14 @@ def test_config_first_save_writes_gt_into_dataset_folder(viewer, keypoint_contro
302329

303330

304331
@pytest.mark.usefixtures("qtbot")
305-
def test_promotion_first_save_prompts_and_creates_sidecar(
306-
viewer, keypoint_controls, qtbot, tmp_path, inputdialog, forbid_project_config_dialog
332+
def test_promotion_first_save_skip_config_then_prompt_scorer_and_create_sidecar(
333+
viewer, keypoint_controls, qtbot, tmp_path, inputdialog, skip_project_config_dialog
307334
):
308335
"""
309336
First save on a machine/prediction layer (no config.yaml, no sidecar):
310-
- prompts for scorer
337+
- offers project-config lookup first
338+
- user continues without config
339+
- then prompts for scorer
311340
- writes .napari-deeplabcut.json sidecar
312341
- creates CollectedData_<scorer>.h5
313342
- does NOT modify machinelabels-iter0.h5
@@ -348,6 +377,13 @@ def test_promotion_first_save_prompts_and_creates_sidecar(
348377
qtbot.wait(200)
349378
assert "save_target" in machine_layer.metadata, machine_layer.metadata.keys()
350379

380+
keypoint_controls._save_layers_dialog(selected=True)
381+
qtbot.wait(200)
382+
383+
assert skip_project_config_dialog["count"] == 1
384+
assert skip_project_config_dialog["kwargs"]["resolve_scorer"] is True
385+
assert "save_target" in machine_layer.metadata, machine_layer.metadata.keys()
386+
351387
# Sidecar created
352388
sidecar = labeled_folder / ".napari-deeplabcut.json"
353389
assert sidecar.exists()
@@ -363,12 +399,14 @@ def test_promotion_first_save_prompts_and_creates_sidecar(
363399

364400

365401
@pytest.mark.usefixtures("qtbot")
366-
def test_promotion_second_save_uses_sidecar_no_prompt(
367-
viewer, keypoint_controls, qtbot, tmp_path, inputdialog, forbid_project_config_dialog
402+
def test_promotion_second_save_skip_config_then_use_sidecar_without_scorer_prompt(
403+
viewer, keypoint_controls, qtbot, tmp_path, inputdialog, skip_project_config_dialog
368404
):
369405
"""
370-
After sidecar exists, saving again must not prompt:
371-
- QInputDialog.getText not called
406+
After sidecar exists, saving again with no config.yaml available:
407+
- offers project-config lookup first
408+
- user continues without config
409+
- QInputDialog.getText not called because sidecar provides scorer
372410
- writes/updates same CollectedData_<scorer>.h5
373411
- machine file unchanged
374412
"""
@@ -410,6 +448,13 @@ def test_promotion_second_save_uses_sidecar_no_prompt(
410448
machine_post = pd.read_hdf(machine_path, key="keypoints")
411449
pd.testing.assert_frame_equal(machine_pre, machine_post)
412450

451+
controls._save_layers_dialog(selected=True)
452+
qtbot.wait(200)
453+
454+
assert skip_project_config_dialog["count"] == 1
455+
assert skip_project_config_dialog["kwargs"]["resolve_scorer"] is True
456+
assert inputdialog.calls == 0
457+
413458

414459
@pytest.mark.usefixtures("qtbot")
415460
def test_projectless_folder_save_can_associate_with_config_and_coerce_paths_to_dlc_row_keys(
@@ -632,3 +677,162 @@ def test_projectless_folder_save_refuses_when_target_dataset_folder_already_cont
632677

633678
# No GT should be created in the external folder because association was refused.
634679
assert not (external_folder / "CollectedData_John.h5").exists()
680+
681+
682+
@pytest.mark.usefixtures("qtbot")
683+
def test_promotion_nearby_config_wins_no_dialog_no_prompt(
684+
viewer,
685+
keypoint_controls,
686+
qtbot,
687+
tmp_path,
688+
monkeypatch,
689+
inputdialog,
690+
):
691+
"""
692+
If a valid DLC config.yaml is discoverable near a machine-labeled layer,
693+
promotion must use the scorer from that config without showing either:
694+
- the project-config selection dialog
695+
- the manual scorer prompt
696+
697+
Sidecar, if present, must be ignored in favor of config.yaml.
698+
"""
699+
project, config_path, labeled_folder, _gt_paths, machine_path = _make_dlc_project_with_multiple_gt(
700+
tmp_path, scorers=("John", "Jane"), with_machine=True
701+
)
702+
assert machine_path is not None
703+
704+
# Create a conflicting sidecar scorer to prove config.yaml wins.
705+
sidecar = labeled_folder / ".napari-deeplabcut.json"
706+
sidecar.write_text('{"schema_version": 1, "default_scorer": "Alice"}', encoding="utf-8")
707+
708+
machine_pre = pd.read_hdf(machine_path, key="keypoints")
709+
710+
dialog_calls = {"count": 0}
711+
712+
def _unexpected_config_dialog(*args, **kwargs):
713+
dialog_calls["count"] += 1
714+
pytest.fail("Config-selection dialog must not appear when nearby config.yaml is auto-discovered.")
715+
716+
monkeypatch.setattr(
717+
"napari_deeplabcut._widgets.ui_dialogs.prompt_for_project_config_for_save",
718+
_unexpected_config_dialog,
719+
)
720+
721+
# Manual scorer prompt must not be used either.
722+
inputdialog.forbid()
723+
724+
viewer.open(str(labeled_folder), plugin="napari-deeplabcut")
725+
qtbot.waitUntil(lambda: len(viewer.layers) >= 2, timeout=10_000)
726+
qtbot.wait(200)
727+
728+
pts_layers = [ly for ly in viewer.layers if isinstance(ly, Points)]
729+
machine_layer = next(p for p in pts_layers if p.name == machine_path.stem)
730+
731+
store = keypoint_controls._stores.get(machine_layer)
732+
assert store is not None
733+
_set_or_add_bodypart_xy(machine_layer, store, "bodypart2", x=54.0, y=43.0)
734+
735+
viewer.layers.selection.active = machine_layer
736+
keypoint_controls.viewer.layers.selection.active = machine_layer
737+
keypoint_controls.viewer.layers.selection.select_only(machine_layer)
738+
739+
keypoint_controls._save_layers_dialog(selected=True)
740+
qtbot.wait(300)
741+
742+
assert dialog_calls["count"] == 0
743+
assert inputdialog.calls == 0
744+
assert "save_target" in machine_layer.metadata, machine_layer.metadata.keys()
745+
746+
# Config scorer must win over sidecar scorer.
747+
expected_gt = labeled_folder / "CollectedData_John.h5"
748+
unexpected_gt = labeled_folder / "CollectedData_Alice.h5"
749+
assert expected_gt.exists(), f"Expected GT with config scorer to be created: {expected_gt}"
750+
assert not unexpected_gt.exists(), f"Sidecar scorer must be ignored when config.yaml is nearby: {unexpected_gt}"
751+
752+
machine_post = pd.read_hdf(machine_path, key="keypoints")
753+
pd.testing.assert_frame_equal(machine_pre, machine_post)
754+
755+
756+
@pytest.mark.usefixtures("qtbot")
757+
def test_promotion_selected_external_config_wins_no_scorer_prompt(
758+
viewer,
759+
keypoint_controls,
760+
qtbot,
761+
tmp_path,
762+
monkeypatch,
763+
inputdialog,
764+
):
765+
"""
766+
If no nearby config.yaml is found, but the user points the save flow to a
767+
valid external DLC config.yaml, promotion must use that config scorer and
768+
must not show the manual scorer prompt.
769+
770+
Sidecar, if present, must be ignored in favor of the user-selected config.
771+
"""
772+
labeled_folder = _make_labeled_folder_with_machine_only(tmp_path)
773+
machine_path = labeled_folder / "machinelabels-iter0.h5"
774+
machine_pre = pd.read_hdf(machine_path, key="keypoints")
775+
776+
# External DLC project whose config scorer should be used.
777+
external_project, external_config_path, _external_dataset = _make_project_config_and_frames_no_gt(
778+
tmp_path / "extproj"
779+
)
780+
assert external_config_path.exists()
781+
782+
# Create a conflicting sidecar scorer to prove selected config wins.
783+
sidecar = labeled_folder / ".napari-deeplabcut.json"
784+
sidecar.write_text('{"schema_version": 1, "default_scorer": "Alice"}', encoding="utf-8")
785+
786+
from napari_deeplabcut.ui import dialogs as ui_dialogs
787+
788+
dialog_calls = {"count": 0, "kwargs": None}
789+
790+
def _choose_external_config(*args, **kwargs):
791+
dialog_calls["count"] += 1
792+
dialog_calls["kwargs"] = kwargs
793+
return ui_dialogs.ProjectConfigPromptResult(
794+
action=ui_dialogs.ProjectConfigPromptAction.ASSOCIATE,
795+
config_path=str(external_config_path),
796+
scorer="John",
797+
)
798+
799+
monkeypatch.setattr(
800+
"napari_deeplabcut._widgets.ui_dialogs.prompt_for_project_config_for_save",
801+
_choose_external_config,
802+
)
803+
804+
# Manual scorer prompt must not be used when selected config already resolves scorer.
805+
inputdialog.forbid()
806+
807+
viewer.open(str(labeled_folder), plugin="napari-deeplabcut")
808+
qtbot.waitUntil(lambda: len(viewer.layers) >= 2, timeout=10_000)
809+
qtbot.wait(200)
810+
811+
pts_layers = [ly for ly in viewer.layers if isinstance(ly, Points)]
812+
machine_layer = next(p for p in pts_layers if p.name == "machinelabels-iter0")
813+
814+
store = keypoint_controls._stores.get(machine_layer)
815+
assert store is not None
816+
_set_or_add_bodypart_xy(machine_layer, store, "bodypart1", x=91.0, y=82.0)
817+
818+
viewer.layers.selection.active = machine_layer
819+
keypoint_controls.viewer.layers.selection.active = machine_layer
820+
keypoint_controls.viewer.layers.selection.select_only(machine_layer)
821+
822+
keypoint_controls._save_layers_dialog(selected=True)
823+
qtbot.wait(300)
824+
825+
assert dialog_calls["count"] == 1
826+
assert dialog_calls["kwargs"]["resolve_scorer"] is True
827+
assert inputdialog.calls == 0
828+
assert "save_target" in machine_layer.metadata, machine_layer.metadata.keys()
829+
830+
expected_gt = labeled_folder / "CollectedData_John.h5"
831+
unexpected_gt = labeled_folder / "CollectedData_Alice.h5"
832+
assert expected_gt.exists(), f"Expected GT with user-selected config scorer to be created: {expected_gt}"
833+
assert not unexpected_gt.exists(), (
834+
f"Sidecar scorer must be ignored when a valid external config is selected: {unexpected_gt}"
835+
)
836+
837+
machine_post = pd.read_hdf(machine_path, key="keypoints")
838+
pd.testing.assert_frame_equal(machine_pre, machine_post)

0 commit comments

Comments
 (0)