diff --git a/ankihub/gui/overlay_dialog.py b/ankihub/gui/overlay_dialog.py index 5bc4c1a5a..219c2a9c6 100644 --- a/ankihub/gui/overlay_dialog.py +++ b/ankihub/gui/overlay_dialog.py @@ -18,7 +18,7 @@ class OverlayTarget: - def __init__(self, parent: QWidget, element: Union[QWidget, QRect]) -> None: + def __init__(self, parent: QWidget, element: Optional[Union[QWidget, QRect]]) -> None: self.parent = parent self.element = element @@ -35,7 +35,9 @@ def window(self) -> Optional[QWidget]: return self.element.window() return None - def rect(self) -> QRect: + def rect(self) -> Optional[QRect]: + if self.element is None: + return None if isinstance(self.element, QWidget): geom = self.element.rect() top_left = self.element.mapToGlobal(geom.topLeft()) diff --git a/ankihub/gui/tutorial.py b/ankihub/gui/tutorial.py index bce16d96f..bff5fd263 100644 --- a/ankihub/gui/tutorial.py +++ b/ankihub/gui/tutorial.py @@ -261,6 +261,8 @@ def on_position(self) -> None: return rect = self.target.rect() + if rect is None: + return webview_top_left = self.web.mapFromGlobal(rect.topLeft()) webview_bottom_right = self.web.mapFromGlobal(rect.bottomRight()) top = webview_top_left.y() @@ -403,7 +405,7 @@ def __post_init__(self): @dataclass class QtTutorialStep(TutorialStep): parent_widget: Optional[Union[QWidget, Callable[[], QWidget]]] = None - qt_target: Optional[Union[OverlayTarget, Callable[[], OverlayTarget]]] = None + qt_target: Optional[Union[OverlayTarget, Callable[[], Optional[OverlayTarget]]]] = None target_outline: bool = True apply_backdrop: bool = False @@ -534,7 +536,10 @@ def show_current(self) -> None: overlay.show() step.tooltip_context = overlay step.target_context = overlay - step.target = "#target" + # When the Qt target is unavailable (e.g. transient Browser/widget lifecycle race), + # render this step as tooltip-only instead of anchoring to a missing target. + step.target = "#target" if target is not None else "" + step.click_target = step.click_target if target is not None else "" def close_overlay() -> None: overlay.close() @@ -1434,6 +1439,21 @@ def _close_browser_and_move_to_next_step(self, on_done: Callable[[], None]) -> N browser.close() self.next() + def _get_smart_search_button_target(self) -> Optional[OverlayTarget]: + browser = self._get_live_browser() + if not browser: + return None + + smart_search_button = browser.findChild(QToolButton, "AnkiHubSmartSearchButton") + if smart_search_button is None: + LOGGER.debug("Smart Search tutorial target unavailable: button not found") + return None + if sip.isdeleted(smart_search_button): + LOGGER.debug("Smart Search tutorial target unavailable: button deleted") + return None + + return OverlayTarget(browser.sidebar, smart_search_button) + def _steps(self) -> list[TutorialStep]: steps = [] @@ -1484,10 +1504,8 @@ def _steps(self) -> list[TutorialStep]: steps.append( QtTutorialStep( body=body, - qt_target=lambda: OverlayTarget( - self._browser.sidebar, self._browser.findChild(QToolButton, "AnkiHubSmartSearchButton") - ), - parent_widget=lambda: self._browser, + qt_target=self._get_smart_search_button_target, + parent_widget=lambda: self._get_live_browser(), ) ) diff --git a/tests/addon/test_integration.py b/tests/addon/test_integration.py index c89c20275..9a7a23aad 100644 --- a/tests/addon/test_integration.py +++ b/tests/addon/test_integration.py @@ -47,7 +47,7 @@ overview_will_render_bottom, ) from aqt.importing import AnkiPackageImporter -from aqt.qt import QAction, QDialog, QEvent, QLabel, Qt, QUrl, QWebEnginePage, QWidget, sip +from aqt.qt import QAction, QDialog, QEvent, QLabel, Qt, QToolButton, QUrl, QWebEnginePage, QWidget, sip from aqt.theme import theme_manager from aqt.webview import AnkiWebView from pytest import fixture @@ -12972,6 +12972,46 @@ def test_init_raises_when_anking_deck_not_installed( with pytest.raises(AssertionError): StepDeckTutorial() + def test_get_smart_search_button_target_returns_none_when_button_missing( + self, + anki_session_with_addon_data: AnkiSession, + ): + """Regression: tutorial target helper should handle missing Smart Search button.""" + from ankihub.gui.tutorial import StepDeckTutorial + + with anki_session_with_addon_data.profile_loaded(): + tutorial = StepDeckTutorial.__new__(StepDeckTutorial) + browser = Mock(sidebar=Mock()) + browser.findChild.return_value = None + tutorial._browser = browser + + assert tutorial._get_smart_search_button_target() is None + + def test_get_smart_search_button_target_uses_live_browser( + self, + anki_session_with_addon_data: AnkiSession, + mocker: MockerFixture, + ): + """Regression: target helper should resolve from the live Browser reference.""" + from ankihub.gui import tutorial as tutorial_module + from ankihub.gui.overlay_dialog import OverlayTarget + from ankihub.gui.tutorial import StepDeckTutorial + + with anki_session_with_addon_data.profile_loaded(): + tutorial = StepDeckTutorial.__new__(StepDeckTutorial) + live_browser = Mock(sidebar=Mock()) + smart_search_button = Mock(spec=QToolButton) + live_browser.findChild.return_value = smart_search_button + tutorial._browser = None + mocker.patch.object(tutorial, "_get_live_browser", return_value=live_browser) + mocker.patch.object(tutorial_module.sip, "isdeleted", return_value=False) + + target = tutorial._get_smart_search_button_target() + + assert isinstance(target, OverlayTarget) + assert target.parent is live_browser.sidebar + assert target.element is smart_search_button + def test_hook_browser_startup_does_not_crash_when_browser_missing( self, anki_session_with_addon_data: AnkiSession,