diff --git a/qt/aqt/__init__.py b/qt/aqt/__init__.py index cdbd05ebe..9b8c48727 100644 --- a/qt/aqt/__init__.py +++ b/qt/aqt/__init__.py @@ -778,6 +778,10 @@ def _run(argv: list[str] | None = None, exec: bool = True) -> AnkiApp | None: ) sys.exit(1) + from aqt.utils import cleanup_and_exit + + cleanup_and_exit.app = app + # load the main window import aqt.main diff --git a/qt/aqt/cleanupandexitusecase.py b/qt/aqt/cleanupandexitusecase.py new file mode 100644 index 000000000..5324b685e --- /dev/null +++ b/qt/aqt/cleanupandexitusecase.py @@ -0,0 +1,42 @@ +import gc +from typing import Callable, List + + +class CleanupAndExitUseCase: + """ + Handles graceful application shutdown by notifying registered subscribers and exiting the app. + """ + + def __init__(self): + self.app = None + self._subscribers: List[Callable[[], None]] = [] + + def subscribe(self, callback: Callable[[], None]): + """ + Registers a callback to be called before application exit. + + Args: + callback: A no-argument function to run before exiting. + """ + self._subscribers.append(callback) + + def unsubscribe(self, callback: Callable[[], None]): + """ + Unregisters a previously registered callback. + + Args: + callback: The function to remove from the subscriber list. + """ + if callback in self._subscribers: + self._subscribers.remove(callback) + + def __call__(self): + """ + Executes all registered callbacks, performs cleanup, and exits the app. + """ + for callback in self._subscribers: + callback() + + gc.collect() + self.app._unset_windows_shutdown_block_reason() + self.app.exit(0) diff --git a/qt/aqt/import_export/importing.py b/qt/aqt/import_export/importing.py index 938824035..a66038b7b 100644 --- a/qt/aqt/import_export/importing.py +++ b/qt/aqt/import_export/importing.py @@ -215,3 +215,24 @@ def import_progress_update(progress: Progress, update: ProgressUpdate) -> None: update.label = progress.importing if update.user_wants_abort: update.abort = True + +from aqt.openbackup import ( + restore_backup, + restore_backup_with_confirm, + choose_and_restore_backup, + ) + +def _restore(path, success, error): + from aqt import mw + + if mw.col: + mw.unloadCollection(lambda: import_collection_package_op( + mw, path, success=success + ).failure(error).run_in_background()) + else: + import_collection_package_op( + mw, path, success=success + ).failure(error).run_in_background() + + +restore_backup.set_restore_func(_restore) diff --git a/qt/aqt/main.py b/qt/aqt/main.py index bc28e287b..062b2a213 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -13,7 +13,6 @@ import traceback import weakref from argparse import Namespace from collections.abc import Callable, Sequence -from concurrent.futures import Future from typing import Any, Literal, TypeVar, cast import anki @@ -78,6 +77,7 @@ from aqt.utils import ( KeyboardModifiersPressed, askUser, checkInvalidFilename, + cleanup_and_exit, current_window, disallow_full_screen, getFile, @@ -220,18 +220,39 @@ class AnkiQt(QMainWindow): # were we given a file to import? if args and args[0] and not self._isAddon(args[0]): self.onAppMsg(args[0]) + # Load profile in a timer so we can let the window finish init and not # close on profile load error. - if is_win: - fn = self.setupProfileAfterWebviewsLoaded - else: - fn = self.setupProfile - def on_window_init() -> None: - fn() - gui_hooks.main_window_did_init() + def run_later(): + try: + if is_win: + self.setupProfileAfterWebviewsLoaded() + else: + self.setupProfile() - self.progress.single_shot(10, on_window_init, False) + gui_hooks.main_window_did_init() + + except Exception: + import traceback + + traceback.print_exc() + + # Schedule actual work + self.progress.single_shot(10, run_later, False) + + on_window_init() + + from aqt.utils import cleanup_and_exit + + def _exit(): + self.errorHandler.unload() + self.mediaServer.shutdown() + # Rust background jobs are not awaited implicitly + self.backend.await_backup_completion() + self.deleteLater() + + cleanup_and_exit.subscribe(_exit) def setupUI(self) -> None: self.col = None @@ -256,6 +277,17 @@ class AnkiQt(QMainWindow): self.setupDeckBrowser() self.setupOverview() self.setupReviewer() + self.setupProfileDialog() + + from aqt.openbackup import choose_and_restore_backup + choose_and_restore_backup.set_choose_path_func(lambda callback: + getFile( + self, + tr.qt_misc_revert_to_backup(), + cb=callback, + filter="*.colpkg", + dir=self.pm.backupFolder(), + )) def finish_ui_setup(self) -> None: "Actions that are deferred until after add-on loading." @@ -268,7 +300,7 @@ class AnkiQt(QMainWindow): if not w._domDone: self.progress.single_shot( 10, - self.setupProfileAfterWebviewsLoaded, + lambda: self.setupProfileAfterWebviewsLoaded(), False, ) return @@ -290,20 +322,6 @@ class AnkiQt(QMainWindow): # Profiles ########################################################################## - class ProfileManager(QMainWindow): - onClose = pyqtSignal() - closeFires = True - - def closeEvent(self, evt: QCloseEvent) -> None: - if self.closeFires: - self.onClose.emit() # type: ignore - evt.accept() - - def closeWithoutQuitting(self) -> None: - self.closeFires = False - self.close() - self.closeFires = True - def setupProfile(self) -> None: if self.pm.meta["firstRun"]: # load the new deck user profile @@ -325,195 +343,41 @@ class AnkiQt(QMainWindow): self.pm.load(name) if not self.pm.name: - self.showProfileManager() + pass else: self.loadProfile() + def setupProfileDialog(self): + from aqt.profiledialog import ProfileDialog + + self.profileDialog = ProfileDialog(self.pm) + def showProfileManager(self) -> None: self.pm.profile = None self.moveToState("profileManager") - d = self.profileDiag = self.ProfileManager() - f = self.profileForm = aqt.forms.profiles.Ui_MainWindow() - f.setupUi(d) - qconnect(f.login.clicked, self.onOpenProfile) - qconnect(f.profiles.itemDoubleClicked, self.onOpenProfile) - qconnect(f.openBackup.clicked, self.onOpenBackup) - qconnect(f.quit.clicked, d.close) - qconnect(d.onClose, self.cleanupAndExit) - qconnect(f.add.clicked, self.onAddProfile) - qconnect(f.rename.clicked, self.onRenameProfile) - qconnect(f.delete_2.clicked, self.onRemProfile) - qconnect(f.profiles.currentRowChanged, self.onProfileRowChange) - f.statusbar.setVisible(False) - qconnect(f.downgrade_button.clicked, self._on_downgrade) - f.downgrade_button.setText(tr.profiles_downgrade_and_quit()) - # enter key opens profile - QShortcut(QKeySequence("Return"), d, activated=self.onOpenProfile) # type: ignore - self.refreshProfilesList() - # raise first, for osx testing - d.show() - d.activateWindow() - d.raise_() - - def refreshProfilesList(self) -> None: - f = self.profileForm - f.profiles.clear() - profs = self.pm.profiles() - f.profiles.addItems(profs) - try: - idx = profs.index(self.pm.name) - except Exception: - idx = 0 - f.profiles.setCurrentRow(idx) - - def onProfileRowChange(self, n: int) -> None: - if n < 0: - # called on .clear() - return - name = self.pm.profiles()[n] - self.pm.load(name) - - def openProfile(self) -> None: - name = self.pm.profiles()[self.profileForm.profiles.currentRow()] - self.pm.load(name) - return - - def onOpenProfile(self, *, callback: Callable[[], None] | None = None) -> None: - def on_done() -> None: - self.profileDiag.closeWithoutQuitting() - if callback: - callback() - - self.profileDiag.hide() - # code flow is confusing here - if load fails, profile dialog - # will be shown again - self.loadProfile(on_done) - - def profileNameOk(self, name: str) -> bool: - return not checkInvalidFilename(name) and name != "addons21" - - def onAddProfile(self) -> None: - name = getOnlyText(tr.actions_name()).strip() - if name: - if name in self.pm.profiles(): - showWarning(tr.qt_misc_name_exists()) - return - if not self.profileNameOk(name): - return - self.pm.create(name) - self.pm.name = name - self.refreshProfilesList() - - def onRenameProfile(self) -> None: - name = getOnlyText(tr.actions_new_name(), default=self.pm.name).strip() - if not name: - return - if name == self.pm.name: - return - if name in self.pm.profiles(): - showWarning(tr.qt_misc_name_exists()) - return - if not self.profileNameOk(name): - return - self.pm.rename(name) - self.refreshProfilesList() - - def onRemProfile(self) -> None: - profs = self.pm.profiles() - if len(profs) < 2: - showWarning(tr.qt_misc_there_must_be_at_least_one()) - return - # sure? - if not askUser( - tr.qt_misc_all_cards_notes_and_media_for2(name=self.pm.name), - msgfunc=QMessageBox.warning, - defaultno=True, - ): - return - self.pm.remove(self.pm.name) - self.refreshProfilesList() + self.profileDialog.show() def _handle_load_backup_success(self) -> None: """ Actions that occur when profile backup has been loaded successfully """ - if self.state == "profileManager": - self.profileDiag.closeWithoutQuitting() - - self.loadProfile() + self.loadCollection() def _handle_load_backup_failure(self, error: Exception) -> None: """ Actions that occur when a profile has loaded unsuccessfully """ showWarning(str(error)) - if self.state != "profileManager": - self.loadProfile() + self.loadCollection() def onOpenBackup(self) -> None: + from aqt.openbackup import choose_and_restore_backup + choose_and_restore_backup(self._handle_load_backup_success, self._handle_load_backup_failure) - def do_open(path: str) -> None: - if not askUser( - tr.qt_misc_replace_your_collection_with_an_earlier2( - os.path.basename(path) - ), - msgfunc=QMessageBox.warning, - defaultno=True, - ): - return - - showInfo(tr.qt_misc_automatic_syncing_and_backups_have_been()) - - # Collection is still loaded if called from main window, so we unload. This is already - # unloaded if called from the ProfileManager window. - if self.col: - self.unloadProfile(lambda: self._start_restore_backup(path)) - return - - self._start_restore_backup(path) - - getFile( - self.profileDiag if self.state == "profileManager" else self, - tr.qt_misc_revert_to_backup(), - cb=do_open, # type: ignore - filter="*.colpkg", - dir=self.pm.backupFolder(), - ) - - def _start_restore_backup(self, path: str): - self.restoring_backup = True - - import_collection_package_op( - self, path, success=self._handle_load_backup_success - ).failure(self._handle_load_backup_failure).run_in_background() - - def _on_downgrade(self) -> None: - self.progress.start() - profiles = self.pm.profiles() - - def downgrade() -> list[str]: - return self.pm.downgrade(profiles) - - def on_done(future: Future) -> None: - self.progress.finish() - problems = future.result() - if not problems: - showInfo("Profiles can now be opened with an older version of Anki.") - else: - showWarning( - "The following profiles could not be downgraded: {}".format( - ", ".join(problems) - ) - ) - return - self.profileDiag.close() - - self.taskman.run_in_background(downgrade, on_done) - - def loadProfile(self, onsuccess: Callable | None = None) -> None: - if not self.loadCollection(): - return + def loadProfile(self) -> None: + self.loadCollection() + def _restoreUI(self): self.setup_sound() self.flags = FlagManager(self) # show main window @@ -537,8 +401,6 @@ class AnkiQt(QMainWindow): def _onsuccess(synced: bool) -> None: if synced: self._refresh_after_sync() - if onsuccess: - onsuccess() if not self.safeMode: self.maybe_check_for_addon_updates(self.setup_auto_update) @@ -568,6 +430,7 @@ class AnkiQt(QMainWindow): refresh_reviewer_on_day_rollover_change() gui_hooks.profile_did_open() + self.moveToState("deckBrowser") self.maybe_auto_sync_on_open_close(_onsuccess) def unloadProfile(self, onsuccess: Callable) -> None: @@ -601,28 +464,11 @@ class AnkiQt(QMainWindow): print(f"Window should have been closed: {w}") def unloadProfileAndExit(self) -> None: - self.unloadProfile(self.cleanupAndExit) + self.unloadProfile(cleanup_and_exit) def unloadProfileAndShowProfileManager(self) -> None: self.unloadProfile(self.showProfileManager) - def cleanupAndExit(self) -> None: - self.errorHandler.unload() - self.mediaServer.shutdown() - # Rust background jobs are not awaited implicitly - self.backend.await_backup_completion() - self.deleteLater() - app = self.app - app._unset_windows_shutdown_block_reason() - - def exit(): - # try to ensure Qt objects are deleted in a logical order, - # to prevent crashes on shutdown - gc.collect() - app.exit(0) - - self.progress.single_shot(100, exit, False) - # Sound/video ########################################################################## @@ -633,7 +479,6 @@ class AnkiQt(QMainWindow): aqt.sound.cleanup_audio() def _add_play_buttons(self, text: str) -> str: - "Return card text with play buttons added, or stripped." if self.col.get_config_bool(Config.Bool.HIDE_AUDIO_PLAY_BUTTONS): return anki.sound.strip_av_refs(text) else: @@ -647,27 +492,33 @@ class AnkiQt(QMainWindow): # Collection load/unload ########################################################################## + def onCollectionLoad(self, col) -> None: + self._restoreUI() + + def onCollectionLoadError(self, e: Exception) -> None: + self.hide() + if "FileTooNew" in str(e): + showWarning( + "This profile requires a newer version of Anki to open. Did you forget to use the Downgrade button prior to switching Anki versions?" + ) + else: + showWarning( + f"{tr.errors_unable_open_collection()}\n{traceback.format_exc()}" + ) + + self.showProfileManager() + def loadCollection(self) -> bool: try: self._loadCollection() except Exception as e: - if "FileTooNew" in str(e): - showWarning( - "This profile requires a newer version of Anki to open. Did you forget to use the Downgrade button prior to switching Anki versions?" - ) - else: - showWarning( - f"{tr.errors_unable_open_collection()}\n{traceback.format_exc()}" - ) # clean up open collection if possible try: self.backend.close_collection(downgrade_to_schema11=False) - except Exception as e: - print("unable to close collection:", e) + except Exception as e_: + print("unable to close collection:", e_) self.col = None - # return to profile manager - self.hide() - self.showProfileManager() + gui_hooks.collection_load_did_fail(e) return False # make sure we don't get into an inconsistent state if an add-on @@ -676,7 +527,7 @@ class AnkiQt(QMainWindow): self.update_undo_actions() gui_hooks.collection_did_load(self.col) self.apply_collection_options() - self.moveToState("deckBrowser") + except Exception as e: # dump error to stderr so it gets picked up by errors.py traceback.print_exc() @@ -986,6 +837,8 @@ title="{}" {}>{}""".format( webview.force_load_hack() gui_hooks.card_review_webview_did_init(self.web, AnkiWebViewKind.MAIN) + gui_hooks.collection_did_load.append(self.onCollectionLoad) + gui_hooks.collection_load_did_fail.append(self.onCollectionLoadError) def closeAllWindows(self, onsuccess: Callable) -> None: aqt.dialogs.closeAll(onsuccess) @@ -1230,15 +1083,9 @@ title="{}" {}>{}""".format( ########################################################################## def closeEvent(self, event: QCloseEvent) -> None: - if self.state == "profileManager": - # if profile manager active, this event may fire via OS X menu bar's - # quit option - self.profileDiag.close() - event.accept() - else: - # ignore the event for now, as we need time to clean up - event.ignore() - self.unloadProfileAndExit() + # ignore the event for now, as we need time to clean up + event.ignore() + self.unloadProfileAndExit() # Undo & autosave ########################################################################## diff --git a/qt/aqt/openbackup.py b/qt/aqt/openbackup.py new file mode 100644 index 000000000..775b33f7d --- /dev/null +++ b/qt/aqt/openbackup.py @@ -0,0 +1,73 @@ +import os + +from PyQt6.QtWidgets import QMessageBox + +from aqt.utils import tr +from aqt.utils import getFile, askUser, showInfo + + +def confirm(path): + return askUser( + tr.qt_misc_replace_your_collection_with_an_earlier2(os.path.basename(path)), + msgfunc=QMessageBox.warning, + defaultno=True, + ) + +def inform(): + showInfo("Automatic syncing and backups have been disabled while restoring. To enable them again, close the profile or restart Anki.") + + +class RestoreBackupUseCase: + def __init__(self): + self.restore_func = lambda path, success, error: error(Exception("No back up function set.")) + self.inform_func = lambda: None + + def set_restore_func(self, restore_func): + self.restore_func = restore_func + + def set_inform_func(self, inform_func): + self.inform_func = inform_func + + def __call__(self, path: str, success, error): + self.inform_func() + self.restore_func(path, success, error) + +class RestoreBackupWithConfirmUseCase: + def __init__(self, open_backup_use_case: RestoreBackupUseCase): + self.confirm_func = lambda path: False + self.open_backup_use_case = open_backup_use_case + + def set_confirm_func(self, confirm_func): + self.confirm_func = confirm_func + + def __call__(self, path: str, success, error): + if self.confirm_func(path): + self.open_backup_use_case(path, success, error) + +class ChooseBackupUseCase: + def __init__(self, restore_use_case: RestoreBackupUseCase): + self.choose_path_func = lambda callback: None + self.confirm_func = lambda path: False + self.restore_use_case = restore_use_case + + def set_choose_path_func(self, choose_path_func): + self.choose_path_func = choose_path_func + + def set_confirm_func(self, confirm_func): + self.confirm_func = confirm_func + + def __call__(self, success, error): + def on_path_chosen(path): + if self.confirm_func(path): + self.restore_use_case(path, success, error) + + self.choose_path_func(on_path_chosen) + + +restore_backup = RestoreBackupUseCase() + +restore_backup_with_confirm = RestoreBackupWithConfirmUseCase(restore_backup) +restore_backup_with_confirm.set_confirm_func(confirm) + +choose_and_restore_backup = ChooseBackupUseCase(restore_backup) +choose_and_restore_backup.set_confirm_func(confirm) diff --git a/qt/aqt/profiledialog.py b/qt/aqt/profiledialog.py new file mode 100644 index 000000000..89ebeaa04 --- /dev/null +++ b/qt/aqt/profiledialog.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from concurrent.futures import Future + +from PyQt6.QtWidgets import QMessageBox, QMainWindow + +from aqt import mw +from aqt.profiles import ProfileManager as ProfileManagerType +from aqt.utils import askUser, checkInvalidFilename, getOnlyText, showInfo, showWarning, tr + + +class ProfileDialog: + _activeProfile: int = 0 + _profiles = [] + + def __init__(self, pm: ProfileManagerType): + self.pm = pm + + def _refreshProfiles(self): + self._profiles = self.pm.profiles() + try: + self._activeProfile = self._profiles.index(self.pm.name) + except Exception: + self._activeProfile = 0 + mw.profileForm.profiles.clear() + mw.profileForm.profiles.addItems(self._profiles) + mw.profileForm.profiles.setCurrentRow(self._activeProfile) + + def _profileNameOk(self, name: str) -> bool: + return not checkInvalidFilename(name) and name != "addons21" + + def _activeProfileName(self): + return self._profiles[self._activeProfile] + + def onOpenProfile(self) -> None: + self.pm.load(self._activeProfileName()) + if mw.loadCollection(): + self.d.hide() + + def onAddProfile(self) -> None: + name = getOnlyText(tr.actions_name()).strip() + if name: + if name in self._profiles: + showWarning(tr.qt_misc_name_exists()) + return + if not self._profileNameOk(name): + return + self.pm.create(name) + self.pm.name = name + self._refreshProfiles() + + def onOpenBackup(self) -> None: + from aqt.openbackup import ( + restore_backup, + restore_backup_with_confirm, + choose_and_restore_backup + ) + + def success(): + self.pm.load(self._activeProfileName()) + if mw.loadCollection(): + self.d.hide() + + def error(e: Exception): + showWarning("Backup could not be restored\n" + str(e)) + + choose_and_restore_backup(success, error) + + def onQuit(self) -> None: + from aqt.utils import cleanup_and_exit + + cleanup_and_exit() + + def onRenameProfile(self) -> None: + name = getOnlyText( + tr.actions_new_name(), default=self._activeProfileName() + ).strip() + if not name: + return + if name == self._activeProfileName(): + return + if name in self._profiles: + showWarning(tr.qt_misc_name_exists()) + return + if not self._profileNameOk(name): + return + self.pm.rename(name) + self._refreshProfiles() + + def onRemProfile(self) -> None: + if len(self._profiles) < 2: + showWarning(tr.qt_misc_there_must_be_at_least_one()) + return + # sure? + if not askUser( + tr.qt_misc_all_cards_notes_and_media_for2(name=self._activeProfileName()), + msgfunc=QMessageBox.warning, + defaultno=True, + ): + return + self.pm.remove(self._activeProfileName()) + self._refreshProfiles() + + def onProfileRowChange(self, n: int) -> None: + if n < 0: + # called on .clear() + return + + self._activeProfile = n + + def onDowngrade(self) -> None: + mw.progress.start() + profiles = mw.pm.profiles() + + def downgrade() -> list[str]: + return mw.pm.downgrade(profiles) + + def on_done(future: Future) -> None: + mw.progress.finish() + problems = future.result() + if not problems: + showInfo("Profiles can now be opened with an older version of Anki.") + else: + showWarning( + "The following profiles could not be downgraded: {}".format( + ", ".join(problems) + ) + ) + return + from aqt.utils import cleanup_and_exit + + cleanup_and_exit() + + mw.taskman.run_in_background(downgrade, on_done) + + def show(self): + import aqt + from aqt.qt import QKeySequence, QShortcut, qconnect + from aqt.utils import tr + + d = self.d = QMainWindow() + f = mw.profileForm = aqt.forms.profiles.Ui_MainWindow() + f.setupUi(d) + qconnect(f.login.clicked, self.onOpenProfile) + qconnect(f.profiles.itemDoubleClicked, self.onOpenProfile) + qconnect(f.openBackup.clicked, self.onOpenBackup) + qconnect(f.quit.clicked, self.onQuit) + qconnect(f.add.clicked, self.onAddProfile) + qconnect(f.rename.clicked, self.onRenameProfile) + d.closeEvent = lambda ev: self.onQuit() + qconnect(f.delete_2.clicked, self.onRemProfile) + qconnect(f.profiles.currentRowChanged, self.onProfileRowChange) + f.statusbar.setVisible(False) + qconnect(f.downgrade_button.clicked, self.onDowngrade) + f.downgrade_button.setText(tr.profiles_downgrade_and_quit()) + # enter key opens profile + QShortcut(QKeySequence("Return"), d, activated=self.onOpenProfile) # type: ignore + self._refreshProfiles() + # raise first, for osx testing + d.show() + d.activateWindow() + d.raise_() + diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index 6ae8bace8..64083f39e 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -28,6 +28,7 @@ from anki.utils import ( no_bundled_libs, version_with_build, ) +from aqt.cleanupandexitusecase import CleanupAndExitUseCase from aqt.qt import * from aqt.qt import ( PYQT_VERSION_STR, @@ -85,6 +86,8 @@ from aqt.theme import theme_manager if TYPE_CHECKING: TextFormat = Literal["plain", "rich", "markdown"] +cleanup_and_exit = CleanupAndExitUseCase() + def aqt_data_path() -> Path: # packaged? diff --git a/qt/tests/test_profiledialog.py b/qt/tests/test_profiledialog.py new file mode 100644 index 000000000..e3cba42ed --- /dev/null +++ b/qt/tests/test_profiledialog.py @@ -0,0 +1,125 @@ +import importlib +import sys +import os + +# Add the root project directory to sys.path + +import unittest +from unittest.mock import Mock, patch, call + + + +class TestProfileDialog(unittest.TestCase): + def setUp(self): + self.pm = Mock() + self.pm.profiles.return_value = ["user1", "user2"] + self.pm.name = "user1" + + # Patch mw and profileForm + patcher1 = patch("aqt.mw") + + self.mock_mw = patcher1.start() + self.addCleanup(patcher1.stop) + if "aqt.profiledialog" in sys.modules: + importlib.reload(sys.modules["aqt.profiledialog"]) + else: + importlib.import_module("aqt.profiledialog") + + from aqt.profiledialog import ProfileDialog + + # mock profileForm + self.mock_profiles = Mock() + self.mock_mw.profileForm.profiles = self.mock_profiles + + # other mocks + self.mock_mw.profileNameOk.return_value = True + self.mock_mw.pm = self.pm + self.mock_mw.taskman = Mock() + self.mock_mw.progress = Mock() + + self.dialog = ProfileDialog(self.pm) + + def test_refresh_profiles_sets_correct_index(self): + self.dialog._refreshProfiles() + + self.assertEqual(self.dialog._activeProfile, 0) + self.mock_profiles.clear.assert_called_once() + self.mock_profiles.addItems.assert_called_once_with(["user1", "user2"]) + self.mock_profiles.setCurrentRow.assert_called_once_with(0) + + def test_active_profile_name(self): + self.dialog._profiles = ["x", "y"] + self.dialog._activeProfile = 1 + self.assertEqual(self.dialog._activeProfileName(), "y") + + def test_on_open_profile_calls_load(self): + self.dialog._profiles = ["user1"] + self.dialog._activeProfile = 0 + self.dialog.onOpenProfile() + self.pm.load.assert_called_once_with("user1") + self.mock_mw.loadCollection.assert_called_once() + + def test_on_add_profile_adds_new_profile(self): + self.dialog._profiles = ["user1"] + with patch("aqt.profiledialog.getOnlyText", return_value="newuser"), \ + patch("aqt.profiledialog.tr") as mock_tr: + mock_tr.actions_name.return_value = "Enter name" + self.dialog.onAddProfile() + self.pm.create.assert_called_once_with("newuser") + self.assertEqual(self.pm.name, "newuser") + + def test_on_add_profile_duplicate(self): + self.dialog._profiles = ["user1", "dupe"] + with patch("aqt.profiledialog.getOnlyText", return_value="dupe"), \ + patch("aqt.profiledialog.tr") as mock_tr, \ + patch("aqt.profiledialog.showWarning") as mock_warn: + mock_tr.actions_name.return_value = "Enter name" + mock_tr.qt_misc_name_exists.return_value = "Name exists" + self.dialog.onAddProfile() + mock_warn.assert_called_once_with("Name exists") + + def test_on_rename_profile_valid(self): + self.dialog._profiles = ["user1"] + self.dialog._activeProfile = 0 + with patch("aqt.profiledialog.getOnlyText", return_value="renamed"), \ + patch("aqt.profiledialog.tr") as mock_tr: + mock_tr.actions_new_name.return_value = "New name" + self.dialog.onRenameProfile() + self.pm.rename.assert_called_once_with("renamed") + + def test_on_rem_profile_confirms_and_removes(self): + self.dialog._profiles = ["user1", "user2"] + self.dialog._activeProfile = 0 + with patch("aqt.profiledialog.tr") as mock_tr, \ + patch("aqt.profiledialog.askUser", return_value=True): + mock_tr.qt_misc_all_cards_notes_and_media_for2.return_value = "Delete?" + self.dialog.onRemProfile() + self.pm.remove.assert_called_once_with("user1") + + def test_on_profile_row_change_updates_index(self): + self.dialog._activeProfile = 0 + self.dialog.onProfileRowChange(1) + self.assertEqual(self.dialog._activeProfile, 1) + + def test_on_profile_row_change_negative_does_not_update(self): + self.dialog._activeProfile = 0 + self.dialog.onProfileRowChange(-1) + self.assertEqual(self.dialog._activeProfile, 0) + + def test_on_downgrade_runs_task(self): + self.mock_mw.pm.downgrade.return_value = [] + future = Mock() + future.result.return_value = [] + on_done = None + + def run_in_background(fn, cb): + nonlocal on_done + on_done = cb + return fn() + + self.mock_mw.taskman.run_in_background.side_effect = run_in_background + self.dialog.onDowngrade() + self.assertTrue(callable(on_done)) + on_done(Mock(result=lambda: [])) + self.mock_mw.progress.start.assert_called() + self.mock_mw.progress.finish.assert_called() diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index 33838c46b..a7b46db40 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -40,6 +40,11 @@ from aqt.undo import UndoActionsInfo hooks = [ # Reviewing ################### + Hook( + name="collection_load_did_fail", + args=["e: Exception"], + doc="""Called when the collection could not be loaded.""", + ), Hook( name="overview_did_refresh", args=["overview: aqt.overview.Overview"],