@@ -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" )
4067def 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" )
415460def 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