2026-10-20 15:14:03 +00:00
|
|
|
import os
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
from textwrap import dedent
|
2026-09-08 15:21:28 +00:00
|
|
|
from types import SimpleNamespace
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from PyQt6.QtGui import QResizeEvent
|
|
|
|
|
from PyQt6.QtWidgets import (
|
|
|
|
|
QApplication,
|
|
|
|
|
QWidget,
|
|
|
|
|
)
|
|
|
|
|
from pytestqt.qtbot import QtBot
|
|
|
|
|
from pytestqt.wait_signal import SignalBlocker
|
2026-10-20 15:14:03 +00:00
|
|
|
import pytest
|
2026-09-08 15:21:28 +00:00
|
|
|
import tractor
|
|
|
|
|
import trio
|
|
|
|
|
|
2026-10-20 15:14:03 +00:00
|
|
|
from piker.service import get_runtime_vars
|
2026-09-08 15:21:28 +00:00
|
|
|
from piker.ui import _window
|
|
|
|
|
from piker.ui._exec import (
|
|
|
|
|
QtractorSession,
|
|
|
|
|
start_qtractor,
|
|
|
|
|
)
|
|
|
|
|
from piker.ui._window import MainWindow
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class QtractorTestWidget(QWidget):
|
|
|
|
|
'''
|
|
|
|
|
Supply the central-widget resize hook expected by `MainWindow`.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
window: MainWindow
|
|
|
|
|
|
|
|
|
|
def on_win_resize(self, event: QResizeEvent) -> None:
|
|
|
|
|
'''
|
|
|
|
|
Accept production `MainWindow.resizeEvent()` notifications.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def run_until_window_close(
|
|
|
|
|
main_widget: QtractorTestWidget,
|
|
|
|
|
exited: list[QtractorTestWidget],
|
2026-10-20 15:14:03 +00:00
|
|
|
reg_addr: tuple[str, int],
|
|
|
|
|
runtime_overrides: dict[str, Any],
|
2026-09-08 15:21:28 +00:00
|
|
|
|
|
|
|
|
) -> None:
|
|
|
|
|
'''
|
|
|
|
|
Publish readiness and retain the guest task until cancellation.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
session: QtractorSession|None = (
|
|
|
|
|
main_widget.window.qtractor_session
|
|
|
|
|
)
|
|
|
|
|
if session is None:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
'No Qtractor session owns the test window'
|
|
|
|
|
)
|
2026-10-20 15:14:03 +00:00
|
|
|
actor: tractor.Actor = tractor.current_actor()
|
|
|
|
|
assert reg_addr in actor.accept_addrs
|
|
|
|
|
assert tractor.runtime._state._runtime_vars[
|
|
|
|
|
'piker_vars'
|
|
|
|
|
] == runtime_overrides
|
2026-09-08 15:21:28 +00:00
|
|
|
session.set_ready()
|
|
|
|
|
try:
|
|
|
|
|
await trio.sleep_forever()
|
|
|
|
|
finally:
|
|
|
|
|
exited.append(main_widget)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_qtractor_restarts_after_structured_window_close(
|
|
|
|
|
isolated_ui_state: SimpleNamespace,
|
|
|
|
|
qapp: QApplication,
|
|
|
|
|
qtbot: QtBot,
|
|
|
|
|
reg_addr: tuple[str, int],
|
2026-10-20 15:14:03 +00:00
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
2026-09-08 15:21:28 +00:00
|
|
|
|
|
|
|
|
) -> None:
|
|
|
|
|
'''
|
|
|
|
|
Repeated Qt sessions must not signal or strand the Trio runtime.
|
|
|
|
|
|
|
|
|
|
`MainWindow.closeEvent()` previously sent process-wide SIGINT
|
|
|
|
|
while `run_qtractor()` hid the guest outcome behind blocking
|
|
|
|
|
`app.exec_()`. A caller-owned pytest-qt application therefore
|
|
|
|
|
could not observe a clean startup or close one session without
|
|
|
|
|
interrupting pytest.
|
|
|
|
|
|
|
|
|
|
Start two real Tractor root runtimes in sequence on the
|
|
|
|
|
test-owned registry. Each guest publishes readiness only after
|
|
|
|
|
entering its task, then a real window-close event requests
|
|
|
|
|
cancellation through `QtractorSession`. Completion, the guest
|
|
|
|
|
`finally` block, the empty runtime slot, and successful second
|
|
|
|
|
startup prove structured teardown completed without quitting the
|
|
|
|
|
shared Qt application.
|
|
|
|
|
|
2026-10-20 15:14:03 +00:00
|
|
|
Before each start, seed `get_runtime_vars()` with a stale
|
|
|
|
|
loopback registry and conflicting runtime overrides. Previously
|
|
|
|
|
these globals overwrote explicit caller kwargs. Assertions inside
|
|
|
|
|
the guest verify the actual bound address and runtime state before
|
|
|
|
|
readiness, rather than merely checking forwarded arguments.
|
|
|
|
|
|
2026-09-08 15:21:28 +00:00
|
|
|
'''
|
|
|
|
|
assert isolated_ui_state.qapp is qapp
|
|
|
|
|
exited: list[QtractorTestWidget] = []
|
|
|
|
|
qt_state: tuple[bool, int, str] = (
|
|
|
|
|
qapp.quitOnLastWindowClosed(),
|
|
|
|
|
qapp.cursorFlashTime(),
|
|
|
|
|
qapp.styleSheet(),
|
|
|
|
|
)
|
|
|
|
|
tractor_kwargs: dict[str, Any] = {
|
|
|
|
|
'registry_addrs': [reg_addr],
|
|
|
|
|
'tractor_runtime_overrides': {
|
|
|
|
|
'piker_test_dir': str(
|
|
|
|
|
isolated_ui_state.test_config_dir
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_: int
|
|
|
|
|
for _ in range(2):
|
2026-10-20 15:14:03 +00:00
|
|
|
monkeypatch.setitem(
|
|
|
|
|
get_runtime_vars(),
|
|
|
|
|
'registry_addrs',
|
|
|
|
|
[('127.0.0.1', 0)],
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setitem(
|
|
|
|
|
get_runtime_vars(),
|
|
|
|
|
'tractor_runtime_overrides',
|
|
|
|
|
{'stale': True},
|
|
|
|
|
)
|
2026-09-08 15:21:28 +00:00
|
|
|
session: QtractorSession = start_qtractor(
|
|
|
|
|
func=run_until_window_close,
|
2026-10-20 15:14:03 +00:00
|
|
|
args=(
|
|
|
|
|
exited,
|
|
|
|
|
reg_addr,
|
|
|
|
|
tractor_kwargs['tractor_runtime_overrides'],
|
|
|
|
|
),
|
2026-09-08 15:21:28 +00:00
|
|
|
main_widget_type=QtractorTestWidget,
|
|
|
|
|
tractor_kwargs=tractor_kwargs,
|
|
|
|
|
app=qapp,
|
|
|
|
|
)
|
|
|
|
|
qtbot.addWidget(session.window)
|
|
|
|
|
|
|
|
|
|
assert session.app is qapp
|
|
|
|
|
assert not session.owns_app_exec
|
|
|
|
|
assert session.window.godwidget is session.main_widget
|
|
|
|
|
assert session.window.isVisible()
|
|
|
|
|
assert session.outcome is None
|
|
|
|
|
|
|
|
|
|
ready: SignalBlocker = qtbot.waitSignal(
|
|
|
|
|
session.application_ready,
|
|
|
|
|
timeout=30_000,
|
|
|
|
|
)
|
|
|
|
|
ready.wait()
|
|
|
|
|
assert session.is_ready
|
|
|
|
|
assert session.trio_token is not None
|
|
|
|
|
assert session.cancel_scope is not None
|
|
|
|
|
|
|
|
|
|
completed: SignalBlocker
|
|
|
|
|
with qtbot.waitSignal(
|
|
|
|
|
session.completed,
|
|
|
|
|
timeout=30_000,
|
|
|
|
|
) as completed:
|
|
|
|
|
session.window.close()
|
|
|
|
|
|
|
|
|
|
assert completed.args == [session.outcome]
|
|
|
|
|
assert session.outcome is not None
|
|
|
|
|
assert session.outcome.unwrap() is None
|
|
|
|
|
assert session.cancel_scope.cancel_called
|
|
|
|
|
assert exited[-1] is session.main_widget
|
|
|
|
|
assert session.window.qtractor_session is None
|
|
|
|
|
assert _window._qt_win is None
|
|
|
|
|
assert tractor.current_actor(
|
|
|
|
|
err_on_no_runtime=False,
|
|
|
|
|
) is None
|
|
|
|
|
assert not qapp.closingDown()
|
|
|
|
|
assert (
|
|
|
|
|
qapp.quitOnLastWindowClosed(),
|
|
|
|
|
qapp.cursorFlashTime(),
|
|
|
|
|
qapp.styleSheet(),
|
|
|
|
|
) == qt_state
|
2026-10-20 15:14:03 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('fail', [False, True])
|
|
|
|
|
def test_run_qtractor_blocking_adapter(
|
|
|
|
|
reg_addr: tuple[str, int],
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
fail: bool,
|
|
|
|
|
|
|
|
|
|
) -> None:
|
|
|
|
|
'''
|
|
|
|
|
The blocking CLI adapter must drive PyQt6 and unwrap its guest.
|
|
|
|
|
|
|
|
|
|
`run_qtractor()` called the removed `QApplication.exec_()` API,
|
|
|
|
|
failing before Trio could start. Run the real adapter in a fresh
|
|
|
|
|
process with an offscreen application and test-owned registry and
|
|
|
|
|
config. The guest either returns normally or raises a sentinel
|
|
|
|
|
error after registering its window for inspection.
|
|
|
|
|
|
|
|
|
|
Guest completion must quit `QApplication.exec()` without a timer
|
|
|
|
|
or external shutdown. A bounded subprocess wait detects hangs;
|
|
|
|
|
exit status, propagated error identity, and the cleared actor and
|
|
|
|
|
window slots prove both outcome paths finish structured teardown.
|
|
|
|
|
Process isolation keeps the CLI's Qt ownership out of pytest-qt.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
script: str = dedent(f'''
|
|
|
|
|
from PyQt6.QtGui import QResizeEvent
|
|
|
|
|
from PyQt6.QtWidgets import QWidget
|
|
|
|
|
import tractor
|
|
|
|
|
from piker.ui import _window
|
|
|
|
|
from piker.ui._exec import run_qtractor
|
|
|
|
|
|
|
|
|
|
class Widget(QWidget):
|
|
|
|
|
def on_win_resize(self, event: QResizeEvent) -> None:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
windows: list[QWidget] = []
|
|
|
|
|
error = RuntimeError('qtractor guest sentinel')
|
|
|
|
|
|
|
|
|
|
async def main(widget: Widget) -> None:
|
|
|
|
|
windows.append(widget.window)
|
|
|
|
|
assert {reg_addr!r} in (
|
|
|
|
|
tractor.current_actor().accept_addrs
|
|
|
|
|
)
|
|
|
|
|
if {fail!r}:
|
|
|
|
|
raise error
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
run_qtractor(
|
|
|
|
|
func=main,
|
|
|
|
|
args=(),
|
|
|
|
|
main_widget_type=Widget,
|
|
|
|
|
tractor_kwargs={{
|
|
|
|
|
'registry_addrs': [{reg_addr!r}],
|
|
|
|
|
'tractor_runtime_overrides': {{
|
|
|
|
|
'piker_test_dir': {str(tmp_path)!r},
|
|
|
|
|
}},
|
|
|
|
|
}},
|
|
|
|
|
)
|
|
|
|
|
except RuntimeError as exc:
|
|
|
|
|
assert {fail!r}
|
|
|
|
|
assert exc is error
|
|
|
|
|
else:
|
|
|
|
|
assert not {fail!r}
|
|
|
|
|
|
|
|
|
|
assert len(windows) == 1
|
|
|
|
|
assert windows[0].qtractor_session is None
|
|
|
|
|
assert _window._qt_win is None
|
|
|
|
|
assert tractor.current_actor(
|
|
|
|
|
err_on_no_runtime=False,
|
|
|
|
|
) is None
|
|
|
|
|
''')
|
|
|
|
|
result: subprocess.CompletedProcess[str] = subprocess.run(
|
|
|
|
|
[sys.executable, '-c', script],
|
|
|
|
|
env={
|
|
|
|
|
**os.environ,
|
|
|
|
|
'QT_QPA_PLATFORM': 'offscreen',
|
|
|
|
|
'XDG_CONFIG_HOME': str(tmp_path / 'config'),
|
|
|
|
|
'XDG_CONFIG_DIRS': str(tmp_path / 'system-config'),
|
|
|
|
|
},
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=60,
|
|
|
|
|
)
|
|
|
|
|
assert result.returncode == 0, result.stdout + result.stderr
|