Skip to content

Commit 72a3a31

Browse files
committed
Add tests for project config/scorer dialogs
Extend dialog tests to cover project config and scorer resolution flows. Adds pytest import, new fake Qt helpers (_FakeMessageBox, _FakeFileDialog, _FakeButton) and a fake_config_prompt_qt fixture that monkeypatches ui_dialogs.QMessageBox and QFileDialog. Introduces tests for load_scorer_from_config (trim, missing, blank), prompt_for_project_config_for_save (skip, cancel, associate valid config, handle missing scorer, unreadable config, custom UI text) and warn_invalid_config_for_scorer. These tests exercise scorer resolution logic and error handling using tmp_path and monkeypatching.
1 parent b80a25f commit 72a3a31

1 file changed

Lines changed: 257 additions & 0 deletions

File tree

src/napari_deeplabcut/_tests/ui/test_dialogs.py

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,22 @@
33
from types import SimpleNamespace
44

55
import numpy as np
6+
import pytest
67
from napari.layers import Image, Points
78
from qtpy.QtCore import QPoint, Qt
89
from qtpy.QtWidgets import QDialog, QLabel, QPlainTextEdit, QPushButton, QScrollArea
910

11+
import napari_deeplabcut.ui.dialogs as ui_dialogs
1012
from napari_deeplabcut.config.keybinds import iter_shortcuts
1113
from napari_deeplabcut.ui.dialogs import (
1214
OverwriteConflictsDialog,
15+
ProjectConfigPromptAction,
1316
ShortcutRow,
1417
Shortcuts,
1518
Tutorial,
19+
load_scorer_from_config,
1620
maybe_confirm_overwrite,
21+
prompt_for_project_config_for_save,
1722
)
1823

1924
# -----------------------------------------------------------------------------
@@ -421,3 +426,255 @@ def fake_confirm(parent, **kwargs):
421426
"affected_text": "3 keypoint overwrite(s) across 2 frame(s)/image(s).",
422427
"details": "img001.png -> nose, tail",
423428
}
429+
430+
431+
# -----------------------------------------------------------------------------
432+
# Project config / scorer resolution dialogs
433+
# -----------------------------------------------------------------------------
434+
435+
436+
class _FakeButton:
437+
def __init__(self, text=None, role=None):
438+
self.text = text
439+
self.role = role
440+
441+
442+
class _FakeMessageBox:
443+
Question = object()
444+
YesRole = object()
445+
NoRole = object()
446+
Cancel = object()
447+
Rejected = 0
448+
449+
planned_click = "yes" # "yes" | "no" | "cancel"
450+
warnings = []
451+
last_instance = None
452+
453+
def __init__(self, parent=None):
454+
self.parent = parent
455+
self._buttons = []
456+
self._clicked = None
457+
self.window_title = None
458+
self.text = None
459+
self.default_button = None
460+
type(self).last_instance = self
461+
462+
def setIcon(self, icon):
463+
self.icon = icon
464+
465+
def setWindowTitle(self, title):
466+
self.window_title = title
467+
468+
def setText(self, text):
469+
self.text = text
470+
471+
def addButton(self, *args):
472+
if len(args) == 2:
473+
text, role = args
474+
btn = _FakeButton(text=text, role=role)
475+
else:
476+
btn = _FakeButton(text="cancel", role=None)
477+
self._buttons.append(btn)
478+
return btn
479+
480+
def setDefaultButton(self, btn):
481+
self.default_button = btn
482+
483+
def exec_(self):
484+
if self.planned_click == "cancel":
485+
self._clicked = None
486+
return self.Rejected
487+
488+
if self.planned_click == "no":
489+
self._clicked = next((b for b in self._buttons if b.role is self.NoRole), None)
490+
return 1
491+
492+
self._clicked = next((b for b in self._buttons if b.role is self.YesRole), None)
493+
return 1
494+
495+
def clickedButton(self):
496+
return self._clicked
497+
498+
@staticmethod
499+
def warning(parent, title, text):
500+
_FakeMessageBox.warnings.append((title, text))
501+
502+
503+
class _FakeFileDialog:
504+
next_result = ("", "")
505+
calls = []
506+
507+
@staticmethod
508+
def getOpenFileName(*args, **kwargs):
509+
_FakeFileDialog.calls.append((args, kwargs))
510+
return _FakeFileDialog.next_result
511+
512+
513+
@pytest.fixture
514+
def fake_config_prompt_qt(monkeypatch):
515+
_FakeMessageBox.planned_click = "yes"
516+
_FakeMessageBox.warnings = []
517+
_FakeMessageBox.last_instance = None
518+
_FakeFileDialog.next_result = ("", "")
519+
_FakeFileDialog.calls = []
520+
monkeypatch.setattr(ui_dialogs, "QMessageBox", _FakeMessageBox)
521+
monkeypatch.setattr(ui_dialogs, "QFileDialog", _FakeFileDialog)
522+
return _FakeMessageBox, _FakeFileDialog
523+
524+
525+
def test_load_scorer_from_config_returns_trimmed_scorer(tmp_path):
526+
cfg = tmp_path / "config.yaml"
527+
cfg.write_text("scorer: ' John '\n", encoding="utf-8")
528+
529+
scorer = load_scorer_from_config(cfg)
530+
531+
assert scorer == "John"
532+
533+
534+
def test_load_scorer_from_config_returns_none_when_missing(tmp_path):
535+
cfg = tmp_path / "config.yaml"
536+
cfg.write_text("dotsize: 5\npcutoff: 0.6\n", encoding="utf-8")
537+
538+
scorer = load_scorer_from_config(cfg)
539+
540+
assert scorer is None
541+
542+
543+
def test_load_scorer_from_config_returns_none_when_blank(tmp_path):
544+
cfg = tmp_path / "config.yaml"
545+
cfg.write_text("scorer: ' '\n", encoding="utf-8")
546+
547+
scorer = load_scorer_from_config(cfg)
548+
549+
assert scorer is None
550+
551+
552+
def test_prompt_for_project_config_for_save_returns_skip_when_user_chooses_no(fake_config_prompt_qt):
553+
fake_messagebox, fake_filedialog = fake_config_prompt_qt
554+
fake_messagebox.planned_click = "no"
555+
556+
result = prompt_for_project_config_for_save(parent=None)
557+
558+
assert result.action is ProjectConfigPromptAction.SKIP
559+
assert result.config_path is None
560+
assert result.scorer is None
561+
assert fake_filedialog.calls == []
562+
563+
564+
def test_prompt_for_project_config_for_save_returns_cancel_when_messagebox_cancelled(fake_config_prompt_qt):
565+
fake_messagebox, fake_filedialog = fake_config_prompt_qt
566+
fake_messagebox.planned_click = "cancel"
567+
568+
result = prompt_for_project_config_for_save(parent=None)
569+
570+
assert result.action is ProjectConfigPromptAction.CANCEL
571+
assert result.config_path is None
572+
assert result.scorer is None
573+
assert fake_filedialog.calls == []
574+
575+
576+
def test_prompt_for_project_config_for_save_resolve_scorer_valid_config(fake_config_prompt_qt, tmp_path):
577+
fake_messagebox, fake_filedialog = fake_config_prompt_qt
578+
fake_messagebox.planned_click = "yes"
579+
580+
cfg = tmp_path / "config.yaml"
581+
cfg.write_text("scorer: John\n", encoding="utf-8")
582+
fake_filedialog.next_result = (str(cfg), "DeepLabCut config (config.yaml)")
583+
584+
result = prompt_for_project_config_for_save(parent=None, resolve_scorer=True)
585+
586+
assert result.action is ProjectConfigPromptAction.ASSOCIATE
587+
assert result.config_path == str(cfg)
588+
assert result.scorer == "John"
589+
assert fake_messagebox.warnings == []
590+
591+
592+
def test_prompt_for_project_config_for_save_resolve_scorer_invalid_config_missing_scorer(
593+
fake_config_prompt_qt,
594+
tmp_path,
595+
):
596+
fake_messagebox, fake_filedialog = fake_config_prompt_qt
597+
fake_messagebox.planned_click = "yes"
598+
599+
cfg = tmp_path / "config.yaml"
600+
cfg.write_text("dotsize: 8\n", encoding="utf-8")
601+
fake_filedialog.next_result = (str(cfg), "DeepLabCut config (config.yaml)")
602+
603+
result = prompt_for_project_config_for_save(parent=None, resolve_scorer=True)
604+
605+
assert result.action is ProjectConfigPromptAction.CANCEL
606+
assert result.config_path is None
607+
assert result.scorer is None
608+
609+
assert len(fake_messagebox.warnings) == 1
610+
title, text = fake_messagebox.warnings[0]
611+
assert title == "Invalid project configuration"
612+
assert "does not define a valid non-empty 'scorer' field" in text
613+
assert str(cfg) in text
614+
615+
616+
def test_prompt_for_project_config_for_save_resolve_scorer_unreadable_config(
617+
monkeypatch,
618+
fake_config_prompt_qt,
619+
tmp_path,
620+
):
621+
fake_messagebox, fake_filedialog = fake_config_prompt_qt
622+
fake_messagebox.planned_click = "yes"
623+
624+
cfg = tmp_path / "config.yaml"
625+
cfg.write_text("scorer: John\n", encoding="utf-8")
626+
fake_filedialog.next_result = (str(cfg), "DeepLabCut config (config.yaml)")
627+
628+
def _boom(*args, **kwargs):
629+
raise ValueError("bad yaml")
630+
631+
monkeypatch.setattr(ui_dialogs, "load_scorer_from_config", _boom)
632+
633+
result = prompt_for_project_config_for_save(parent=None, resolve_scorer=True)
634+
635+
assert result.action is ProjectConfigPromptAction.CANCEL
636+
assert result.config_path is None
637+
assert result.scorer is None
638+
639+
assert len(fake_messagebox.warnings) == 1
640+
title, text = fake_messagebox.warnings[0]
641+
assert title == "Invalid project configuration"
642+
assert "could not be read as a DeepLabCut config.yaml" in text
643+
assert str(cfg) in text
644+
645+
646+
def test_prompt_for_project_config_for_save_uses_custom_text(fake_config_prompt_qt):
647+
fake_messagebox, _ = fake_config_prompt_qt
648+
fake_messagebox.planned_click = "cancel"
649+
650+
prompt_for_project_config_for_save(
651+
parent=None,
652+
window_title="Locate config",
653+
message="Pick a config for scorer resolution",
654+
choose_button_text="Browse…",
655+
skip_button_text="Continue without config",
656+
)
657+
658+
inst = fake_messagebox.last_instance
659+
assert inst is not None
660+
assert inst.window_title == "Locate config"
661+
assert inst.text == "Pick a config for scorer resolution"
662+
assert [b.text for b in inst._buttons[:2]] == ["Browse…", "Continue without config"]
663+
664+
665+
def test_warn_invalid_config_for_scorer_auto_found_unreadable(fake_config_prompt_qt):
666+
fake_messagebox, _ = fake_config_prompt_qt
667+
668+
ui_dialogs.warn_invalid_config_for_scorer(
669+
parent=None,
670+
config_path="/tmp/config.yaml",
671+
reason="unreadable",
672+
auto_found=True,
673+
)
674+
675+
assert len(fake_messagebox.warnings) == 1
676+
title, text = fake_messagebox.warnings[0]
677+
assert title == "Invalid project configuration"
678+
assert "found automatically" in text
679+
assert "could not be read" in text
680+
assert "/tmp/config.yaml" in text

0 commit comments

Comments
 (0)