diff --git a/DisplayCAL/VERSION b/DisplayCAL/VERSION index 3ce8dc805..c4a00fc9a 100644 --- a/DisplayCAL/VERSION +++ b/DisplayCAL/VERSION @@ -1 +1 @@ -3.10.0.dev48 \ No newline at end of file +3.10.0.dev49 \ No newline at end of file diff --git a/DisplayCAL/ui/main_window.py b/DisplayCAL/ui/main_window.py index 4f8b7ce62..8d5328007 100644 --- a/DisplayCAL/ui/main_window.py +++ b/DisplayCAL/ui/main_window.py @@ -177,6 +177,7 @@ argyll_version_at_least, check_argyll_bin, check_set_argyll_bin, + get_argyll_instrument_config, get_argyll_util, make_argyll_compatible_path, ) @@ -278,7 +279,7 @@ ) from DisplayCAL.util_decimal import stripzeros from DisplayCAL.util_dict import dict_sort -from DisplayCAL.util_os import get_program_file, launch_file, waccess +from DisplayCAL.util_os import get_program_file, launch_file, waccess, which from DisplayCAL.worker import ( Worker, check_file_isfile, @@ -602,6 +603,79 @@ def related_files(self) -> dict[str, bool]: return {name: cb.isChecked() for name, cb in self._checks.items()} +class _InstrumentConfUninstallDialog(QDialog): + """Qt port of the checkbox ``ConfirmDialog`` in ``install_argyll_instrument_conf``. + + Lets the user individually toggle which installed Argyll instrument + udev-rule/hotplug files get uninstalled, mirroring wx's per-file + ``wx.CheckBox`` list, all pre-checked. + """ + + def __init__(self, filenames: list[str], parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setWindowTitle( + lang.getstr("argyll.instrument.configuration_files.uninstall") + ) + layout = QVBoxLayout(self) + label = QLabel(lang.getstr("dialog.confirm_uninstall")) + label.setWordWrap(True) + layout.addWidget(label) + + self._checks: dict[str, QCheckBox] = {} + for filename in filenames: + cb = QCheckBox(filename) + cb.setChecked(True) + self._checks[filename] = cb + layout.addWidget(cb) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.button(QDialogButtonBox.Ok).setText(lang.getstr("uninstall")) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def selected_filenames(self) -> list[str]: + return [name for name, cb in self._checks.items() if cb.isChecked()] + + +class _InstrumentDriversConfirmDialog(QDialog): + """Qt port of the ``ConfirmDialog`` in ``install_argyll_instrument_drivers``. + + A single "launch device manager afterwards" checkbox alongside the + confirm/cancel buttons, mirroring wx's ``dlg.launch_devman`` checkbox, + which starts pre-checked only when uninstalling (matching wx's + ``dlg.launch_devman.SetValue(uninstall)``). + """ + + def __init__( + self, + title: str, + msg: str, + ok_label: str, + uninstall: bool, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setWindowTitle(title) + layout = QVBoxLayout(self) + label = QLabel(msg) + label.setWordWrap(True) + layout.addWidget(label) + + self._launch_devman_cb = QCheckBox(lang.getstr("device_manager.launch")) + self._launch_devman_cb.setChecked(uninstall) + layout.addWidget(self._launch_devman_cb) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.button(QDialogButtonBox.Ok).setText(ok_label) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def launch_devman(self) -> bool: + return self._launch_devman_cb.isChecked() + + class _DonationDialog(QDialog): """Qt port of ``display_cal.donation_message``. @@ -2138,10 +2212,28 @@ def _build_tools_menu(self) -> None: :mod:`DisplayCAL.ui.spyder2_enable`) and "calibrate_instrument" (:meth:`_calibrate_instrument_action_handler`, running ``Worker.calibrate_instrument_producer`` through the shared - :class:`WorkerRunController`) are reproduced; the Argyll instrument - configuration-file / driver install-uninstall entries are not (they - need the still-unported driver-installer / `oeminst` plumbing wx's - own handlers wrap). + :class:`WorkerRunController`) are reproduced. The four + platform-conditional Argyll instrument configuration-file / driver + install-uninstall entries are reproduced too + (:meth:`_install_argyll_instrument_conf_action_handler` / + :meth:`_install_argyll_instrument_drivers_action_handler`), gated by + the same ``sys.platform`` checks (plus ``TEST``) wx's own + ``MainFrame.__init__`` uses to ``Bind``/``RemoveItem`` them: the udev + configuration-file pair is Linux-only, the driver-install entry is + Windows-only, and the driver-uninstall entry additionally needs + Windows Vista or newer. wx's inline ``ConfirmDialog`` checkbox list + for picking which installed configuration files to uninstall becomes + :class:`_InstrumentConfUninstallDialog` (the same pattern as + :class:`_DeleteConfirmationDialog`), and its "this is a system file" + second-guess prompt is a plain :class:`QMessageBox`. The driver + install/uninstall confirmation (with its "launch Device Manager + afterwards" checkbox) becomes :class:`_InstrumentDriversConfirmDialog`. + Both reuse the already-ported ``Worker.install_argyll_instrument_conf`` + / ``Worker.install_argyll_instrument_drivers`` producers through the + shared :class:`WorkerRunController`, and + :meth:`Worker.authenticate` is called upfront on the GUI thread + exactly as wx does, since ``Worker.exec_cmd``'s ``asroot`` handling + refuses to prompt for a password from a background thread. wx's ``video_card_gamma_table`` submenu (load/reset the display's video card gamma table directly, independent of a full calibrate @@ -2209,6 +2301,43 @@ def _build_tools_menu(self) -> None: reset_cal_action.triggered.connect(self._reset_video_lut_action_handler) instrument_menu = tools_menu.addMenu(lang.getstr("instrument")) + self.install_argyll_instrument_conf_action: QAction | None = None + self.uninstall_argyll_instrument_conf_action: QAction | None = None + self.install_argyll_instrument_drivers_action: QAction | None = None + self.uninstall_argyll_instrument_drivers_action: QAction | None = None + if sys.platform not in ("darwin", "win32") or TEST: + # Linux may need instrument access being set up (udev rules) + self.install_argyll_instrument_conf_action = instrument_menu.addAction( + lang.getstr("argyll.instrument.configuration_files.install") + ) + self.install_argyll_instrument_conf_action.triggered.connect( + lambda: self._install_argyll_instrument_conf_action_handler(False) + ) + self.uninstall_argyll_instrument_conf_action = instrument_menu.addAction( + lang.getstr("argyll.instrument.configuration_files.uninstall") + ) + self.uninstall_argyll_instrument_conf_action.triggered.connect( + lambda: self._install_argyll_instrument_conf_action_handler(True) + ) + self._update_instrument_conf_menu_state() + if sys.platform == "win32" or TEST: + # Windows may need an Argyll CMS instrument driver + self.install_argyll_instrument_drivers_action = instrument_menu.addAction( + lang.getstr("argyll.instrument.drivers.install") + ) + self.install_argyll_instrument_drivers_action.triggered.connect( + lambda: self._install_argyll_instrument_drivers_action_handler(False) + ) + if (sys.platform == "win32" and sys.getwindowsversion() >= (6,)) or TEST: + # Windows Vista and newer can uninstall the Argyll CMS instrument driver + self.uninstall_argyll_instrument_drivers_action = ( + instrument_menu.addAction( + lang.getstr("argyll.instrument.drivers.uninstall") + ) + ) + self.uninstall_argyll_instrument_drivers_action.triggered.connect( + lambda: self._install_argyll_instrument_drivers_action_handler(True) + ) self.enable_spyder2_action = instrument_menu.addAction( lang.getstr("enable_spyder2") ) @@ -2357,6 +2486,153 @@ def _on_calibrate_instrument_finished(self, result: object) -> None: if isinstance(result, Exception): message_box.critical(self, APPNAME, str(result)) + def _update_instrument_conf_menu_state(self) -> None: + """Refresh the enabled state of the udev conf install/uninstall actions. + + Qt port of the corresponding slice of wx's ``update_menus``: only + enable "install" if the configuration isn't already installed (and is + installable), and only enable "uninstall" if it is installed. + """ + if self.install_argyll_instrument_conf_action is None: + return + installed = get_argyll_instrument_config("installed") + installable = get_argyll_instrument_config() + self.install_argyll_instrument_conf_action.setEnabled( + bool(not installed and installable) + ) + self.uninstall_argyll_instrument_conf_action.setEnabled( + bool(installed and installable) + ) + + def _confirm_instrument_conf_system_file_removal(self, filename: str) -> bool: + """Qt port of the second, system-file ``ConfirmDialog``. + + See ``install_argyll_instrument_conf``. + """ + box = QMessageBox(self) + box.setWindowTitle( + lang.getstr("argyll.instrument.configuration_files.uninstall") + ) + box.setIcon(QMessageBox.Warning) + box.setText(lang.getstr("warning.system_file", filename)) + continue_button = box.addButton( + lang.getstr("continue"), QMessageBox.AcceptRole + ) + box.addButton(lang.getstr("cancel"), QMessageBox.RejectRole) + message_box.exec_box(box) + return box.clickedButton() is continue_button + + def _install_argyll_instrument_conf_action_handler(self, uninstall: bool) -> None: + """(Un)install Argyll instrument udev rules/hotplug scripts (Linux). + + Instrument menu action. Qt port of ``install_argyll_instrument_conf``/ + ``uninstall_argyll_instrument_conf``: for uninstall, lets the user + toggle which installed files to remove + (:class:`_InstrumentConfUninstallDialog`), warning separately before + removing any file under ``/lib/udev/rules.d`` (likely owned by + another package, e.g. ``colord``, rather than installed by this + action). Authenticates for the elevated ``cp``/``rm`` upfront on the + GUI thread, exactly as wx does, then runs + ``Worker.install_argyll_instrument_conf`` through the shared + :class:`WorkerRunController`. + """ + filenames = None + cmd = "cp" + if uninstall: + filenames = get_argyll_instrument_config("installed") + if not filenames: + return + dialog = _InstrumentConfUninstallDialog(filenames, self) + if dialog.exec_() != QDialog.Accepted: + return + filenames = dialog.selected_filenames() + if not filenames: + return + for filename in filenames: + if os.path.dirname(filename) != "/lib/udev/rules.d": + continue + if not self._confirm_instrument_conf_system_file_removal(filename): + return + cmd = "rm" + + result = self.worker.authenticate(which(cmd)) + if result not in (True, None): + if isinstance(result, Exception): + message_box.critical(self, APPNAME, str(result)) + return + + controller = self._ensure_run_controller() + controller.run( + self.worker.install_argyll_instrument_conf, + lambda result: self._on_install_argyll_instrument_conf_finished( + result, uninstall + ), + wkwargs={"uninstall": uninstall, "filenames": filenames}, + progress_msg=lang.getstr( + "argyll.instrument.configuration_files." + + ("uninstall" if uninstall else "install") + ), + pauseable=False, + ) + + def _on_install_argyll_instrument_conf_finished( + self, result: object, uninstall: bool + ) -> None: + if isinstance(result, Exception): + message_box.critical(self, APPNAME, str(result)) + elif result is False: + message_box.critical( + self, APPNAME, "".join(self.worker.errors) or lang.getstr("error") + ) + else: + self._update_instrument_conf_menu_state() + msgid = "argyll.instrument.configuration_files." + ( + "uninstall.success" if uninstall else "install.success" + ) + message_box.information(self, APPNAME, lang.getstr(msgid)) + + def _install_argyll_instrument_drivers_action_handler( + self, uninstall: bool + ) -> None: + """(Un)install the Argyll instrument USB driver (Instrument menu, Windows). + + Qt port of ``install_argyll_instrument_drivers``/ + ``uninstall_argyll_instrument_drivers``: confirms via + :class:`_InstrumentDriversConfirmDialog`, then runs + ``Worker.install_argyll_instrument_drivers`` through the shared + :class:`WorkerRunController`. Unlike wx's own handler, which calls + ``self.check_update_controls(True)`` (a full re-detect-displays pass) + on success, this refreshes via the lighter :meth:`update_controls` -- + the Qt port has no equivalent of that heavier flow yet. + """ + if uninstall: + title = lang.getstr("argyll.instrument.drivers.uninstall") + msg = lang.getstr("argyll.instrument.drivers.uninstall.confirm") + ok_label = lang.getstr("continue") + else: + title = lang.getstr("argyll.instrument.drivers.install") + msg = lang.getstr("argyll.instrument.drivers.install.confirm") + ok_label = lang.getstr("download_install") + dialog = _InstrumentDriversConfirmDialog(title, msg, ok_label, uninstall, self) + if dialog.exec_() != QDialog.Accepted: + return + launch_devman = dialog.launch_devman() + + controller = self._ensure_run_controller() + controller.run( + self.worker.install_argyll_instrument_drivers, + self._on_install_argyll_instrument_drivers_finished, + wargs=(uninstall, launch_devman), + progress_msg=title, + pauseable=False, + ) + + def _on_install_argyll_instrument_drivers_finished(self, result: object) -> None: + if isinstance(result, Exception): + message_box.critical(self, APPNAME, str(result)) + else: + self.update_controls() + def _show_curves_action_handler(self) -> None: """Open the calibration curve viewer (Tools menu). diff --git a/DisplayCAL/worker.py b/DisplayCAL/worker.py index f3d691e1c..9eedffca1 100644 --- a/DisplayCAL/worker.py +++ b/DisplayCAL/worker.py @@ -3041,7 +3041,11 @@ def authenticate(self, cmd, title=APPNAME, parent=None): cmd = which(ocmd) if not cmd or not os.path.isfile(cmd): return Error(lang.getstr("file.missing", ocmd)) - _disabler = BetterWindowDisabler() + # BetterWindowDisabler.disable() unconditionally touches + # wx.GetTopLevelWindows(), which needs a live wx.App -- under Qt-only + # operation (no wx.App running) this doesn't raise a catchable + # exception on Linux/GTK, it crashes the whole process natively. + _disabler = BetterWindowDisabler() if wx.GetApp() is not None else None result = True if not self.sudo: self.sudo = Sudo() diff --git a/tests/conftest.py b/tests/conftest.py index 82ded7e60..e629a5bb8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -109,7 +109,7 @@ def _exit_before_native_shutdown_crash(): from DisplayCAL.debughelpers import DownloadError import pytest -from DisplayCAL import config +from DisplayCAL import audio, config from DisplayCAL.util_os import which from DisplayCAL.worker import Worker @@ -150,6 +150,19 @@ def _exit_before_native_shutdown_crash(): except ImportError: pass +# Every "instrument connected" / "measurement taken" / startup chime in the +# app goes through audio.Sound(path).safe_play() (worker.py's measurement_/ +# commit_sound, display_cal.py's startup_sound, ui/startup.py's +# play_startup_sound(), ui/untethered_window.py, wx_windows.py's progress +# gauge, ...). All of those call sites do `audio.Sound(...)` module-qualified +# rather than `from DisplayCAL.audio import Sound`, so replacing the class +# attribute here -- before any other DisplayCAL module has a chance to call +# it -- makes every one of them transparently construct the module's own +# no-op DummySound instead, silencing beep.wav/camera_shutter.wav/ +# intro_new.wav etc. for the whole test session without needing a per-file +# monkeypatch. +audio.Sound = audio.DummySound + @pytest.fixture(scope="module") def data_files(): diff --git a/tests/test_ui_main_window.py b/tests/test_ui_main_window.py index 042d11a36..1cc88ba32 100644 --- a/tests/test_ui_main_window.py +++ b/tests/test_ui_main_window.py @@ -8,6 +8,7 @@ import os import shutil +import sys import time from types import SimpleNamespace @@ -90,6 +91,17 @@ def _init_config(): setcfg("3dlut.format", "cube") setcfg("profile.black_point_compensation", 0) setcfg("calibration.black_point_correction.auto", 0) + # test_confirm_bpc_choice_turn_on_accept_persists_bpc drives the real + # _confirm_black_point_correction_choice() "accept" path, which persists + # calibration.black_point_correction = 1.0, and never restores it. Left + # leaked, a later test's freshly-constructed window initializes its + # black-point-correction control from that value, so + # get_black_point_correction() > 0 -- one of + # measurement_mode_ctrl_handler()'s guards for showing the (here + # unmocked) BPC choice QMessageBox -- becomes true too, hanging the run + # exactly like the other pinned keys above (confirmed via a real CI hang + # in test_measurement_mode_ctrl_populates_and_persists). + setcfg("calibration.black_point_correction", 0.0) orig_argyll_version = getcfg("argyll.version") setcfg("argyll.version", "0.0.0") yield @@ -7373,17 +7385,29 @@ def test_report_menu_matches_wx_xrc_order(window): assert texts == actual -def test_instrument_menu_matches_wx_xrc_order(window): - expected = ["enable_spyder2", "", "calibrate_instrument"] - actual = [lang.getstr(key) if key else "" for key in expected] - for action in window._tools_menu.actions(): - if action.text() == lang.getstr("instrument"): - instrument_menu = action.menu() - break - else: - pytest.fail("instrument submenu not found") - texts = [action.text() for action in instrument_menu.actions()] - assert texts == actual +def test_instrument_menu_matches_wx_xrc_order(qapp, stub_worker, monkeypatch): + # The Instrument submenu gains extra, platform-conditional items on + # Linux/Windows (#851), so this order assertion is only meaningful + # against a fixed platform rather than whatever the CI host happens to + # be; pin to macOS/the pre-#851 baseline like the sibling tests below do + # (via _build_window_with_platform, defined further down in this file's + # #851 section -- see its docstring for why a naive + # ``monkeypatch.setattr(mw.sys, "platform", ...)`` + ``mw.MainWindow()`` + # isn't safe here). + win = _build_window_with_platform(monkeypatch, "darwin") + try: + expected = ["enable_spyder2", "", "calibrate_instrument"] + actual = [lang.getstr(key) if key else "" for key in expected] + for action in win._tools_menu.actions(): + if action.text() == lang.getstr("instrument"): + instrument_menu = action.menu() + break + else: + pytest.fail("instrument submenu not found") + texts = [action.text() for action in instrument_menu.actions()] + assert texts == actual + finally: + win.close() def test_ccxx_menu_matches_wx_xrc_order(window): @@ -7495,6 +7519,475 @@ def test_calibrate_instrument_finished_noop_on_success(window, monkeypatch): assert errors == [] +# --- instrument udev conf / driver install-uninstall (#851) ----------------- +# +# Menu-item presence is asserted via the stored ``self.*_action`` attributes +# (mirroring wx's own ``self.menuitem_*`` refs for these same four items) +# rather than by walking ``QMenu.actions()``: under this offscreen macOS test +# environment, drilling into a *nested* ``QMenu`` (``action.menu()``) is +# unreliable -- it can raise "libshiboken: Internal C++ object ... already +# deleted" even for an untouched, freshly built menu -- while the top-level +# ``QAction`` references construction already assigns to ``self`` stay valid. + + +class _FakeSysModule: + """Stand-in for the ``sys`` module with ``platform`` overridden. + + Delegates every other attribute (``argv``, ``exit``, + ``getwindowsversion``, ...) to the real ``sys`` module via + ``__getattr__``. Used by :func:`_build_window_with_platform` instead of + ``monkeypatch.setattr(mw.sys, "platform", ...)``: ``mw.sys`` is the + real, single global ``sys`` module, so mutating its ``platform`` + attribute doesn't just affect ``_build_tools_menu()``'s own gate -- it + also reaches every other module's ``sys.platform`` reads for the + duration of the test, including ones with real, un-mockable + consequences: CPython's own ``multiprocessing`` resource-tracker + dispatches its spawn path off it (spoofing "linux" while the real host + is Windows makes ``Worker()`` -> ``ThreadAbort()`` -> ``mp.Event()`` + try to ``import _posixsubprocess``, which doesn't exist there, crashing + the whole worker), and ``DisplayCAL.config`` only imports + ``LIBRARY``/``LIBRARY_HOME`` at module-import time when the *real* host + is "darwin" (spoofing "darwin" on a real Linux host then makes + ``colorimeter_correction.py``'s data-file lookup raise + ``AttributeError: module 'DisplayCAL.config' has no attribute + 'LIBRARY'``). Both were hit for real in CI (PR #881, runs 29646328469 + and 29647026132). Replacing the *name* ``sys`` inside + ``main_window``'s own module namespace, instead of mutating the shared + module object, confines the spoof to exactly the one place under test. + """ + + def __init__(self, platform: str) -> None: + self.platform = platform + + def __getattr__(self, name): + return getattr(sys, name) + + +def _build_window_with_platform(monkeypatch, platform: str) -> mw.MainWindow: + """Construct a MainWindow with ``sys.platform`` spoofed to ``platform``. + + See :class:`_FakeSysModule` for why this doesn't use + ``monkeypatch.setattr(mw.sys, "platform", ...)``. + """ + monkeypatch.setattr(mw, "sys", _FakeSysModule(platform)) + return mw.MainWindow() + + +def test_instrument_conf_and_driver_actions_absent_on_macos_by_default( + qapp, stub_worker, monkeypatch +): + # Pin the platform explicitly rather than relying on the shared `window` + # fixture: these actions are gated by the *real* host sys.platform (see + # #851), so this "absent on macOS" assertion only holds when run on an + # actual macOS CI host, not on Linux/Windows CI, where the same fixture + # would legitimately create them. + win = _build_window_with_platform(monkeypatch, "darwin") + try: + assert win.install_argyll_instrument_conf_action is None + assert win.uninstall_argyll_instrument_conf_action is None + assert win.install_argyll_instrument_drivers_action is None + assert win.uninstall_argyll_instrument_drivers_action is None + finally: + win.close() + + +def test_instrument_conf_actions_present_on_linux(qapp, stub_worker, monkeypatch): + win = _build_window_with_platform(monkeypatch, "linux") + try: + assert win.install_argyll_instrument_conf_action is not None + assert win.uninstall_argyll_instrument_conf_action is not None + assert win.install_argyll_instrument_conf_action.text() == lang.getstr( + "argyll.instrument.configuration_files.install" + ) + assert win.uninstall_argyll_instrument_conf_action.text() == lang.getstr( + "argyll.instrument.configuration_files.uninstall" + ) + assert win.install_argyll_instrument_drivers_action is None + assert win.uninstall_argyll_instrument_drivers_action is None + finally: + win.close() + + +def test_instrument_menu_all_actions_present_under_test_flag( + qapp, stub_worker, monkeypatch +): + # Matches wx: every platform-conditional item is bound under TEST, + # regardless of the actual host platform, so it can be exercised in CI + # without needing a real Linux/Windows box (see the #851 issue notes). + monkeypatch.setattr(mw, "TEST", True) + win = mw.MainWindow() + try: + assert win.install_argyll_instrument_conf_action is not None + assert win.uninstall_argyll_instrument_conf_action is not None + assert win.install_argyll_instrument_drivers_action is not None + assert win.uninstall_argyll_instrument_drivers_action is not None + assert win.install_argyll_instrument_drivers_action.text() == lang.getstr( + "argyll.instrument.drivers.install" + ) + assert win.uninstall_argyll_instrument_drivers_action.text() == lang.getstr( + "argyll.instrument.drivers.uninstall" + ) + finally: + win.close() + + +def test_update_instrument_conf_menu_state_noop_without_action( + qapp, stub_worker, monkeypatch +): + # Same host-platform-dependence as the "absent on macOS" test above: + # pin to macOS so install_argyll_instrument_conf_action is actually None + # regardless of the CI host running Linux/Windows. + win = _build_window_with_platform(monkeypatch, "darwin") + try: + assert win.install_argyll_instrument_conf_action is None + win._update_instrument_conf_menu_state() # Must not raise. + finally: + win.close() + + +def test_update_instrument_conf_menu_state_enables_correctly(window, monkeypatch): + install_action = mw.QAction("install", window) + uninstall_action = mw.QAction("uninstall", window) + window.install_argyll_instrument_conf_action = install_action + window.uninstall_argyll_instrument_conf_action = uninstall_action + + monkeypatch.setattr( + mw, + "get_argyll_instrument_config", + lambda what=None: [] if what == "installed" else ["usb/55-Argyll.rules"], + ) + window._update_instrument_conf_menu_state() + assert install_action.isEnabled() is True + assert uninstall_action.isEnabled() is False + + monkeypatch.setattr( + mw, + "get_argyll_instrument_config", + lambda what=None: ( + ["/etc/udev/rules.d/55-Argyll.rules"] + if what == "installed" + else ["usb/55-Argyll.rules"] + ), + ) + window._update_instrument_conf_menu_state() + assert install_action.isEnabled() is False + assert uninstall_action.isEnabled() is True + + +def test_confirm_instrument_conf_system_file_removal_true_on_continue( + window, monkeypatch +): + monkeypatch.setattr(mw.QMessageBox, "exec_", lambda self: None) + monkeypatch.setattr(mw.QMessageBox, "clickedButton", lambda self: self.buttons()[0]) + assert ( + window._confirm_instrument_conf_system_file_removal( + "/lib/udev/rules.d/55-Argyll.rules" + ) + is True + ) + + +def test_confirm_instrument_conf_system_file_removal_false_on_cancel( + window, monkeypatch +): + monkeypatch.setattr(mw.QMessageBox, "exec_", lambda self: None) + monkeypatch.setattr(mw.QMessageBox, "clickedButton", lambda self: self.buttons()[1]) + assert ( + window._confirm_instrument_conf_system_file_removal( + "/lib/udev/rules.d/55-Argyll.rules" + ) + is False + ) + + +class _FakeInstrumentConfUninstallDialog: + """Stand-in for ``mw._InstrumentConfUninstallDialog`` that skips the modal loop.""" + + answer = None # set per-test + selected = None # set per-test; None keeps every filename selected + + def __init__(self, filenames, parent=None): + self._filenames = filenames + + def exec_(self): + return self.__class__.answer + + def selected_filenames(self): + if self.__class__.selected is not None: + return self.__class__.selected + return list(self._filenames) + + +class _FakeInstrumentDriversConfirmDialog: + """Stand-in for ``mw._InstrumentDriversConfirmDialog`` that skips the modal loop.""" + + answer = None # set per-test + launch = False # set per-test + + def __init__(self, title, msg, ok_label, uninstall, parent=None): + pass + + def exec_(self): + return self.__class__.answer + + def launch_devman(self): + return self.__class__.launch + + +def test_install_argyll_instrument_conf_handler_install_runs_producer( + window, monkeypatch +): + monkeypatch.setattr(window.worker, "authenticate", lambda cmd: True) + run_calls = [] + + class _FakeController: + def run(self, *a, **k): + run_calls.append((a, k)) + + monkeypatch.setattr(window, "_ensure_run_controller", lambda: _FakeController()) + window._install_argyll_instrument_conf_action_handler(False) + assert run_calls + args, kwargs = run_calls[0] + assert args[0] == window.worker.install_argyll_instrument_conf + assert kwargs["wkwargs"] == {"uninstall": False, "filenames": None} + assert kwargs["pauseable"] is False + + +def test_install_argyll_instrument_conf_handler_uninstall_no_installed_files_is_noop( + window, monkeypatch +): + monkeypatch.setattr(mw, "get_argyll_instrument_config", lambda what=None: []) + controller_calls = [] + monkeypatch.setattr( + window, "_ensure_run_controller", lambda: controller_calls.append(True) + ) + window._install_argyll_instrument_conf_action_handler(True) + assert controller_calls == [] + + +def test_install_argyll_instrument_conf_handler_uninstall_dialog_rejected_is_noop( + window, monkeypatch +): + monkeypatch.setattr( + mw, + "get_argyll_instrument_config", + lambda what=None: ["/etc/udev/rules.d/55-Argyll.rules"], + ) + _FakeInstrumentConfUninstallDialog.answer = mw.QDialog.Rejected + monkeypatch.setattr( + mw, "_InstrumentConfUninstallDialog", _FakeInstrumentConfUninstallDialog + ) + controller_calls = [] + monkeypatch.setattr( + window, "_ensure_run_controller", lambda: controller_calls.append(True) + ) + window._install_argyll_instrument_conf_action_handler(True) + assert controller_calls == [] + + +def test_install_argyll_instrument_conf_handler_uninstall_no_files_selected_is_noop( + window, monkeypatch +): + monkeypatch.setattr( + mw, + "get_argyll_instrument_config", + lambda what=None: ["/etc/udev/rules.d/55-Argyll.rules"], + ) + _FakeInstrumentConfUninstallDialog.answer = mw.QDialog.Accepted + _FakeInstrumentConfUninstallDialog.selected = [] + monkeypatch.setattr( + mw, "_InstrumentConfUninstallDialog", _FakeInstrumentConfUninstallDialog + ) + controller_calls = [] + monkeypatch.setattr( + window, "_ensure_run_controller", lambda: controller_calls.append(True) + ) + window._install_argyll_instrument_conf_action_handler(True) + assert controller_calls == [] + + +def test_install_argyll_instrument_conf_handler_system_file_declined_aborts( + window, monkeypatch +): + filename = "/lib/udev/rules.d/55-Argyll.rules" + monkeypatch.setattr( + mw, "get_argyll_instrument_config", lambda what=None: [filename] + ) + _FakeInstrumentConfUninstallDialog.answer = mw.QDialog.Accepted + _FakeInstrumentConfUninstallDialog.selected = [filename] + monkeypatch.setattr( + mw, "_InstrumentConfUninstallDialog", _FakeInstrumentConfUninstallDialog + ) + monkeypatch.setattr( + window, "_confirm_instrument_conf_system_file_removal", lambda fn: False + ) + controller_calls = [] + monkeypatch.setattr( + window, "_ensure_run_controller", lambda: controller_calls.append(True) + ) + window._install_argyll_instrument_conf_action_handler(True) + assert controller_calls == [] + + +def test_install_argyll_instrument_conf_handler_uninstall_runs_producer( + window, monkeypatch +): + filename = "/etc/udev/rules.d/55-Argyll.rules" + monkeypatch.setattr( + mw, "get_argyll_instrument_config", lambda what=None: [filename] + ) + _FakeInstrumentConfUninstallDialog.answer = mw.QDialog.Accepted + _FakeInstrumentConfUninstallDialog.selected = [filename] + monkeypatch.setattr( + mw, "_InstrumentConfUninstallDialog", _FakeInstrumentConfUninstallDialog + ) + monkeypatch.setattr(window.worker, "authenticate", lambda cmd: True) + run_calls = [] + + class _FakeController: + def run(self, *a, **k): + run_calls.append((a, k)) + + monkeypatch.setattr(window, "_ensure_run_controller", lambda: _FakeController()) + window._install_argyll_instrument_conf_action_handler(True) + assert run_calls + args, kwargs = run_calls[0] + assert args[0] == window.worker.install_argyll_instrument_conf + assert kwargs["wkwargs"] == {"uninstall": True, "filenames": [filename]} + + +def test_install_argyll_instrument_conf_handler_auth_exception_shows_error( + window, monkeypatch +): + monkeypatch.setattr( + window.worker, "authenticate", lambda cmd: RuntimeError("boom") + ) + errors = [] + monkeypatch.setattr(mw.QMessageBox, "critical", lambda *a, **k: errors.append(a)) + controller_calls = [] + monkeypatch.setattr( + window, "_ensure_run_controller", lambda: controller_calls.append(True) + ) + window._install_argyll_instrument_conf_action_handler(False) + assert errors + assert controller_calls == [] + + +def test_install_argyll_instrument_conf_handler_auth_cancelled_is_silent( + window, monkeypatch +): + monkeypatch.setattr(window.worker, "authenticate", lambda cmd: False) + errors = [] + monkeypatch.setattr(mw.QMessageBox, "critical", lambda *a, **k: errors.append(a)) + controller_calls = [] + monkeypatch.setattr( + window, "_ensure_run_controller", lambda: controller_calls.append(True) + ) + window._install_argyll_instrument_conf_action_handler(False) + assert errors == [] + assert controller_calls == [] + + +def test_on_install_argyll_instrument_conf_finished_exception_shows_error( + window, monkeypatch +): + errors = [] + monkeypatch.setattr(mw.QMessageBox, "critical", lambda *a, **k: errors.append(a)) + window._on_install_argyll_instrument_conf_finished(RuntimeError("boom"), False) + assert errors + + +def test_on_install_argyll_instrument_conf_finished_false_shows_worker_errors( + window, monkeypatch +): + window.worker.errors = ["disk full"] + errors = [] + monkeypatch.setattr(mw.QMessageBox, "critical", lambda *a, **k: errors.append(a)) + window._on_install_argyll_instrument_conf_finished(False, True) + assert errors + assert "disk full" in errors[0][2] + + +def test_on_install_argyll_instrument_conf_finished_success_shows_info( + window, monkeypatch +): + infos = [] + monkeypatch.setattr( + mw.QMessageBox, "information", lambda *a, **k: infos.append(a) + ) + refresh_calls = [] + monkeypatch.setattr( + window, + "_update_instrument_conf_menu_state", + lambda: refresh_calls.append(True), + ) + window._on_install_argyll_instrument_conf_finished(True, False) + assert refresh_calls == [True] + assert infos + assert infos[0][2] == lang.getstr( + "argyll.instrument.configuration_files.install.success" + ) + + +def test_install_argyll_instrument_drivers_handler_dialog_rejected_is_noop( + window, monkeypatch +): + _FakeInstrumentDriversConfirmDialog.answer = mw.QDialog.Rejected + monkeypatch.setattr( + mw, "_InstrumentDriversConfirmDialog", _FakeInstrumentDriversConfirmDialog + ) + controller_calls = [] + monkeypatch.setattr( + window, "_ensure_run_controller", lambda: controller_calls.append(True) + ) + window._install_argyll_instrument_drivers_action_handler(False) + assert controller_calls == [] + + +def test_install_argyll_instrument_drivers_handler_runs_producer( + window, monkeypatch +): + _FakeInstrumentDriversConfirmDialog.answer = mw.QDialog.Accepted + _FakeInstrumentDriversConfirmDialog.launch = True + monkeypatch.setattr( + mw, "_InstrumentDriversConfirmDialog", _FakeInstrumentDriversConfirmDialog + ) + run_calls = [] + + class _FakeController: + def run(self, *a, **k): + run_calls.append((a, k)) + + monkeypatch.setattr(window, "_ensure_run_controller", lambda: _FakeController()) + window._install_argyll_instrument_drivers_action_handler(True) + assert run_calls + args, kwargs = run_calls[0] + assert args[0] == window.worker.install_argyll_instrument_drivers + assert kwargs["wargs"] == (True, True) + assert kwargs["pauseable"] is False + + +def test_on_install_argyll_instrument_drivers_finished_exception_shows_error( + window, monkeypatch +): + errors = [] + monkeypatch.setattr(mw.QMessageBox, "critical", lambda *a, **k: errors.append(a)) + update_calls = [] + monkeypatch.setattr(window, "update_controls", lambda: update_calls.append(True)) + window._on_install_argyll_instrument_drivers_finished(RuntimeError("boom")) + assert errors + assert update_calls == [] + + +def test_on_install_argyll_instrument_drivers_finished_success_updates_controls( + window, monkeypatch +): + update_calls = [] + monkeypatch.setattr(window, "update_controls", lambda: update_calls.append(True)) + window._on_install_argyll_instrument_drivers_finished(None) + assert update_calls == [True] + + def test_show_curves_action_handler_creates_and_shows_window(window): assert window._curve_viewer_window is None window._show_curves_action_handler()