from unittest import mock import pytest import shiboken6 from qtpy.QtCore import SIGNAL, QEvent, QEventLoop, QObject, Qt, Signal from qtpy.QtGui import QHideEvent, QShowEvent from qtpy.QtTest import QTest from qtpy.QtWidgets import QApplication, QWidget import bec_widgets.widgets.editors.bec_console.bec_console as bec_console_module from bec_widgets.tests.client_mocks import mocked_client from bec_widgets.widgets.editors.bec_console.bec_console import ( BecConsole, BECShell, ConsoleMode, _bec_console_registry, ) from bec_widgets.widgets.utility.bec_term.qtermwidget_wrapper import BecQTerm def process_deferred_deletes(): app = QApplication.instance() app.sendPostedEvents(None, QEvent.DeferredDelete) app.processEvents(QEventLoop.AllEvents) @pytest.fixture(autouse=True) def clean_bec_console_registry(): _bec_console_registry.clear() yield _bec_console_registry.clear() process_deferred_deletes() @pytest.fixture def console_widget(qtbot): """Create a BecConsole widget.""" widget = BecConsole(client=mocked_client, gui_id="test_console", terminal_id="test_terminal") qtbot.addWidget(widget) return widget @pytest.fixture def two_console_widgets_same_terminal(qtbot): widget1 = BecConsole(client=mocked_client, gui_id="console_1", terminal_id="shared_terminal") widget2 = BecConsole(client=mocked_client, gui_id="console_2", terminal_id="shared_terminal") qtbot.addWidget(widget1) qtbot.addWidget(widget2) return widget1, widget2 def test_bec_console_initialization(console_widget: BecConsole): assert console_widget.console_id == "test_console" assert console_widget.terminal_id == "test_terminal" assert console_widget._mode == ConsoleMode.ACTIVE assert console_widget.term is not None assert console_widget.zoom_level == 1 assert console_widget._overlay.isHidden() console_widget.show() assert console_widget.isVisible() assert _bec_console_registry.owner_is_visible(console_widget.terminal_id) def test_bec_console_yield_terminal_ownership(console_widget): console_widget.show() console_widget.take_terminal_ownership() console_widget.yield_ownership() assert console_widget.term is None assert console_widget._mode == ConsoleMode.INACTIVE def test_bec_console_hide_event_yields_ownership(console_widget): console_widget.take_terminal_ownership() console_widget.hideEvent(QHideEvent()) assert console_widget.term is None assert console_widget._mode == ConsoleMode.HIDDEN def test_bec_console_show_event_takes_ownership(console_widget): console_widget.yield_ownership() console_widget.showEvent(QShowEvent()) assert console_widget.term is not None assert console_widget._mode == ConsoleMode.ACTIVE def test_bec_console_overlay_click_takes_ownership(qtbot, console_widget): console_widget.yield_ownership() assert console_widget._mode == ConsoleMode.HIDDEN QTest.mouseClick(console_widget._overlay, Qt.LeftButton) assert console_widget.term is not None assert console_widget._mode == ConsoleMode.ACTIVE assert not console_widget._overlay.isVisible() def test_two_consoles_shared_terminal(two_console_widgets_same_terminal): widget1, widget2 = two_console_widgets_same_terminal # Widget1 takes ownership widget1.take_terminal_ownership() assert widget1.term is not None assert widget1._mode == ConsoleMode.ACTIVE assert widget2.term is None assert widget2._mode == ConsoleMode.HIDDEN # Widget2 takes ownership widget2.take_terminal_ownership() assert widget2.term is not None assert widget2._mode == ConsoleMode.ACTIVE assert widget1.term is None assert widget1._mode == ConsoleMode.HIDDEN def test_bec_console_registry_cleanup(console_widget: BecConsole): console_widget.take_terminal_ownership() terminal_id = console_widget.terminal_id assert terminal_id in _bec_console_registry._terminal_registry _bec_console_registry.unregister(console_widget) assert terminal_id not in _bec_console_registry._terminal_registry class _FakeApp(QObject): """Stand-in for the QApplication: its aboutToQuit can be emitted without quitting the real test application, whose other aboutToQuit handlers would tear down shared state.""" aboutToQuit = Signal() class _StubConsole: """Minimal console exposing only what BecConsoleRegistry.register reads.""" def __init__(self, console_id: str, terminal_id: str): self.console_id = console_id self.terminal_id = terminal_id self.persist_terminal_session = False self.default_zoom_level = 0 def _about_to_quit_receivers(app: QObject) -> int: return app.receivers(SIGNAL("aboutToQuit()")) def _patch_app_instance(app: QObject): """Make the bec_console module (only) see ``app`` as the running application.""" return mock.patch.object(bec_console_module, "QApplication", **{"instance.return_value": app}) def test_bec_console_registry_clears_terminals_on_about_to_quit(qtbot): fake_app = _FakeApp() registry = bec_console_module.BecConsoleRegistry() consoles = [ _StubConsole("quit_console_1", "quit_t1"), _StubConsole("quit_console_2", "quit_t2"), ] try: with _patch_app_instance(fake_app): for console in consoles: registry.register(console) terminals = [registry.get_terminal("quit_t1"), registry.get_terminal("quit_t2")] assert all(term is not None for term in terminals) fake_app.aboutToQuit.emit() assert registry._terminal_registry == {} process_deferred_deletes() assert not any(shiboken6.isValid(term) for term in terminals) # registering twice must not wire the cleanup twice assert _about_to_quit_receivers(fake_app) == 1 finally: registry.clear() process_deferred_deletes() fake_app.deleteLater() def test_bec_console_registry_reconnects_cleanup_for_new_app(qtbot): """The once-only guard is per application: a new application instance gets wired too.""" registry = bec_console_module.BecConsoleRegistry() old_app, new_app = _FakeApp(), _FakeApp() try: for app in (old_app, new_app): with _patch_app_instance(app): registry._connect_app_cleanup() registry._connect_app_cleanup() assert _about_to_quit_receivers(app) == 1 finally: old_app.deleteLater() new_app.deleteLater() process_deferred_deletes() def test_bec_console_registry_cleanup_connected_once_to_real_app(qtbot): app = QApplication.instance() registry = bec_console_module.BecConsoleRegistry() before = _about_to_quit_receivers(app) try: registry._connect_app_cleanup() registry._connect_app_cleanup() assert _about_to_quit_receivers(app) == before + 1 finally: # never emit the real aboutToQuit here; just drop our connection again if _about_to_quit_receivers(app) > before: app.aboutToQuit.disconnect(registry.clear) def test_bec_shell_initialization(qtbot): widget = BECShell(gui_id="bec_shell") qtbot.addWidget(widget) assert widget.console_id == "bec_shell" assert widget.terminal_id == "bec_shell" assert widget.startup_cmd is not None assert widget.zoom_level == 1 def test_bec_console_write(console_widget): console_widget.take_terminal_ownership() with mock.patch.object(console_widget.term, "write") as mock_write: console_widget.write("test command") mock_write.assert_called_once_with("test command", True) def test_bec_console_write_can_target_shared_terminal_without_ownership(qtbot): owner = BecConsole(client=mocked_client, gui_id="owner_console", terminal_id="shared_submit") submitter = BecConsole( client=mocked_client, gui_id="submitter_console", terminal_id="shared_submit" ) qtbot.addWidget(owner) qtbot.addWidget(submitter) owner.take_terminal_ownership() assert owner.term is not None assert submitter.term is None with mock.patch.object(owner.term, "write") as mock_write: submitter.write("test command", regardless_of_ownership=True) mock_write.assert_called_once_with("test command", True) def test_bec_console_zoom_tracks_shared_terminal_without_ownership(qtbot): owner = BecConsole(client=mocked_client, gui_id="zoom_owner", terminal_id="shared_zoom") follower = BecConsole(client=mocked_client, gui_id="zoom_follower", terminal_id="shared_zoom") qtbot.addWidget(owner) qtbot.addWidget(follower) owner.take_terminal_ownership() assert owner.term is not None with ( mock.patch.object(owner.term, "zoom_in") as mock_zoom_in, mock.patch.object(owner.term, "zoom_out") as mock_zoom_out, ): assert follower.zoom_in() == 2 assert follower.zoom_level == 2 mock_zoom_in.assert_called_once_with() assert follower.zoom_out() == 1 assert follower.zoom_level == 1 mock_zoom_out.assert_called_once_with() def test_bec_console_reapplies_zoom_level_when_terminal_is_recreated(qtbot, monkeypatch): class RecordingTerminal(QWidget): zoom_in_calls = 0 zoom_out_calls = 0 def write(self, text: str, add_newline: bool = True): return None def zoom_in(self): type(self).zoom_in_calls += 1 def zoom_out(self): type(self).zoom_out_calls += 1 def send_ctrl_c(self): return None monkeypatch.setattr(bec_console_module, "_BecTermClass", RecordingTerminal) widget = BecConsole(client=mocked_client, gui_id="zoom_recreate", terminal_id="zoom_recreate") qtbot.addWidget(widget) widget.zoom_in() widget.zoom_in() assert widget.zoom_level == 3 assert RecordingTerminal.zoom_in_calls == 3 _bec_console_registry._terminal_registry[widget.terminal_id].instance = None widget.take_terminal_ownership() assert widget.zoom_level == 3 assert RecordingTerminal.zoom_in_calls == 6 def test_bec_console_starts_with_default_zoom_level(qtbot, monkeypatch): class RecordingTerminal(QWidget): zoom_in_calls = 0 zoom_out_calls = 0 def write(self, text: str, add_newline: bool = True): return None def zoom_in(self): type(self).zoom_in_calls += 1 def zoom_out(self): type(self).zoom_out_calls += 1 def send_ctrl_c(self): return None monkeypatch.setattr(bec_console_module, "_BecTermClass", RecordingTerminal) widget = BecConsole( client=mocked_client, gui_id="console_zoom_default", terminal_id="zoom_default" ) qtbot.addWidget(widget) assert widget.zoom_level == 1 assert RecordingTerminal.zoom_in_calls == 1 assert RecordingTerminal.zoom_out_calls == 0 def test_is_owner(console_widget: BecConsole): assert _bec_console_registry.is_owner(console_widget) mock_console = mock.MagicMock() mock_console.console_id = "fake_console" _bec_console_registry._consoles["fake_console"] = mock_console assert not _bec_console_registry.is_owner(mock_console) mock_console.terminal_id = console_widget.terminal_id assert not _bec_console_registry.is_owner(mock_console) def test_closing_active_console_keeps_terminal_valid_for_remaining_console(qtbot): widget1 = BecConsole(client=mocked_client, gui_id="close_owner", terminal_id="shared_close") widget2 = BecConsole(client=mocked_client, gui_id="remaining", terminal_id="shared_close") qtbot.addWidget(widget2) widget1.take_terminal_ownership() term = widget1.term assert term is not None widget1.close() widget1.deleteLater() process_deferred_deletes() assert shiboken6.isValid(term) widget2.take_terminal_ownership() assert widget2.term is term assert widget2._mode == ConsoleMode.ACTIVE def test_active_console_detaches_terminal_before_destruction(qtbot): widget1 = BecConsole(client=mocked_client, gui_id="owner", terminal_id="shared_detach") widget2 = BecConsole(client=mocked_client, gui_id="survivor", terminal_id="shared_detach") qtbot.addWidget(widget1) qtbot.addWidget(widget2) widget1.take_terminal_ownership() term = widget1.term assert term is not None assert widget1.isAncestorOf(term) widget1.close() assert shiboken6.isValid(term) assert not widget1.isAncestorOf(term) assert term.parent() is widget2._term_holder def test_bec_shell_terminal_persists_after_last_shell_unregisters(qtbot): shell = BECShell(gui_id="bec_shell_persistent") qtbot.addWidget(shell) term = shell.term assert term is not None _bec_console_registry.unregister(shell) info = _bec_console_registry._terminal_registry["bec_shell"] assert info.registered_console_ids == set() assert info.owner_console_id is None assert info.persist_session is True assert info.instance is term assert shiboken6.isValid(term) def test_new_bec_shell_claims_preserved_terminal(qtbot): shell1 = BECShell(gui_id="bec_shell_first") term = shell1.term assert term is not None shell1.close() shell1.deleteLater() process_deferred_deletes() assert "bec_shell" in _bec_console_registry._terminal_registry assert shiboken6.isValid(term) shell2 = BECShell(gui_id="bec_shell_second") qtbot.addWidget(shell2) shell2.showEvent(QShowEvent()) assert shell2.term is term assert shell2._mode == ConsoleMode.ACTIVE def test_persistent_bec_shell_sends_startup_command_once(qtbot, monkeypatch): class RecordingTerminal(QWidget): writes = [] def write(self, text: str, add_newline: bool = True): self.writes.append((text, add_newline)) monkeypatch.setattr(bec_console_module, "_BecTermClass", RecordingTerminal) shell1 = BECShell(gui_id="bec_shell_startup_first") shell1.close() shell1.deleteLater() process_deferred_deletes() shell2 = BECShell(gui_id="bec_shell_startup_second") qtbot.addWidget(shell2) shell2.showEvent(QShowEvent()) assert len(RecordingTerminal.writes) == 1 assert RecordingTerminal.writes[0][0].startswith("bec ") assert RecordingTerminal.writes[0][1] is True def test_plain_console_terminal_removed_after_last_unregister(qtbot): widget = BecConsole(client=mocked_client, gui_id="plain_console", terminal_id="plain_terminal") qtbot.addWidget(widget) assert "plain_terminal" in _bec_console_registry._terminal_registry _bec_console_registry.unregister(widget) assert "plain_terminal" not in _bec_console_registry._terminal_registry def test_bec_qterm_clipboard_shortcuts_call_terminal_clipboard_methods(qtbot): term = BecQTerm() qtbot.addWidget(term) with ( mock.patch.object(term, "_copyClipboard") as mock_copy, mock.patch.object(term, "_pasteClipboard") as mock_paste, ): term._clipboard_shortcuts["copy"].activated.emit() term._clipboard_shortcuts["paste"].activated.emit() mock_copy.assert_called_once_with() mock_paste.assert_called_once_with()