diff --git a/Furious/Actions/Import.py b/Furious/Actions/Import.py index 6672edcf..4589f749 100644 --- a/Furious/Actions/Import.py +++ b/Furious/Actions/Import.py @@ -304,8 +304,8 @@ class ImportURIsProgressDialog(AppQTransientDialog): def retranslate(self): """Refresh translated text for the import ur is progress dialog.""" - self.setWindowTitle(_(self.windowTitle())) - self.cancelButton.setText(_(self.cancelButton.text())) + super().retranslate() + self.updateStatus() @@ -316,14 +316,10 @@ class MBoxImportError(AppQMessageBox): """Initialize the MBoxImportError.""" super().__init__(*args, **kwargs) - def retranslate(self): + def _retranslateContent(self): """Refresh translated text for the m box import error.""" self.setText(_(self.text())) - # Ignore informative text, buttons - - self.moveToCenter() - class MBoxImportMultiSuccess(AppQMessageBox): """Represent m box import multi success.""" @@ -358,14 +354,10 @@ class MBoxImportMultiSuccess(AppQMessageBox): # Limited return _('Import share link success') + f'\n\n...' - def retranslate(self): + def _retranslateContent(self): """Refresh translated text for the m box import multi success.""" self.setText(self.customText()) - # Ignore informative text, buttons - - self.moveToCenter() - class MBoxImportSuccess(AppQMessageBox): """Represent m box import success.""" @@ -383,14 +375,10 @@ class MBoxImportSuccess(AppQMessageBox): else: return _('Import success') - def retranslate(self): + def _retranslateContent(self): """Refresh translated text for the m box import success.""" self.setText(self.customText()) - # Ignore informative text, buttons - - self.moveToCenter() - class ImportFromFileAction(AppQAction): """Handle the import from file action.""" diff --git a/Furious/Backends/Xray/AssetListView.py b/Furious/Backends/Xray/AssetListView.py index 80a97f08..ac0f153d 100644 --- a/Furious/Backends/Xray/AssetListView.py +++ b/Furious/Backends/Xray/AssetListView.py @@ -49,14 +49,10 @@ class MBoxAssetExists(AppQMessageBox): AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No ) - def retranslate(self): + def _retranslateContent(self): """Refresh translated text for the m box asset exists.""" self.setText(_(self.text())) - # Ignore informative text, buttons - - self.moveToCenter() - class XrayAssetListView(Mixins.ThemeAware, AppQListView): """Provide the model-based Xray asset viewer list.""" diff --git a/Furious/Qt/AGENTS.md b/Furious/Qt/AGENTS.md index 425ff123..c7422e89 100644 --- a/Furious/Qt/AGENTS.md +++ b/Furious/Qt/AGENTS.md @@ -15,6 +15,14 @@ behavior, and lifetime primitives; pages and services consume them without creat object-name rule even when the widget's enabled state is correct. - Controls that retranslate retain source text; semantic/user-defined values stay untranslated. Preserve keyboard focus, shortcut scope, accessibility, translated-text growth, responsive layout, high-DPI behavior, and both themes. + Extend a concrete base retranslator when its standard fields still apply; retain intentional state/content + overrides instead of calling the abstract mixin. Registered child controls translate themselves, including their + opt-out flags; owners translate plain Qt controls that have no registration. Message boxes customize + `_retranslateContent()` for composite text or preserved diagnostics, leaving title, heading, buttons and geometry + with the shared retranslator. The locale-switch cases in `tests/test_ui_behavior.py` cover both button policies + and diagnostic preservation; progress and metrics cases cover one child refresh per global pass. + Visible message boxes refit after registered children finish translating, through a weak continuation that + ignores hidden or destroyed dialogs. Preserve their bounded button widths and transient destruction policy. - Application-owned theme transitions commit destination state immediately; snapshots are non-interactive presentation objects that are interrupted/disposed on replacement, geometry change, window destruction, or application cleanup. Geometry/destruction event filters retire snapshots immediately but defer completion observers through an owned diff --git a/Furious/Qt/QtGui.py b/Furious/Qt/QtGui.py index e0e39f48..26a84b20 100644 --- a/Furious/Qt/QtGui.py +++ b/Furious/Qt/QtGui.py @@ -304,12 +304,18 @@ class AppQAction(Mixins.QTranslatable, Mixins.ThemeAware, QAction): def recursiveTranslate(action, memo): """Handle recursive translate for the app q action.""" - if action not in memo and not action.isSeparator() and action.translatable: - action.setText(_(action.text())) - action.setStatusTip(_(action.statusTip())) + if action in memo: + return memo[action] = True + if action is not self and isinstance(action, Mixins.QTranslatable): + return + + if not action.isSeparator() and getattr(action, 'translatable', True): + action.setText(_(action.text())) + action.setStatusTip(_(action.statusTip())) + # Some old version PySide6 does not have menu() method # for QAction. Protect it if hasattr(action, 'menu'): diff --git a/Furious/Qt/QtWidgets.py b/Furious/Qt/QtWidgets.py index f9009f69..bc6147e9 100644 --- a/Furious/Qt/QtWidgets.py +++ b/Furious/Qt/QtWidgets.py @@ -522,7 +522,9 @@ class AppQDialogButtonBox(Mixins.QTranslatable, QDialogButtonBox): def retranslate(self): """Refresh translated text for the app Qt dialog button box.""" for button in self.buttons(): - button.setText(_(button.text())) + if not isinstance(button, Mixins.QTranslatable): + # Manage the translation explicitly + button.setText(_(button.text())) class AppQGroupBox(Mixins.QTranslatable, QGroupBox): @@ -1995,7 +1997,7 @@ class AppQMessageBox(AppQTransientDialog): def retranslate(self): """Refresh translated text for the app q message box.""" - self.setWindowTitle(_(self.windowTitle())) + super().retranslate() try: self.setHeading(_(self.heading())) @@ -2003,6 +2005,32 @@ class AppQMessageBox(AppQTransientDialog): # Runtime headings may already be localized by their owning layer. pass + self._retranslateContent() + + for button in self.buttons(): + if isinstance(button, Mixins.QTranslatable): + continue + + if button.text().find('OK') != -1: + # &OK... + pass + else: + button.setText(_(button.text())) + + if self.isVisible(): + # Registered child buttons finish translating later in the global pass. + singleShotWeakly(0, self, '_refreshTranslatedGeometry') + else: + self.moveToCenter() + + def _refreshTranslatedGeometry(self): + """Fit current translated controls after the registered children update.""" + if self.isVisible(): + self._updateDialogSize() + self.moveToCenter() + + def _retranslateContent(self): + """Translate ordinary content; subclasses own composite or diagnostic text.""" self.setText(_(self.text())) try: @@ -2011,15 +2039,6 @@ class AppQMessageBox(AppQTransientDialog): # Any translatable informative text pass - for button in self.buttons(): - if button.text().find('OK') != -1: - # &OK... - pass - else: - button.setText(_(button.text())) - - self.moveToCenter() - def disconnectedCallback(self): """Update the app q message box for a disconnected state.""" self.setWindowIcon(AppHue.disconnectedWindowIcon()) @@ -2466,14 +2485,10 @@ class MBoxQuestionDelete(AppQMessageBox): else: return _('Delete this item?') + f'\n\n{self.possibleRemark}' - def retranslate(self): + def _retranslateContent(self): """Refresh translated text for the m box question delete.""" self.setText(self.customText()) - # Ignore informative text, buttons - - self.moveToCenter() - class MBoxNewChangesNextTime(AppQMessageBox): """Represent m box new changes next time.""" @@ -2496,15 +2511,10 @@ class MBoxNewChangesNextTime(AppQMessageBox): _('New changes will take effect next time') + '\n\n' + _('Reconnect now?') ) - def retranslate(self): + def _retranslateContent(self): """Refresh translated text for the m box new changes next time.""" - self.setWindowTitle(_(self.windowTitle())) self.setText(self.customText()) - # Ignore informative text, buttons - - self.moveToCenter() - def showMBoxNewChangesNextTime(**kwargs): """Show m box new changes next time.""" @@ -2565,15 +2575,10 @@ class MBoxDirectRulesNotAllowed(AppQMessageBox): + _('Switch to global and reconnect?') ) - def retranslate(self): + def _retranslateContent(self): """Refresh translated text for the m box direct rules not allowed.""" - self.setHeading(_(self.heading())) self.setText(self.customText()) - # Ignore informative text, buttons - - self.moveToCenter() - def showMBoxDirectRulesNotAllowed(**kwargs): """Show m box direct rules not allowed.""" diff --git a/Furious/Service/UpdateManager.py b/Furious/Service/UpdateManager.py index b1e1847e..ebaa9be3 100644 --- a/Furious/Service/UpdateManager.py +++ b/Furious/Service/UpdateManager.py @@ -54,16 +54,11 @@ class MBoxQuestionUpdate(AppQMessageBox): """Return the user-facing message text for the m box question update.""" return _('New version available') + f': {self.version}' - def retranslate(self): + def _retranslateContent(self): """Refresh translated text for the m box question update.""" self.setText(self.customText()) - self.setWindowTitle(_(self.windowTitle())) self.setInformativeText(_(self.informativeText())) - # Ignore button text - - self.moveToCenter() - class UpdateManager(HttpGetManager): """Coordinate updates operations.""" diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index d1fbca56..3f18f675 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -122,16 +122,11 @@ class MBoxUpdateSubsInfo(AppQMessageBox): * self.fontMetrics().averageCharWidth(), ) - def retranslate(self): + def _retranslateContent(self): """Refresh translated text for the m box update subs info.""" - self.setWindowTitle(_(self.windowTitle())) self.setText(self.customText()) self.setColumnMinWidth() - # Ignore informative text, buttons - - self.moveToCenter() - class DeleteServersProgressDialog(AppQTransientDialog): """Present progress and cancellation controls for delete servers.""" @@ -279,8 +274,8 @@ class DeleteServersProgressDialog(AppQTransientDialog): def retranslate(self): """Refresh translated text for the delete servers progress dialog.""" - self.setWindowTitle(_(self.windowTitle())) - self.cancelButton.setText(_(self.cancelButton.text())) + super().retranslate() + self.updateStatus() @@ -320,6 +315,7 @@ class DuplicateServersProgressDialog(AppQTransientDialog): def open(self): result = super().open() + singleShotWeakly(0, self, 'duplicateNext') return result @@ -1501,6 +1497,7 @@ class ServerTableView( for row, profile in enumerate(Storage.UserServers()) if profile.metadata.profileId in changed ] + self.sourceModel.dataChanged.emit( self.sourceModel.index(rows[0], 0), self.sourceModel.index(rows[-1], self.sourceModel.columnCount() - 1), diff --git a/Furious/Window/LogPage.py b/Furious/Window/LogPage.py index 14d46c56..e6ae7d04 100644 --- a/Furious/Window/LogPage.py +++ b/Furious/Window/LogPage.py @@ -131,14 +131,10 @@ class MBoxSaveError(AppQMessageBox): else: return _('Unable to save log') - def retranslate(self): + def _retranslateContent(self): """Refresh translated text for the m box save error.""" self.setText(self.customText()) - # Ignore informative text, buttons - - self.moveToCenter() - def saveAsFile(content: str): """Save as file.""" diff --git a/Furious/Window/MetricsPage.py b/Furious/Window/MetricsPage.py index 3dd1598b..6bda11cd 100644 --- a/Furious/Window/MetricsPage.py +++ b/Furious/Window/MetricsPage.py @@ -458,7 +458,5 @@ class MetricsPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow): _('Upload Speed'), _('Upload Traffic Usage'), ) - self.endpointInfoWidget.retranslate() - self._dirty = True self._scheduleRender() diff --git a/Furious/Window/TextEditorWindow.py b/Furious/Window/TextEditorWindow.py index dbd80fdf..23a521dc 100644 --- a/Furious/Window/TextEditorWindow.py +++ b/Furious/Window/TextEditorWindow.py @@ -72,14 +72,10 @@ class MBoxJSONDecodeError(AppQMessageBox): + f'\n\n{self.error}' ) - def retranslate(self): + def _retranslateContent(self): """Refresh translated text for the m box JSON decode error.""" self.setText(self.customText()) - # Ignore informative text, buttons - - self.moveToCenter() - class TextEditorWindow(AppQMainWindow): """Present the text editor window.""" diff --git a/tests/README.md b/tests/README.md index 6e658ea6..0e674ef8 100644 --- a/tests/README.md +++ b/tests/README.md @@ -102,7 +102,7 @@ worker. Choose tests by the changed contract rather than by filename alone. | [test_service_runtime.py](test_service_runtime.py) | Update-response validation, HTTP timeouts/context release/reentrant destruction, duplicate completion, plugin-page registration, bounded connectivity requests, blocked statistics-worker callback lifetime. | | [test_xray_asset_download.py](test_xray_asset_download.py) | Checksum validation, atomic asset replacement, real pool delivery, early owner destruction, callback release, plugin shutdown. | | [test_endpoint_info.py](test_endpoint_info.py) | Opt-in proxy-only discovery using fake HTTP responses, fallback/cache/session invalidation, privacy/presentation controls, local map styling and persistent-scene contracts. | -| [test_metrics_behavior.py](test_metrics_behavior.py) | Immutable time-series history, pruning/buckets, usage versus speed clearing, hidden-page rendering, graph hover lookup. | +| [test_metrics_behavior.py](test_metrics_behavior.py) | Immutable time-series history, pruning/buckets, usage versus speed clearing, hidden-page rendering, graph hover lookup, and independent endpoint retranslation without changing history. | | [test_log_manager_generation.py](test_log_manager_generation.py) | Reference-model state transitions, concurrency, live/retired ownership, bounded FIFO cleanup, structural complexity, incremental/multiline LogPage rendering; also contains an opt-in generation campaign. | | [test_log_manager_benchmark.py](test_log_manager_benchmark.py) | Data-only report validation, workload compatibility, timing ratios, and real standalone CLI JSON round trips; no performance gates or historical imports. | @@ -114,7 +114,7 @@ worker. Choose tests by the changed contract rather than by filename alone. | [test_main_window_geometry.py](test_main_window_geometry.py) | First-show lifecycle, restored/default/legacy geometry, main/routing window reuse, session-only navigation state, protection of never-shown windows' saved geometry. | | [test_dialog_geometry.py](test_dialog_geometry.py) | Dialog show/open/exec preparation, sizing/centering, failure cleanup, specialized message-box geometry and transient destruction. | | [test_layout_matrix.py](test_layout_matrix.py) | Navigation and message-box layouts in fresh processes at scale factors 1, 1.25, 1.5, and 2, under both themes. | -| [test_ui_behavior.py](test_ui_behavior.py) | Translation and editor mappings, settings construction visibility/organization/search/platform gates, stable server/routing moves, real QR rendering/decoding, incremental log filtering/tail behavior, connected-routing change notices, message boxes and connection-error copy/log navigation, shared Home/tray state. | +| [test_ui_behavior.py](test_ui_behavior.py) | Translation and editor mappings, settings construction visibility/organization/search/platform gates, stable server/routing moves, real QR rendering/decoding, incremental log filtering/tail behavior, connected-routing change notices, message boxes and connection-error copy/log navigation, shared Home/tray state, preserved message payloads and managed-versus-plain button retranslation. | | [test_qt_interactions.py](test_qt_interactions.py) | Real keyboard/mouse/focus and proxy mapping, scoped shortcuts, sorting/selection, stable subscription deletion confirmations, debounced Home search, favorite actions and intersecting filters, Tests-menu selection color, shared settings, batched import/delete/duplication, cancellation, preparation failure, and owner destruction. | | [test_qr_export_scalability.py](test_qr_export_scalability.py) | Production capture cap, immediate single export, incremental yielding, failure/cancel/close paths, immutable snapshots, window-owned state destruction. | | [test_stylesheet_states.py](test_stylesheet_states.py) | Targeted rendering/alpha/geometry assertions for table/list insets, popup corners, clear buttons, focus/disabled states, and stylesheet composition. | diff --git a/tests/test_metrics_behavior.py b/tests/test_metrics_behavior.py index 3a8fad05..b852fe0c 100644 --- a/tests/test_metrics_behavior.py +++ b/tests/test_metrics_behavior.py @@ -19,6 +19,7 @@ from __future__ import annotations +from Furious.Frozenlib import AppSettings, Mixins from Furious.Qt import gettext as _ from Furious.Service.MetricsHistory import ( DOWNLOAD_SPEED_METRIC, @@ -33,7 +34,15 @@ from Furious.Window.MetricsPage import MetricsPage from PySide6 import QtCore -from tests.support import application, collectAtBoundary, processQtEvents, waitFor +from tests.support import ( + application, + collectAtBoundary, + isolatedSettings, + processQtEvents, + waitFor, +) + +from unittest import mock import math import time @@ -190,6 +199,39 @@ class MetricsPageAndGraphTest(unittest.TestCase): """Drain deferred widget destruction after every UI case.""" collectAtBoundary() + def testGlobalRetranslationRefreshesEndpointOnceWithoutChangingHistory(self): + with isolatedSettings(): + AppSettings.set('Language', 'EN') + manager = MetricsHistory() + page = MetricsPage(manager) + manager.recordSample({DOWNLOAD_SPEED_METRIC: 32}) + samples = manager.rawSamples() + + try: + for locale in ('RU', 'ZH', 'EN'): + AppSettings.set('Language', locale) + with mock.patch.object( + page.endpointInfoWidget, + 'retranslate', + wraps=page.endpointInfoWidget.retranslate, + ) as translate: + Mixins.QTranslatable.retranslateAll() + self.assertEqual(translate.call_count, 1) + self.assertEqual( + page.metricsCard.downloadTitleLabel.text(), _('Download') + ) + self.assertEqual( + page.endpointInfoWidget.titleLabel.text(), + _('Proxy Endpoint Information'), + ) + self.assertEqual(manager.rawSamples(), samples) + self.assertEqual(page._renderRevision, 0) + finally: + page.close() + page.deleteLater() + manager.deleteLater() + processQtEvents() + def testTrafficGraphsAndSelectorsShareOneCard(self): """Keep the page title separate while grouping all metrics controls.""" manager = MetricsHistory() diff --git a/tests/test_qt_interactions.py b/tests/test_qt_interactions.py index d5b2bc98..0a28e330 100644 --- a/tests/test_qt_interactions.py +++ b/tests/test_qt_interactions.py @@ -2343,6 +2343,59 @@ class ProfileMutationBatchTest(unittest.TestCase): self.assertFalse(isValid(dialog)) self.assertEqual(len(Storage.UserServers()), 1000) + def testProgressDialogsUseSharedRetranslationWithoutLosingProgress(self): + with self.table(10) as (table, controller): + AppSettings.set('Language', 'EN') + dialogs = ( + ( + ImportURIsProgressDialog(('fixture',) * 10, parent=table), + 'Import', + 'Importing', + 'currentIndex', + ), + ( + DeleteServersProgressDialog(table, range(10), parent=table), + 'Delete', + 'Deleting', + 'deletedCount', + ), + ( + DuplicateServersProgressDialog(table, table._visibleProfileIds()), + 'Duplicate', + 'Duplicating', + 'copiedCount', + ), + ) + + for dialog, title, status, counter in dialogs: + setattr(dialog, counter, 7) + + for locale in ('RU', 'ZH', 'EN'): + AppSettings.set('Language', locale) + with ExitStack() as stack: + translations = [ + stack.enter_context( + mock.patch.object( + dialog.cancelButton, + 'retranslate', + wraps=dialog.cancelButton.retranslate, + ) + ) + for dialog, *_fields in dialogs + ] + Mixins.QTranslatable.retranslateAll() + + for (dialog, title, status, counter), translated in zip( + dialogs, translations + ): + self.assertEqual(translated.call_count, 1) + self.assertEqual(dialog.windowTitle(), gettext(title)) + self.assertEqual(dialog.cancelButton.text(), gettext('Cancel')) + self.assertEqual( + dialog.statusLabel.text(), gettext(status) + '... 7/10' + ) + self.assertEqual(getattr(dialog, counter), 7) + def testImportCoalescesRowsAndProgressWithoutLosingInvalidInputPositions(self): profiles = [self.profile(str(index)) for index in range(600)] invalid = mock.Mock() diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py index bb5843e6..ca8bf0be 100644 --- a/tests/test_ui_behavior.py +++ b/tests/test_ui_behavior.py @@ -66,6 +66,7 @@ from Furious.Backends.Xray.VmessEditor import ( VmessEditor, ) from Furious.Actions.Connection import ConnectAction, ConnectionErrorMessageBox +from Furious.Actions.Import import MBoxImportError from Furious.Controllers.ConnectionController import ( ConnectionError, ConnectionController, @@ -99,9 +100,13 @@ from Furious.Repository import Storage from Furious.Repository.Routings import UserRoutings from Furious.Qt import ( AppHue, + AppQAction, AppQComboBox, AppQDialog, + AppQDialogButtonBox, AppQMessageBox, + AppQMenu, + AppQPushButton, AppQSwitch, AppStyleSheet, gettext as _, @@ -130,9 +135,10 @@ from Furious.Widget.RoutingSelector import RoutingSelector from Furious.Widget.ServerTableView import ServerTableView from PySide6 import QtCore -from PySide6.QtGui import QImage +from PySide6.QtGui import QAction, QImage from PySide6.QtTest import QAbstractItemModelTester, QSignalSpy, QTest from PySide6.QtWidgets import ( + QPushButton, QStackedWidget, QToolButton, QComboBox, @@ -2972,6 +2978,119 @@ class DialogBehaviorTest(unittest.TestCase): """Finish every deferred transient deletion between tests.""" collectAtBoundary() + def testCustomMessageRetranslationKeepsPayloadAndUpdatesDialogControls(self): + with isolatedSettings(): + AppSettings.set('Language', 'EN') + box = MBoxImportError() + box.setWindowTitle(_('Duplicate')) + box.setHeading(_('Delete')) + box.setText(_('Invalid data')) + box.setInformativeText('Save') + button = box.addButton('Cancel', box.ButtonRole.RejectRole) + + try: + for locale in ('RU', 'ZH', 'EN'): + AppSettings.set('Language', locale) + Mixins.QTranslatable.retranslateAll() + self.assertEqual(box.windowTitle(), _('Duplicate')) + self.assertEqual(box.heading(), _('Delete')) + self.assertEqual(box.text(), _('Invalid data')) + self.assertEqual(box.informativeText(), 'Save') + self.assertEqual(button.text(), _('Cancel')) + finally: + box.deleteLater() + processQtEvents() + + def testButtonContainersRespectManagedTranslationAndPlainQtFallback(self): + with isolatedSettings(): + AppSettings.set('Language', 'EN') + + for containerType in (AppQMessageBox, AppQDialogButtonBox): + with self.subTest(container=containerType.__name__): + owner = containerType() + protected = AppQPushButton('Cancel', translatable=False) + managed = AppQPushButton('Save') + plain = QPushButton('Cancel') + + for button in (protected, managed, plain): + owner.addButton(button, owner.ButtonRole.ActionRole) + + try: + for locale in ('RU', 'ZH', 'EN'): + AppSettings.set('Language', locale) + with mock.patch.object( + managed, 'retranslate', wraps=managed.retranslate + ) as translate: + Mixins.QTranslatable.retranslateAll() + self.assertEqual(translate.call_count, 1) + self.assertEqual(protected.text(), 'Cancel') + self.assertEqual(managed.text(), _('Save')) + self.assertEqual(plain.text(), _('Cancel')) + finally: + owner.deleteLater() + processQtEvents() + + def testActionRetranslationLeavesManagedChildrenToTheirOwnPass(self): + with isolatedSettings(): + AppSettings.set('Language', 'EN') + owner = QWidget() + menu = AppQMenu(parent=owner) + managed = AppQAction('Save', parent=menu) + protected = AppQAction('Cancel', parent=menu, translatable=False) + menu.addActions([managed, protected]) + parent = AppQAction('Server', parent=owner, menu=menu) + + try: + for locale in ('RU', 'ZH', 'EN'): + AppSettings.set('Language', locale) + with mock.patch.object( + managed, 'setText', wraps=managed.setText + ) as update: + Mixins.QTranslatable.retranslateAll() + self.assertEqual(update.call_count, 1) + self.assertEqual(parent.text(), _('Server')) + self.assertEqual(managed.text(), _('Save')) + self.assertEqual(protected.text(), 'Cancel') + + plain = QAction('Cancel', menu) + menu.addAction(plain) + AppSettings.set('Language', 'ZH') + Mixins.QTranslatable.retranslateAll() + self.assertEqual(plain.text(), _('Cancel')) + finally: + owner.deleteLater() + processQtEvents() + + def testMessageGeometryRefreshesAfterManagedButtonTranslation(self): + with isolatedSettings(): + AppSettings.set('Language', 'EN') + box = AppQMessageBox() + button = AppQPushButton(_('Copy Error')) + box.addButton(button, box.ButtonRole.ActionRole) + box.open() + processQtEvents() + + try: + for locale in ('RU', 'ZH', 'EN'): + AppSettings.set('Language', locale) + Mixins.QTranslatable.retranslateAll() + processQtEvents() + self.assertEqual(button.text(), _('Copy Error')) + self.assertGreaterEqual( + button.width(), box._preferredButtonWidth(button) + ) + self.assertEqual(box.surface.size(), box.size()) + + AppSettings.set('Language', 'RU') + Mixins.QTranslatable.retranslateAll() + deleteQObject(box) + processQtEvents() + self.assertFalse(isValid(box)) + finally: + if isValid(box): + box.close() + processQtEvents() + def testThemeFallbackWorksBeforeConnectionControllerExists(self): """Allow startup error dialogs to use the disconnected theme safely.""" with mock.patch(