refactor: split responsibilities between MainWindow and ProfileManager

This commit is contained in:
Marvin Kopf 2025-06-02 14:04:02 +02:00
parent 50b7588231
commit 2f51d903af
9 changed files with 519 additions and 236 deletions

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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="{}" {}>{}</button>""".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="{}" {}>{}</button>""".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
##########################################################################

73
qt/aqt/openbackup.py Normal file
View file

@ -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)

163
qt/aqt/profiledialog.py Normal file
View file

@ -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_()

View file

@ -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?

View file

@ -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()

View file

@ -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"],