Fix Qt object lifetime handling

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-10-07 20:42:21 +08:00
parent 720f823cf6
commit 1881cb6aab
11 changed files with 551 additions and 17 deletions
+3
View File
@@ -399,6 +399,9 @@ class ImportFromFileAction(AppQAction):
filter=_('Text files (*.json);;All files (*)'),
)
if not Mixins.qObjectIsValid(self):
return
if filename:
try:
with open(filename, 'r', encoding='utf-8') as file:
+3
View File
@@ -132,6 +132,9 @@ class ExternalCorePathInput(EditorWidgetBinding):
_('All files (*)'),
)
if not Mixins.qObjectIsValid(self._container, self._input):
return
if selected:
self._input.setText(str(Path(selected).resolve(strict=False)))
+3
View File
@@ -136,5 +136,8 @@ class XrayAssetWindow(AppQMainWindow):
None, _('Import File'), filter=_('All files (*)')
)
if not Mixins.qObjectIsValid(self, self.xrayAssetListView):
return
if filename:
self.xrayAssetListView.appendNewItem(filename)
+12 -8
View File
@@ -111,15 +111,19 @@ class Mixins:
"""Group reusable lifecycle, translation, theme, and Qt context mixins."""
@staticmethod
def qObjectIsValid(qobject) -> bool:
"""Return the q object is valid value used by the mixins."""
if not isinstance(qobject, QtCore.QObject):
return True
def qObjectIsValid(qobject, *qobjects) -> bool:
"""Require every QObject to be valid; accept non-QObjects as before."""
for ob in (qobject, *qobjects):
if not isinstance(ob, QtCore.QObject):
continue
try:
return isValidQObject(qobject)
except RuntimeError:
return False
try:
if not isValidQObject(ob):
return False
except RuntimeError:
return False
return True
class ConnectionAware:
"""Represent connection aware."""
+9 -4
View File
@@ -86,10 +86,10 @@ Use the `manage-qt-pyside6-lifetimes` skill for source lifetime work when availa
does not enforce that requirement. An action also owns a submenu supplied without a QWidget parent and schedules
its native deletion when the action dies;
`QAction.setMenu()` alone does not establish parent ownership. Explicitly parented menus retain their chosen owner.
- `AppQMenu` adopts constructor actions/separator placeholders that lack a QObject parent; explicitly owned actions
remain borrowed. Adding an action to a menu or action group does not itself transfer QObject ownership. Dynamic
groups must parent their generated actions so retiring the group releases native actions even when compiled
callbacks keep Python wrappers alive. `tests/test_qt_lifetime.py` exercises both borrowed and owned cases.
- `AppQMenu` and `AppQToolBar` adopt constructor actions/separator placeholders without a QObject parent; explicitly
owned actions remain borrowed. Adding an action to a menu or action group does not itself transfer QObject
ownership. Dynamic groups must parent their generated actions so retiring the group releases native actions even
when compiled callbacks keep Python wrappers alive. `tests/test_qt_lifetime.py` exercises both borrowed and owned cases.
- Every `QNetworkReply` has one manager/context owner, one freshness rule, and one terminal deletion path. Request
context must also be released when native destruction skips `finished`, including manager-first teardown with
retained Python wrappers. Use the shared network-manager tracking boundary; cleanup must not capture a reply
@@ -99,6 +99,11 @@ Use the `manage-qt-pyside6-lifetimes` skill for source lifetime work when availa
Once-only HTTP completion also guards an in-progress callback: a second reply may finish synchronously inside
the first callback before the terminal flag is set. Preserve per-request completion mode and the existing final
flag timing; `tests/test_service_runtime.py` exercises real nested finished delivery and native resource teardown.
- Modal `exec()` and native file/directory choosers run nested event loops. Recheck the initiating feature and
required native widgets after return before reading controls, opening files, mutating a model, or presenting
follow-up UI. A pure Python editor binding can survive its destroyed Qt field tree. Publication after a data
commit may also destroy the presenter: retain the committed outcome while stopping stale UI work, including
the close-confirmation caller. `ModalPickerLifetimeTest` in `tests/test_qt_lifetime.py` challenges these boundaries.
- Queued delivery never transfers ownership implicitly. The sender may finish before delivery, so callbacks resolve a
still-valid receiver and current generation in the receiver's Qt thread before touching widgets, models, or wrappers.
A zero-delay timer yields work but does not establish ordering against an unrelated Qt event. Express required
+7 -1
View File
@@ -2423,16 +2423,22 @@ class AppQToolBar(Mixins.QTranslatable, QToolBar):
for action in actions:
if isinstance(action, AppQSeparator):
if action.parent() is None:
action.setParent(self)
self._actions.append(action)
self.addSeparator()
elif isinstance(action, AppQAction):
if action.parent() is None:
action.setParent(self)
self._actions.append(action)
self.addAction(action)
else:
# Do nothing
pass
self.actionTriggered.connect(self.showMenuBelow)
connectWeakly(self.actionTriggered, self, 'showMenuBelow', sender=self)
@QtCore.Slot(AppQAction)
def showMenuBelow(self, action: AppQAction):
+28 -2
View File
@@ -333,9 +333,15 @@ class TextEditorWindow(AppQMainWindow):
pass
if not Mixins.qObjectIsValid(self):
return True
if index == Storage.UserActivatedItemIndex():
showMBoxNewChangesNextTime(parent=self, method=showChangesMethod)
if not Mixins.qObjectIsValid(self):
return True
self.markAsSaved()
return True
@@ -346,10 +352,15 @@ class TextEditorWindow(AppQMainWindow):
None, _('Save File'), filter=_('Text files (*.json);;All files (*)')
)
if not Mixins.qObjectIsValid(self, self.jsonEditor):
return
if filename:
try:
content = self.jsonEditor.toPlainText()
with open(filename, 'w', encoding='utf-8') as file:
file.write(self.jsonEditor.toPlainText())
file.write(content)
except Exception as ex:
# Any non-exit exceptions
@@ -437,7 +448,14 @@ class TextEditorWindow(AppQMainWindow):
"""Handle button clicked."""
if button == mbox.button0:
# Save
if self.save(showChangesMethod='exec'):
saved = self.save(showChangesMethod='exec')
if not Mixins.qObjectIsValid(self, mbox):
event.ignore()
return
if saved:
mbox.close()
event.accept()
@@ -447,6 +465,11 @@ class TextEditorWindow(AppQMainWindow):
# Discard
self.markAsSaved()
if not Mixins.qObjectIsValid(self, mbox):
event.ignore()
return
mbox.close()
event.accept()
@@ -459,6 +482,9 @@ class TextEditorWindow(AppQMainWindow):
# Show the MessageBox and wait for the user to close it
mbox.exec()
if not Mixins.qObjectIsValid(self):
return
if event.isAccepted():
super().closeEvent(event)
else:
+4 -1
View File
@@ -134,7 +134,7 @@ worker. Choose tests by the changed contract rather than by filename alone.
| [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. |
| [test_theme_transition.py](test_theme_transition.py) | Real cross-fades, immediate theme activation, interruption, per-window resize/destruction, coordinator teardown, animation policy, native resize/deletion probes, deferred completion delivery, replacement/stop flushing and owner-first cancellation. |
| [test_qt_lifetime.py](test_qt_lifetime.py) | Native destruction and weak-wrapper/registry evidence across dialogs, menus, actions, timers, signals, message-box buttons/masks, owner-first confirmations, reusable editors, simulated compiled-method retention. |
| [test_qt_lifetime.py](test_qt_lifetime.py) | Modal chooser/notification continuations after owner teardown, text-export file preservation, toolbar owned/borrowed action teardown, native destruction and weak-wrapper/registry evidence across dialogs, menus, actions, timers, signals, message-box buttons/masks, owner-first confirmations, reusable editors, simulated compiled-method retention. |
### Repeated stress and release confidence
@@ -358,6 +358,9 @@ destruction, statistics executor/thread release with an invalid wrapper retained
release before native child-worker deletion. Controller-owned update requests die with their controller;
injected services retain their existing owner. Source regressions also exercise reentrant metrics enablement,
download cancellation with a deleted reply, and disposal during an output-drain callback.
The modal-picker probe repeats native owner destruction during file/directory selection and verifies no stale
UI operation or file truncation. The toolbar probe checks owned versus borrowed native actions and compiled
callback growth; wrapper collection is checked at the batch boundary, not forced per cycle.
The publication/teardown probe covers nested once-only and per-request HTTP completion, native destruction during
Home status publication, update notifications ending manager/parent ownership, and destruction or cancellation
during the shared post-show event flush. Window/QR probes verify registry and timer cleanup before further work.
+177
View File
@@ -20,6 +20,8 @@
from __future__ import annotations
from Furious.Backends import OFFICIAL_PLUGIN_TYPES
from Furious.Backends.ExternalCore.Editor import ExternalCorePathInput
from Furious.Backends.Xray.AssetWindow import XrayAssetWindow
from Furious.Backends.Xray.RoutingWindow import RoutingRulesDialog
from Furious.Backends.Xray.AssetListView import XrayAssetListView
import Furious.Backends.Xray.AssetListView as assetModule
@@ -39,6 +41,8 @@ from Furious.Qt import (
AppQDialog,
AppQMessageBox,
AppQMainWindow,
AppQToolBar,
AppQSeparator,
ThemeTransition,
connectWeakly,
)
@@ -72,6 +76,7 @@ from Furious.Repository import Storage
from Furious.Window.SettingsPage import _TUNBackendSettingsCard
from Furious.Window.HomePage import HomePage, NetworkStateBadge, AppConnectivityManager
from Furious.Window.QRCodeWindow import QRCodeWindow
from Furious.Window.TextEditorWindow import TextEditorWindow
from Furious.Widget.ServerTableView import ServerTableView
import PySide6
@@ -95,6 +100,7 @@ from types import SimpleNamespace
from unittest import mock
import argparse
import importlib
import json
import sys
import weakref
@@ -1837,6 +1843,175 @@ def runConfirmationProbe(iterations=100):
return result
def runToolbarOwnershipProbe(iterations=100):
"""Toolbar teardown releases owned actions while preserving borrowed owners."""
application()
protected = getattr(
sys.modules.get('PySide6-postLoad', PySide6), '_protected', None
)
protectedBefore = len(protected) if protected is not None else None
references = []
destroyed = []
with isolatedSettings():
for borrowed in (False, True):
for _ in range(iterations):
owner = QWidget()
action = AppQAction(
'Toolbar fixture', parent=owner if borrowed else None
)
separator = AppQSeparator()
if borrowed:
separator.setParent(owner)
toolbar = AppQToolBar(action, separator, parent=owner)
references.append(weakref.ref(toolbar))
toolbar.destroyed.connect(lambda *_args: destroyed.append(True))
toolbar.actionTriggered.emit(action)
deleteQObject(toolbar)
assert isValid(action) == borrowed
assert isValid(separator) == borrowed
deleteQObject(owner)
assert not isValid(action)
assert not isValid(separator)
del action, separator, toolbar, owner
collectAtBoundary()
assert len(destroyed) == iterations * 2
assert all(reference() is None for reference in references)
growth = len(protected) - protectedBefore if protected is not None else None
if growth is not None:
assert growth == 0, growth
return {
'toolbarCycles': iterations * 2,
'destroyed': len(destroyed),
'protectedGrowth': growth,
}
def runModalPickerProbe(iterations=100):
"""Exercise modal owner-first teardown with actual native widget deletion."""
application()
references = []
destroyed = []
textModule = importlib.import_module('Furious.Window.TextEditorWindow')
assetWindowModule = importlib.import_module('Furious.Backends.Xray.AssetWindow')
pathModule = importlib.import_module('Furious.Backends.ExternalCore.Editor')
def destroyOwner(owner):
loop = QtCore.QEventLoop()
def finish():
deleteQObject(owner)
loop.quit()
QtCore.QTimer.singleShot(0, finish)
loop.exec()
def record(object_):
references.append(weakref.ref(object_))
object_.destroyed.connect(lambda *_args: destroyed.append(True))
with isolatedSettings(), tempfile.TemporaryDirectory() as directory:
filename = Path(directory) / 'fixture.json'
for _ in range(iterations):
filename.write_text('keep', encoding='utf-8')
parent = QWidget()
editor = TextEditorWindow(parent)
editor.jsonEditor.setPlainText('{"fixture": true}')
record(editor)
def saveSelection(*_args, **_kwargs):
destroyOwner(parent)
return str(filename), ''
with mock.patch.object(
textModule.QFileDialog, 'getSaveFileName', side_effect=saveSelection
):
editor.saveAsFile()
assert not isValid(editor)
assert filename.read_text(encoding='utf-8') == 'keep'
del editor, parent
for directoryMode in (False, True):
binding = ExternalCorePathInput(
'Path', 'executable', directory=directoryMode
)
container = binding._container
record(container)
def pathSelection(*_args, **_kwargs):
destroyOwner(container)
return str(filename) if directoryMode else (str(filename), '')
method = 'getExistingDirectory' if directoryMode else 'getOpenFileName'
with mock.patch.object(
pathModule.QFileDialog, method, side_effect=pathSelection
):
binding.browse()
assert not isValid(binding._input)
deleteQObject(binding._title)
del binding, container
parent = QWidget()
window = XrayAssetWindow(parent)
record(window)
def assetSelection(*_args, **_kwargs):
destroyOwner(parent)
return str(filename), ''
with mock.patch.object(
assetWindowModule.QFileDialog,
'getOpenFileName',
side_effect=assetSelection,
):
with mock.patch.object(
window.xrayAssetListView, 'appendNewItem'
) as append:
window.appendNewItem()
append.assert_not_called()
assert not isValid(window)
del window, parent
action = importModule.ImportFromFileAction()
record(action)
def importSelection(*_args, **_kwargs):
destroyOwner(action)
return str(filename), ''
with mock.patch.object(
importModule.QFileDialog, 'getOpenFileName', side_effect=importSelection
):
with mock.patch.object(importModule, 'profileFromAny') as parse:
action.triggeredCallback(False)
parse.assert_not_called()
assert not isValid(action)
del action
processQtEvents()
assert len(destroyed) == iterations * 5
collectAtBoundary()
assert all(reference() is None for reference in references)
assert not AppQDialog._openDialogs
return {'modalPickerCycles': iterations, 'destroyed': len(destroyed), 'retained': 0}
def main():
"""Run the probe as a standalone source or Nuitka executable."""
parser = argparse.ArgumentParser()
@@ -1855,6 +2030,8 @@ def main():
)
try:
print(json.dumps(runModalPickerProbe(arguments.iterations)))
print(json.dumps(runToolbarOwnershipProbe(arguments.iterations)))
print(json.dumps(runNotificationAndDnsProbe(arguments.iterations)))
print(json.dumps(runConnectionRecoveryProbe(arguments.iterations)))
print(json.dumps(runReentrantLifetimeProbe(arguments.iterations)))
+24
View File
@@ -60,6 +60,30 @@ class FrozenlibQtContextTest(unittest.TestCase):
"""Create the suite-owned QApplication before constructing widgets."""
application()
def testObjectValidityRequiresEveryNativeObjectToBeAlive(self):
"""Check all arguments while retaining the single-object contract."""
first = QtCore.QObject()
second = QtCore.QObject()
destroyed = QtCore.QObject()
deleteQObject(destroyed)
try:
self.assertTrue(Mixins.qObjectIsValid(qobject=first))
self.assertTrue(Mixins.qObjectIsValid(None))
self.assertTrue(Mixins.qObjectIsValid(first, None, object(), second))
self.assertFalse(Mixins.qObjectIsValid(destroyed))
for arguments in (
(destroyed, first, second),
(first, destroyed, second),
(first, second, destroyed),
):
with self.subTest(arguments=arguments):
self.assertFalse(Mixins.qObjectIsValid(*arguments))
finally:
deleteQObject(first)
deleteQObject(second)
def testDisabledContextPreservesPriorAndNestedState(self):
"""Restore both initially enabled and initially disabled widgets."""
button = QPushButton()
+281 -1
View File
@@ -19,11 +19,15 @@
from __future__ import annotations
from Furious.Backends.ExternalCore.Editor import ExternalCoreEditor
from Furious.Backends.ExternalCore.Editor import (
ExternalCoreEditor,
ExternalCorePathInput,
)
from Furious.Backends.Hysteria1.Editor import Hysteria1Editor
from Furious.Backends.Hysteria2.Editor import Hysteria2Editor
from Furious.Backends.Hysteria2.TunSettingsDialog import Hysteria2TunSettingsDialog
from Furious.Backends.Xray.AssetListView import XrayAssetListView
from Furious.Backends.Xray.AssetWindow import XrayAssetWindow
from Furious.Backends.Xray.RoutingWindow import (
UserRoutingTableView,
RoutingDocumentationURL,
@@ -39,6 +43,7 @@ from Furious.Backends.Xray.VlessEditor import VlessEditor
from Furious.Backends.Xray.VmessEditor import VmessEditor
from Furious.Actions.Import import (
ImportURIsProgressDialog,
ImportFromFileAction,
ImportQRCodeOnTheScreenAction,
)
from Furious.Actions.Routing import RoutingAction
@@ -60,6 +65,7 @@ from Furious.Qt import (
AppQSwitch,
AppQSeparator,
AppQTransientDialog,
AppQToolBar,
connectWeakly,
singleShotWeakly,
)
@@ -88,14 +94,288 @@ from tests.support import (
from tests.fixtures.editor_lifetime_probe import runSingletonIPCProbe
import gc
import importlib
import builtins
from pathlib import Path
from types import SimpleNamespace
import tempfile
import unittest
from unittest import mock
import weakref
class ToolbarLifetimeTest(unittest.TestCase):
"""Constructor actions use the toolbar tree unless an owner was explicit."""
def testToolbarAdoptsOnlyUnownedActionsAndSeparators(self):
application()
with isolatedSettings():
for borrowed in (False, True):
with self.subTest(borrowed=borrowed):
owner = QWidget()
action = AppQAction(
'Toolbar fixture', parent=owner if borrowed else None
)
separator = AppQSeparator()
if borrowed:
separator.setParent(owner)
toolbar = AppQToolBar(action, separator, parent=owner)
try:
self.assertIs(action.parent(), owner if borrowed else toolbar)
self.assertIs(
separator.parent(), owner if borrowed else toolbar
)
toolbar.actionTriggered.emit(action)
deleteQObject(toolbar)
self.assertEqual(isValid(action), borrowed)
self.assertEqual(isValid(separator), borrowed)
finally:
if isValid(owner):
deleteQObject(owner)
if isValid(action):
deleteQObject(action)
if isValid(separator):
deleteQObject(separator)
class ModalPickerLifetimeTest(unittest.TestCase):
"""A nested chooser can destroy its caller before returning a selection."""
@staticmethod
def _destroyDuringModalLoop(owner):
loop = QtCore.QEventLoop()
def finish():
deleteQObject(owner)
loop.quit()
QtCore.QTimer.singleShot(0, finish)
loop.exec()
def testTextSaveDoesNotTruncateFileAfterWindowDestruction(self):
"""Cancel before opening the destination when the editor no longer exists."""
application()
module = importlib.import_module('Furious.Window.TextEditorWindow')
with isolatedSettings(), tempfile.TemporaryDirectory() as directory:
filename = Path(directory) / 'existing.json'
filename.write_text('keep original bytes', encoding='utf-8')
parent = QWidget()
editor = TextEditorWindow(parent)
editor.jsonEditor.setPlainText('{"updated": true}')
def select(*_args, **_kwargs):
self._destroyDuringModalLoop(parent)
return str(filename), ''
with mock.patch.object(
module.QFileDialog, 'getSaveFileName', side_effect=select
):
editor.saveAsFile()
self.assertFalse(isValid(editor))
self.assertEqual(
filename.read_text(encoding='utf-8'), 'keep original bytes'
)
def testCloseConfirmationStopsAfterSavingDestroysItsOwner(self):
"""Do not close a deleted prompt or continue its former close event."""
script = """
import importlib
import sys
from unittest import mock
from PySide6 import QtCore
from PySide6.QtGui import QCloseEvent
from shiboken6 import delete as deleteQObject, isValid
from tests.support import application, isolatedSettings, processQtEvents
from Furious.Window.TextEditorWindow import TextEditorWindow
application()
module = importlib.import_module('Furious.Window.TextEditorWindow')
errors = []
sys.excepthook = lambda *args: errors.append(args)
with isolatedSettings():
editor = TextEditorWindow()
editor.modified = True
originalPrompt = module.MBoxQuestionSave
class Prompt(originalPrompt):
def exec(self):
QtCore.QTimer.singleShot(0, self.button0.click)
return super().exec()
def save(*args, **kwargs):
deleteQObject(editor)
return True
with mock.patch.object(module, 'MBoxQuestionSave', Prompt):
with mock.patch.object(editor, 'save', side_effect=save):
editor.closeEvent(QCloseEvent())
processQtEvents()
assert not isValid(editor)
assert not errors, [(str(args[0]), str(args[1])) for args in errors]
"""
assertChildSucceeded(
self, runPythonChild(script), 'save destroys close-confirmation owner'
)
def testTextSaveCommitSurvivesDestructionDuringItsNotifications(self):
"""A committed save must not touch the dead editor or open a stale notice."""
application()
module = importlib.import_module('Furious.Window.TextEditorWindow')
with isolatedSettings():
for boundary in ('row-change', 'reconnect-notice'):
with self.subTest(boundary=boundary):
parent = QWidget()
editor = TextEditorWindow(parent)
editor.currentIndex = 0
editor.jsonEditor.setPlainText('{"server": "after"}')
rows = [
ServerProfile.fromConfiguration(
CoreConfiguration({'server': 'before'})
)
]
def flush(*_args):
if boundary == 'row-change':
deleteQObject(parent)
def notice(*_args, **_kwargs):
if isValid(parent):
deleteQObject(parent)
try:
with mock.patch.object(
module.Storage, 'UserServers', return_value=rows
):
with mock.patch.object(
module.Storage, 'UserActivatedItemIndex', return_value=0
):
with mock.patch.object(
module,
'AppMainWindow',
return_value=SimpleNamespace(flushRow=flush),
):
with mock.patch.object(
module,
'configurationFromMapping',
side_effect=CoreConfiguration,
):
with mock.patch.object(
module,
'showMBoxNewChangesNextTime',
side_effect=notice,
) as showNotice:
self.assertTrue(
editor.save(showChangesMethod='exec')
)
if boundary == 'row-change':
showNotice.assert_not_called()
else:
showNotice.assert_called_once()
self.assertFalse(isValid(editor))
self.assertEqual(rows[0]['server'], 'after')
finally:
if isValid(parent):
deleteQObject(parent)
def testPathBrowseStopsAfterItsFieldTreeIsDestroyed(self):
"""A pure binding can survive after its native row widgets disappear."""
application()
module = importlib.import_module('Furious.Backends.ExternalCore.Editor')
with isolatedSettings():
for directoryMode in (False, True):
with self.subTest(directory=directoryMode):
binding = ExternalCorePathInput(
'Path', 'executable', directory=directoryMode
)
container = binding._container
def select(*_args, **_kwargs):
self._destroyDuringModalLoop(container)
return '/chosen' if directoryMode else ('/chosen', '')
method = (
'getExistingDirectory' if directoryMode else 'getOpenFileName'
)
try:
with mock.patch.object(
module.QFileDialog, method, side_effect=select
):
binding.browse()
self.assertFalse(isValid(binding._input))
finally:
if isValid(binding._title):
deleteQObject(binding._title)
if isValid(container):
deleteQObject(container)
def testAssetImportStopsAfterWindowDestruction(self):
"""Do not pass a selected filename into a dead asset view."""
application()
module = importlib.import_module('Furious.Backends.Xray.AssetWindow')
with isolatedSettings():
parent = QWidget()
window = XrayAssetWindow(parent)
def select(*_args, **_kwargs):
self._destroyDuringModalLoop(parent)
return '/chosen.dat', ''
with mock.patch.object(
module.QFileDialog, 'getOpenFileName', side_effect=select
):
with mock.patch.object(
window.xrayAssetListView, 'appendNewItem'
) as append:
window.appendNewItem()
append.assert_not_called()
self.assertFalse(isValid(window))
def testFileImportStopsWhenItsActionIsDestroyed(self):
"""A surviving main window must not receive work from a dead action."""
application()
module = importlib.import_module('Furious.Actions.Import')
with isolatedSettings(), tempfile.TemporaryDirectory() as directory:
filename = Path(directory) / 'input.json'
filename.write_text('{}', encoding='utf-8')
action = ImportFromFileAction()
mainWindow = mock.Mock()
def select(*_args, **_kwargs):
self._destroyDuringModalLoop(action)
return str(filename), ''
with mock.patch.object(
module.QFileDialog, 'getOpenFileName', side_effect=select
):
with mock.patch.object(
module, 'AppMainWindow', return_value=mainWindow
):
with mock.patch.object(module, 'profileFromAny') as parse:
with mock.patch.object(module, 'MBoxImportSuccess'):
action.triggeredCallback(False)
parse.assert_not_called()
mainWindow.appendNewItemByFactory.assert_not_called()
self.assertFalse(isValid(action))
class ProbeTransientDialog(AppQTransientDialog):
"""Own a running timer and menu so their destruction can be observed."""