Ctrl K
ComunidadGuías…

tests/TestGUI.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (c) 2008-2026 Andrew Ziem.
#
# This work is licensed under the terms of the GNU GPL, version 3 or
# later.  See the COPYING file in the top-level directory.


"""
Test case for module GUI
"""

# These tests reach into internals on purpose.
# pylint: disable=protected-access

import os
import unittest
import time
import types
import warnings
from unittest import mock

from tests import common
from tests.common import pytest

import bleachbit
from bleachbit.Cleaner import Cleaner, backends
from bleachbit.GtkShim import Gdk, Gio, GLib, GObject, Gtk, is_gtk_available
from bleachbit.Language import get_supported_language_code_name_dict, setup_translation
from bleachbit.Language import get_text as _
from bleachbit.Options import options

HAVE_GTK = is_gtk_available()
if HAVE_GTK:
    from bleachbit.GuiPreferences import PreferencesDialog
    from bleachbit.GuiUtil import (clear_clipboard, get_font_size_from_name,
                                   get_window_info)
    from bleachbit.GuiTreeModels import TreeDisplayModel

bleachbit.online_update_notification_enabled = False


@unittest.skipUnless(HAVE_GTK, 'requires GTK+ module and a display environment')
@pytest.mark.xdist_group('gui')
class GUITestCase(common.BleachbitTestCase):
    app = None
    options_get_tree = options.get_tree
    _NEW_CLEANER_ID, _NEW_OPTION_ID = 'test_run_operations', 'test1'

    """Test case for module GUI"""
    @classmethod
    def setUpClass(cls):
        from bleachbit.GuiApplication import Bleachbit
        cls.app = Bleachbit(auto_exit=False, uac=False)
        cls._lang_env = common.set_temporary_env('LANGUAGE', 'en')
        cls._lang_env.__enter__()
        # reminder: the set up in the parent class creates a clean
        # configuration file.
        super().setUpClass()
        options.get_tree = types.MethodType(
            lambda self, parent, child: False, options)
        # override, not set(): a plain set() is reverted by the per-test
        # tearDown reload after the first test in the class
        options.set_override('font_check_completed', True)
        try:
            cls.app.register()
        except GLib.GError as e:
            if "already exported" in str(e):
                cls.app = Gio.Application.get_default()
            else:
                raise
        with common.capture_glib_exceptions() as glib_errors:
            cls.app.activate()
            cls.refresh_gui()
        if glib_errors:
            _exc_type, exc_value, exc_tb = glib_errors[0]
            raise exc_value.with_traceback(exc_tb)

    @classmethod
    def tearDownClass(cls):
        options.get_tree = cls.options_get_tree
        cls._lang_env.__exit__(None, None, None)
        window = cls.get_window()
        if window:
            # The last test can leave frames and style transitions pending,
            # and destroying the window mid-frame trips GTK's frame clock
            deadline = time.monotonic() + 0.5
            while time.monotonic() < deadline:
                cls.refresh_gui(0.02)
            window.destroy()
            cls.clear_window()
            cls.refresh_gui()
        super().tearDownClass()

    @classmethod
    def get_window(cls):
        return getattr(cls.app, '_window', None)

    @classmethod
    def clear_window(cls):
        if cls.app:
            setattr(cls.app, '_window', None)

    @classmethod
    def refresh_gui(cls, delay=0):
        while Gtk.events_pending():
            Gtk.main_iteration_do(blocking=False)
        time.sleep(delay)

    @classmethod
    def print_widget(cls, widget, indent=0):
        print('{}{}'.format(' ' * indent, widget))
        if isinstance(widget, Gtk.Container):
            for c in widget.get_children():
                cls.print_widget(c, indent + 2)

    @classmethod
    def find_widget(cls, widget, widget_class, widget_label=None):
        if isinstance(widget, widget_class):
            if widget_label is None or widget.get_label() == widget_label:
                return widget
        if isinstance(widget, Gtk.Container):
            for c in widget.get_children():
                b = cls.find_widget(c, widget_class, widget_label)
                if b is not None:
                    return b
        return None

    @classmethod
    def get_iter(cls, model, cleaner):
        it = model.get_iter(Gtk.TreePath(0))
        while it:
            if model[it][2] == cleaner:
                return model.iter_children(it)
            it = model.iter_next(it)
        return None

    @classmethod
    def find_option(cls, model, cleaner, option):
        it = cls.get_iter(model, cleaner)
        # cls.assertIsNotNone(cls, it)
        while it:
            if model[it][2] == option:
                return it
            it = model.iter_next(it)
        return None

    def _put_checkmark_on_cleaner(self, gui, cleaner_id, option_id):
        model = gui.view.get_model()
        tree = self.find_widget(gui, Gtk.TreeView)
        self.assertIsNotNone(tree)
        it = self.find_option(model, cleaner_id, option_id)
        self.assertIsNotNone(it)
        tree.scroll_to_cell(model.get_path(it), None, False, 0, 0)
        model[model.iter_parent(it)][1] = True
        model[it][1] = True
        self.refresh_gui()

    def click_button(self, dialog, label):
        b = self.find_widget(dialog, Gtk.Button, label)
        self.assertIsNotNone(b)
        b.clicked()
        self.refresh_gui()

    def wait_until(self, condition, timeout=5):
        """
        Wait until a condition is met or timeout is reached.

        Args:
            condition: A callable that returns a boolean
            timeout: Maximum time to wait in seconds

        Returns:
            True if condition is met, False if timeout is reached
        """
        deadline = time.time() + timeout
        while time.time() < deadline:
            self.refresh_gui(0.05)
            if condition():
                return True
        self.refresh_gui()
        return condition()

    @pytest.mark.no_xdist
    def test_append_text(self):
        """Test append_text handles special strings"""
        gui = self.get_window()
        for test_str in common.SPECIAL_TEST_STRINGS:
            gui.append_text(test_str + "\n")
            gui.append_text(f"prefix{test_str}suffix\n")
            gui.append_text(test_str + "\n", tag='error')
            gui.append_text(f"Deleting file: /path/to/file_{test_str}.txt\n")
        self.refresh_gui()

    def test_get_window_info(self):
        """Test get_window_info"""
        gui = self.get_window()
        # pylint: disable-next=possibly-used-before-assignment
        geo = get_window_info(gui)
        self.assertGreaterEqual(geo.x, 0)
        self.assertGreaterEqual(geo.y, 0)
        self.assertGreaterEqual(geo.width, 0)
        self.assertGreaterEqual(geo.height, 0)
        self.assertIsInstance(geo.monitor_model, str)
        self.assertGreater(len(geo.monitor_model), 0)
        self.assertNotEqual(geo.monitor_model, "(error)")

    def test_GUI(self):
        """Unit test for class GUI"""
        # there should be no crashes
        # app.do_startup()
        # pp.do_activate()                            Build a unit test that that does this
        gui = self.get_window()
        gui.update_progress_bar(0.0)
        gui.update_progress_bar(1.0)
        gui.update_progress_bar("status")

    def test_get_font_size_from_name(self):
        """Test get_font_size_from_name()"""
        tests = (("Sans 12", 12),
                 ("Sans 10.5", 10),
                 ("Sans 0", None),
                 ("Sans -1", None),
                 ("Sans", None),
                 ("", None),
                 (None, None))
        for font_name, expected in tests:
            # pylint: disable-next=possibly-used-before-assignment
            self.assertEqual(get_font_size_from_name(
                font_name), expected, f"Font name '{font_name}' should return {expected}")

    def test_splash_screen_only_for_fontconfig_backend(self):
        """The font cache splash shows only with the fontconfig backend"""
        from bleachbit import GuiWindow
        fake_windows = mock.Mock()
        fake_windows.get_font_conf_file.return_value = __file__
        cases = ((None, False, False),
                 ('win32', False, False),
                 ('fc', False, True),
                 ('fontconfig', False, True),
                 ('fc', True, False))
        with mock.patch.object(GuiWindow, 'IS_WINDOWS', True), \
                mock.patch.object(GuiWindow, 'Windows', fake_windows,
                                  create=True), \
                mock.patch.dict(os.environ):
            os.environ.pop('BLEACHBIT_SPLASH_SCREEN_DELAY', None)
            for backend, has_cache, expected in cases:
                with self.subTest(backend=backend, has_cache=has_cache):
                    fake_windows.reset_mock()
                    fake_windows.has_fontconfig_cache.return_value = has_cache
                    os.environ.pop('PANGOCAIRO_BACKEND', None)
                    if backend:
                        os.environ['PANGOCAIRO_BACKEND'] = backend
                    self.get_window()._show_splash_screen()
                    self.assertEqual(
                        fake_windows.splash_thread.start.called, expected)

    def test_preferences(self):
        """Opens the preferences dialog and closes it"""

        # show preferences dialog
        pref = self.app.get_preferences_dialog()
        pref.dialog.show_all()
        self.refresh_gui()

        # click close button
        self.click_button(pref.dialog, Gtk.STOCK_CLOSE)

        # destroy
        pref.dialog.destroy()

    def test_preferences_language_scan_once(self):
        """Opening the preferences dialog scans for languages once"""
        with mock.patch('bleachbit.GuiPreferences.get_supported_language_code_name_dict',
                        wraps=get_supported_language_code_name_dict) as mock_get:
            pref = self.app.get_preferences_dialog()
        self.assertEqual(mock_get.call_count, 1)
        pref.dialog.destroy()

    @common.skipIfWindows
    def test_preferences_keep_language_list(self):
        """Every locale the localizations cleaner purges can be kept"""
        from bleachbit.Unix import get_purgeable_locales
        pref = self.app.get_preferences_dialog()
        try:
            page = pref.page_stack.get_child_by_name('languages')
            model = self.find_widget(page, Gtk.TreeView).get_model()
            listed = {row[1] for row in model}
            self.assertIn('cy', listed)
            self.assertEqual(
                set(get_purgeable_locales(['en'])) - listed, set())
        finally:
            pref.dialog.destroy()

    def test_preferences_language_selection(self):
        """Test language selection and sensitivity in preferences dialog"""
        options.set('auto_detect_lang', True)
        options.set('forced_language', '')
        pref = self.app.get_preferences_dialog()
        try:
            # Language widgets are insensitive when auto-detect is on.
            self.assertFalse(pref.lang_select_box.get_sensitive())
            self.assertFalse(pref.lang_label.get_sensitive())
            self.assertFalse(pref.lang_combo.get_sensitive())
            # Combobox has an active language selected matching detected language.
            self.assertIsNotNone(pref.lang_combo.get_active_text())

            # Toggle auto-detect off: widgets become sensitive, and
            # detected language is saved to forced_language.
            pref.cb_auto_lang.set_active(False)
            self.assertTrue(pref.lang_select_box.get_sensitive())
            self.assertTrue(pref.lang_label.get_sensitive())
            self.assertTrue(pref.lang_combo.get_sensitive())
            self.assertFalse(options.get('auto_detect_lang'))
            self.assertGreaterEqual(len(options.get('forced_language')), 2)
            # The saved language matches the language shown in the
            # dropdown.
            combo_code = pref.lang_combo.get_active_text().split(
                "(")[-1].rstrip(")")
            self.assertEqual(options.get('forced_language'), combo_code)

            # Toggle auto-detect back on: widgets become insensitive, and
            # forced_language is cleared.
            pref.cb_auto_lang.set_active(True)
            self.assertFalse(pref.lang_select_box.get_sensitive())
            self.assertFalse(pref.lang_label.get_sensitive())
            self.assertFalse(pref.lang_combo.get_sensitive())
            self.assertTrue(options.get('auto_detect_lang'))
            self.assertEqual(options.get('forced_language'), '')
        finally:
            pref.dialog.destroy()
            options.set('auto_detect_lang', True)
            options.set('forced_language', '')

    def test_preferences_language_unsupported_locale(self):
        """Fallback when the detected language has no supported match

        When detection finds no match (e.g., the 'C' locale), the
        dropdown falls back to English, and manual mode must save that
        same fallback so the displayed and saved languages agree.
        """
        options.set('auto_detect_lang', True)
        options.set('forced_language', '')
        with mock.patch('bleachbit.GuiPreferences.get_active_language_code',
                        return_value='C'):
            pref = self.app.get_preferences_dialog()
            try:
                self.assertIsNotNone(pref.lang_combo.get_active_text())
                # Toggle auto-detect off: the dropdown falls back to
                # English, and the same fallback is saved.
                pref.cb_auto_lang.set_active(False)
                combo_code = pref.lang_combo.get_active_text().split(
                    "(")[-1].rstrip(")")
                self.assertTrue(options.get('forced_language'))
                self.assertEqual(options.get('forced_language'), combo_code)
            finally:
                pref.dialog.destroy()
                options.set('auto_detect_lang', True)
                options.set('forced_language', '')

    @pytest.mark.no_xdist
    def test_preferences_undecodable_path(self):
        """Paths that are not valid UTF-8 reach GTK only sanitized"""
        saved = os.path.join(self.tempdir, 'caf\udce9')
        added = os.path.join(self.tempdir, 'na\udcefve')
        options.set_list('shred_drives', [saved])
        pref = self.app.get_preferences_dialog()
        try:
            page = pref.page_stack.get_child_by_name('drives')
            with mock.patch('bleachbit.GuiBasic.browse_folder', return_value=added):
                self.find_widget(page, Gtk.Button, 'Add').clicked()
            self.assertEqual(options.get_list('shred_drives'), [saved, added])
            treeview = self.find_widget(page, Gtk.TreeView)
            treeview.get_selection().select_path(Gtk.TreePath(0))
            self.find_widget(page, Gtk.Button, 'Remove').clicked()
            self.assertEqual(options.get_list('shred_drives'), [added])

            options.set('expert_mode', True)
            with mock.patch('bleachbit.ProtectedPath.check_protected_path',
                            return_value={'case_sensitive': True}), \
                    mock.patch('bleachbit.ProtectedPath.calculate_impact',
                               return_value={'file_count': 0, 'size_human': '0B'}), \
                    mock.patch.object(Gtk.Dialog, 'run', return_value=Gtk.ResponseType.OK):
                self.assertTrue(pref._check_protected_path(saved))
        finally:
            pref.dialog.destroy()

    def test_preferences_cookies_page(self):
        """Opens the preferences dialog and navigates to cookies page"""
        pref = self.app.get_preferences_dialog()
        pref.dialog.show_all()
        self.refresh_gui()

        # Navigate to cookies page - this will trigger cookie manager loading
        pref.select_page('cookies')
        self.refresh_gui()

        # click close button
        self.click_button(pref.dialog, Gtk.STOCK_CLOSE)
        pref.dialog.destroy()

    def test_preferences_run_refresh_operations(self):
        """PreferencesDialog.run() refreshes operations if needed"""
        mock_refresh = mock.Mock()
        mock_theme = mock.Mock()
        # PreferencesDialog is imported under IS_GTK, causing false positive.
        # pylint: disable-next=possibly-used-before-assignment
        pref = PreferencesDialog(self.get_window(), mock_refresh, mock_theme)
        try:
            self.assertFalse(pref.refresh_operations)
            with mock.patch.object(pref.dialog, 'run',
                                   return_value=Gtk.ResponseType.CLOSE):
                pref.run()
            mock_refresh.assert_not_called()

            pref2 = PreferencesDialog(self.get_window(), mock_refresh,
                                      mock_theme)
            pref2.refresh_operations = True
            with mock.patch.object(pref2.dialog, 'run',
                                   return_value=Gtk.ResponseType.CLOSE):
                pref2.run()
            mock_refresh.assert_called_once()
        finally:
            if hasattr(pref, 'dialog') and pref.dialog:
                pref.dialog.destroy()

    def test_cb_refresh_operations_destroyed_window(self):
        """GuiWindow.cb_refresh_operations() is safe if window is destroyed"""
        window = self.get_window()
        window._destroyed = True
        try:
            self.assertFalse(window.cb_refresh_operations())
        finally:
            window._destroyed = False

    def test_system_information(self):
        """Opens the system information dialog and closes it"""
        dialog, _txt, _txtbuffer = self.app.get_system_information_dialog()
        dialog.show_all()
        self.refresh_gui()

        # click close button
        self.click_button(dialog, Gtk.STOCK_CLOSE)

        # destroy
        dialog.destroy()

    def test_about(self):
        """Opens the about dialog and closes it"""
        about = self.app.get_about_dialog()
        about.show_all()
        self.refresh_gui()

        # destroy
        about.destroy()

    def test_chaff(self):
        """Minimal test of the chaff dialog"""
        from bleachbit import Chaff, GuiChaff
        # each function's models_dir default is frozen at def-time, so each needs its own patch
        models_dir = os.path.join(self.tempdir, 'chaff_models')
        os.makedirs(models_dir, exist_ok=True)
        real_download_models = Chaff.download_models
        real_generate_2600 = GuiChaff.generate_2600
        real_generate_emails = GuiChaff.generate_emails

        def isolated_download_models(*args, **kwargs):
            kwargs.setdefault('models_dir', models_dir)
            return real_download_models(*args, **kwargs)

        def isolated_generate_2600(*args, **kwargs):
            kwargs.setdefault('model_dir', models_dir)
            return real_generate_2600(*args, **kwargs)

        def isolated_generate_emails(*args, **kwargs):
            kwargs.setdefault('models_dir', models_dir)
            return real_generate_emails(*args, **kwargs)

        with mock.patch('bleachbit.Chaff.DEFAULT_MODELS_DIR', models_dir), \
                mock.patch('bleachbit.Chaff.download_models',
                           side_effect=isolated_download_models), \
                mock.patch('bleachbit.GuiChaff.generate_2600',
                           side_effect=isolated_generate_2600), \
                mock.patch('bleachbit.GuiChaff.generate_emails',
                           side_effect=isolated_generate_emails):
            if not Chaff.download_models():
                self.skipTest('Unable to download chaff models for GUI test')
            gui = self.get_window()
            cd = GuiChaff.ChaffDialog(gui)
            cd.show_all()
            # Trigger missing-folder branch (no destination chosen)
            cd.choose_folder_button.unselect_all()
            self.refresh_gui()
            cd.on_make_files(None)
            self.refresh_gui()
            self.assertTrue(cd.infobar.get_visible())
            chaff_dst_dir = os.path.join(self.tempdir, 'chaff_dst')
            os.mkdir(chaff_dst_dir)
            cd.choose_folder_button.set_filename(chaff_dst_dir)
            # do not delete after generation
            cd.when_finished_combo.set_active(1)
            cd.stop_value_spin.set_value(10)
            self.refresh_gui()
            self.click_button(cd, _("Make files"))
            self.assertIsNotNone(
                cd.thread, 'Chaff generation thread did not start')
            cd.thread.join()
            self.refresh_gui()
            self.assertEqual(len(os.listdir(chaff_dst_dir)), 10)

    def test_cookie_manager_bulk_actions_wait_for_loading(self):
        """Bulk cookie actions should not save until loading finishes"""
        from bleachbit import GuiCookie

        class FakeThread:
            def __init__(self, target=None, daemon=None):
                self.target = target
                self.daemon = daemon

            def start(self):
                return None

        with mock.patch('bleachbit.GuiCookie.threading.Thread', FakeThread):
            pane = GuiCookie.CookieManagerPane()

        self.assertTrue(pane._is_loading)
        self.assertFalse(pane.select_all_btn.get_sensitive())
        self.assertFalse(pane.deselect_all_btn.get_sensitive())

        with mock.patch.object(pane, 'save_changes') as save_changes:
            pane.on_select_all_clicked(None)
            pane.on_deselect_all_clicked(None)
            save_changes.assert_not_called()

        pane._finish_populate(['example.com'])
        self.refresh_gui()

        self.assertFalse(pane._is_loading)
        self.assertTrue(pane.select_all_btn.get_sensitive())
        self.assertTrue(pane.deselect_all_btn.get_sensitive())

        with mock.patch.object(pane, 'save_changes') as save_changes:
            pane.on_select_all_clicked(None)
            save_changes.assert_called_once_with()
        self.assertTrue(all(row[0] for row in pane.cookie_store))
        pane.destroy()

    def test_preview(self):
        """Select cleaner option and clicks preview button"""
        gui = self.get_window()
        self.refresh_gui()
        self._put_checkmark_on_cleaner(gui, 'system', 'tmp')
        self.refresh_gui()
        self.click_button(gui, _("Preview"))
        self.refresh_gui()

    def test_notify(self):
        """Test a pop-up notification"""
        from bleachbit.GuiUtil import notify
        notify('This is a test notification')
        time.sleep(1)  # Pause for 1 second

    @mock.patch('bleachbit.GuiBasic.delete_confirmation_dialog')
    def test_confirm_delete(self, mock_delete_confirmation_dialog):
        gui = self.get_window()
        options.set('expert_mode', True)
        for new_delete_confirmation in [True, False]:
            options.set('delete_confirmation',
                        new_delete_confirmation)
            gui._confirm_delete(False, False)

        # We should have a single call to delete_confirmation_dialog
        # only when delete_confirmation option is True.
        mock_delete_confirmation_dialog.assert_called_once()

    def test_shred_paths(self):
        dirname = self.mkdtemp(prefix='bleachbit-test-shred_paths')
        test_files_dirs = [
            self.mkstemp(prefix="somefile", dir=dirname),
            dirname,
            self.mkstemp(prefix="somefile")
        ]

        for obj in test_files_dirs:
            self.assertExists(obj)

        gui = self.get_window()
        self.refresh_gui()

        with mock.patch('bleachbit.GuiWindow.GUI._confirm_delete', return_value=True):
            self.assertTrue(gui._confirm_delete(False, False))
            gui.shred_paths(test_files_dirs)

        self.refresh_gui()

        for obj in test_files_dirs:
            self.assertNotExists(obj)

    @common.skipIfWindows
    def test_shred_quit_linked_options_dir(self):
        """Shred settings through a linked options dir, keeping the link"""
        base = self.mkdtemp()
        target = self.mkdir(os.path.join(base, 'target'))
        self.write_file(os.path.join(target, 'bleachbit.ini'))
        link = os.path.join(base, 'link')
        os.symlink(target, link)

        with mock.patch.object(bleachbit, 'options_dir', link), \
                mock.patch('bleachbit.GuiApplication.GUI.shred_paths',
                           return_value=False) as mock_shred_paths:
            self.app.cb_shred_quit(None, None)
            self.assertEqual(mock_shred_paths.call_args.args[1],
                             [os.path.join(link, 'bleachbit.ini')])

            with mock.patch('bleachbit.GuiApplication.os.listdir',
                            side_effect=PermissionError):
                self.app.cb_shred_quit(None, None)
            self.assertEqual(mock_shred_paths.call_args.args[1], [])

    def test_shred_paths_cancel_cleans_temporary_backend(self):
        """Test that shred_paths with cancel cleans up the temporary backend"""
        test_file = self.write_file('shred-cancel')
        gui = self.get_window()
        options.set('delete_confirmation', True)
        self.refresh_gui()

        try:
            with mock.patch.object(gui, '_confirm_delete', return_value=False):
                gui.shred_paths([test_file])

            self.assertTrue(self.wait_until(lambda: '_gui' not in backends))
            self.assertExists(test_file)
        finally:
            options.set('delete_confirmation', False)
            backends.pop('_gui', None)

    def test_shred_paths_cancel_after_preview_cleans_temporary_backend(self):
        """Test cancel cleans up _gui even when the preview finishes during the dialog.

        In real usage the confirmation dialog runs a nested GTK main loop, so
        the preview worker can finish and call worker_done() before the user
        clicks Cancel.  This test simulates that by processing GUI events
        inside the mocked _confirm_delete before returning False.
        """
        test_file = self.write_file('shred-cancel-after-preview')
        gui = self.get_window()
        options.set('delete_confirmation', True)
        self.refresh_gui()

        def fake_confirm_delete(*_args, **_kwargs):
            # Simulate the dialog processing idle events, allowing the
            # preview worker to finish before the user cancels.
            self.wait_until(lambda: '_gui' not in backends
                            or gui._gui_cleaner_cleanup_pending is None)
            self.refresh_gui()
            return False

        try:
            with mock.patch.object(gui, '_confirm_delete',
                                   side_effect=fake_confirm_delete):
                gui.shred_paths([test_file])

            # _gui must be gone even though the preview finished during the
            # dialog (before _confirm_delete returned).
            self.assertNotIn('_gui', backends)
            self.assertExists(test_file)
        finally:
            options.set('delete_confirmation', False)
            backends.pop('_gui', None)

    def test_failed_worker_is_not_left_running(self):
        """A worker that raises restores the UI and cleans up worker state"""
        gui = self.get_window()
        self.addCleanup(gui.set_sensitive, True)
        self.addCleanup(backends.pop, '_gui', None)

        def failing_run(_worker):
            yield True
            raise RuntimeError('worker failed')

        backends['_gui'] = Cleaner()
        gui._gui_cleaner_cleanup_pending = object()
        with mock.patch('bleachbit.Worker.Worker.run', failing_run), \
                mock.patch.object(gui.tree_store, 'refresh_rows',
                                  wraps=gui.tree_store.refresh_rows) as refresh_rows, \
                common.capture_glib_exceptions() as glib_errors:
            gui.preview_or_run_operations(False, {'system': ['tmp']})
            gui.cb_refresh_operations()
            self.assertTrue(gui._refresh_pending)
            self.refresh_gui()
        self.assertEqual([RuntimeError], [error[0] for error in glib_errors])
        self.assertIsNone(gui._worker_source)
        self.assertNotIn('_gui', backends)
        self.assertIsNone(gui._gui_cleaner_cleanup_pending)
        self.assertFalse(gui._refresh_pending)
        refresh_rows.assert_called_once_with()
        self.assertTrue(gui.run_button_get_sensitive())
        self.assertFalse(gui.stop_button.get_sensitive())
        self.assertFalse(gui.progressbar.get_visible())

    def test_interrupted_worker_is_not_left_running(self):
        gui = self.get_window()
        self.addCleanup(gui.set_sensitive, True)
        self.addCleanup(backends.pop, '_gui', None)

        def interrupted_run(_worker):
            yield True
            raise KeyboardInterrupt

        backends['_gui'] = Cleaner()
        gui._gui_cleaner_cleanup_pending = object()
        with mock.patch('bleachbit.Worker.Worker.run', interrupted_run), \
                mock.patch.object(gui.tree_store, 'refresh_rows',
                                  wraps=gui.tree_store.refresh_rows) as refresh_rows:
            gui.preview_or_run_operations(False, {'system': ['tmp']})
            GLib.source_remove(gui._worker_source)
            gui.cb_refresh_operations()
            self.assertTrue(gui._step_worker(gui._worker_run))
            with self.assertRaises(KeyboardInterrupt):
                gui._step_worker(gui._worker_run)
            self.refresh_gui()
        self.assertIsNone(gui._worker_source)
        self.assertNotIn('_gui', backends)
        self.assertIsNone(gui._gui_cleaner_cleanup_pending)
        self.assertFalse(gui._refresh_pending)
        refresh_rows.assert_called_once_with()
        self.assertTrue(gui.run_button_get_sensitive())
        self.assertFalse(gui.stop_button.get_sensitive())
        self.assertFalse(gui.progressbar.get_visible())

    def test_shred_paths_confirm_stops_running_preview(self):
        """Confirming a shred stops the preview that is still running"""
        dirname = self.mkdtemp(prefix='bleachbit-test-shred-confirm')
        for i in range(20):
            self.write_file(os.path.join(dirname, f'file{i}'))
        gui = self.get_window()
        options.set('delete_confirmation', True)
        self.refresh_gui()

        try:
            # Confirm before the preview gets a single idle step
            with mock.patch.object(gui, '_confirm_delete', return_value=True), \
                    mock.patch.object(gui, 'worker_done',
                                      wraps=gui.worker_done) as worker_done:
                gui.shred_paths([dirname])
                self.assertTrue(self.wait_until(
                    lambda: worker_done.called and gui.run_button_get_sensitive()))

            self.assertEqual(
                [True], [call.args[1] for call in worker_done.call_args_list])
            self.assertNotExists(dirname)
        finally:
            options.set('delete_confirmation', False)
            backends.pop('_gui', None)

    def test_shred_paths_confirms_without_expert_mode(self):
        """Shredding asks first unless expert mode turned that off"""
        gui = self.get_window()
        options.set('delete_confirmation', False)
        options.set('expert_mode', False)
        self.addCleanup(backends.pop, '_gui', None)
        with mock.patch.object(gui, 'worker', None, create=True), \
                mock.patch.object(gui, 'preview_or_run_operations') as start, \
                mock.patch('bleachbit.GuiBasic.delete_confirmation_dialog',
                           return_value=False) as dialog:
            self.assertFalse(gui.shred_paths([self.tempdir]))
        dialog.assert_called_once()
        start.assert_called_once_with(False, {'_gui': ['files']})

    def test_shred_paths_clears_clipboard_mock(self):
        """Test that shred_paths with should_clear_clipboard=True clears the clipboard"""
        test_file = self.write_file('shred-me-via-clipboard')
        gui = self.get_window()
        self.refresh_gui()

        with mock.patch('bleachbit.GuiWindow.clear_clipboard') as mock_clear_clipboard:
            with mock.patch.object(gui, '_confirm_delete', return_value=True):
                gui.shred_paths([test_file], should_clear_clipboard=True)

        self.refresh_gui()

        mock_clear_clipboard.assert_called_once()
        self.assertNotExists(test_file)

    # Other xdist workers can take the X11 CLIPBOARD owner away mid-test
    @pytest.mark.no_xdist
    def test_shred_paths_from_clipboard_menu_integration(self):
        """Shred a path copied to the real clipboard"""
        test_file = self.write_file('shred-me-via-real-clipboard')
        self.refresh_gui()

        options.set('expert_mode', True)
        options.set('delete_confirmation', False)

        clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
        clipboard.set_text(test_file, -1)
        self.refresh_gui()
        self.assertTrue(self.wait_until(
            lambda: test_file == clipboard.wait_for_text()))

        targets = [Gdk.Atom.intern('text/plain', False)]
        self.app.cb_clipboard_uri_received(clipboard, targets, None)
        self.assertTrue(self.wait_until(lambda: not os.path.exists(test_file)))
        self.assertNotExists(test_file)

    def test_shred_clipboard_empty_no_glib_warnings(self):
        """Empty clipboard must not trigger GLib g_array warnings."""
        # pylint: disable-next=possibly-used-before-assignment
        clear_clipboard()
        self.refresh_gui()

        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter('always')
            self.app.cb_shred_clipboard(None, None)
            self.refresh_gui()
            glib_warnings = [
                warning for warning in caught
                if 'g_array' in str(warning.message)
            ]

        self.assertEqual([], glib_warnings)

    def test_shred_settings_leaves_exit_to_quit(self):
        """Shred settings and quit exits through quit() even with exit_done"""
        gui = self.get_window()
        options.set('exit_done', True)
        gui.start_time = None
        self.addCleanup(setattr, gui, '_quit_after_worker', False)
        self.addCleanup(backends.pop, '_gui', None)
        worker = mock.Mock()
        with mock.patch.object(gui, 'worker', worker, create=True), \
                mock.patch.object(gui, 'preview_or_run_operations'), \
                mock.patch.object(gui, '_confirm_delete', return_value=True), \
                mock.patch('bleachbit.GuiWindow.sys.exit') as mock_exit:
            self.assertTrue(gui.shred_paths([self.tempdir],
                                            shred_settings=True))
            gui.worker_done(worker, True)
        mock_exit.assert_not_called()

    def test_worker_setup_error_restores_ui(self):
        """An error before the worker starts leaves the buttons usable"""
        gui = self.get_window()
        with mock.patch.object(gui, '_filter_operations_for_expert_mode',
                               side_effect=KeyError('not_loaded_yet')), \
                mock.patch('bleachbit.GuiWindow.logger.exception') as log_exception:
            gui.preview_or_run_operations(True, {'not_loaded_yet': ['cache']})
        log_exception.assert_called_once()
        self.assertTrue(gui.run_button_get_sensitive())
        self.assertFalse(gui.stop_button.get_sensitive())

    def test_drop_without_file_uris_shreds_nothing(self):
        """Dropping only links or other non-file URIs starts no shred"""
        gui = self.get_window()
        with mock.patch.object(gui, 'drag_dest_set'), \
                mock.patch.object(gui, 'connect') as connect, \
                mock.patch.object(gui.textview, 'drag_dest_set'), \
                mock.patch.object(gui.textview, 'connect'):
            gui.setup_drag_n_drop()
        handler = next(call.args[1] for call in connect.call_args_list
                       if call.args[0] == 'drag_data_received')
        data = mock.Mock()
        data.get_uris.return_value = ['https://example.com/', 'trash:///a']
        with mock.patch.object(gui, 'shred_paths') as shred_paths:
            handler(mock.Mock(), None, 0, 0, data, 80, 0)
        shred_paths.assert_not_called()

    def test_shred_and_wipe_refused_while_busy(self):
        """Shredding and wiping do not start while an operation runs"""
        test_file = self.write_file('shred-while-busy')
        gui = self.get_window()
        gui.set_sensitive(False)
        self.addCleanup(gui.set_sensitive, True)
        self.addCleanup(backends.pop, '_gui', None)
        with mock.patch.object(gui, 'show_infobar') as show_infobar, \
                mock.patch.object(gui, 'preview_or_run_operations') as start, \
                mock.patch('bleachbit.GuiBasic.browse_folder',
                           return_value=self.tempdir):
            self.assertFalse(gui.shred_paths([test_file]))
            self.app.cb_wipe_empty_space(None, None)
        start.assert_not_called()
        self.assertEqual(2, show_infobar.call_count)
        self.assertExists(test_file)

    def test_shred_paths_refused_after_reload_during_confirmation(self):
        """A reload started during the confirmation stops the shred"""
        dirname = self.mkdtemp(prefix='bleachbit-test-shred-reload')
        self.write_file(os.path.join(dirname, 'file'))
        gui = self.get_window()
        options.set('delete_confirmation', True)
        self.refresh_gui()

        def fake_confirm_delete(*_args, **_kwargs):
            # The preview finishes, then e.g. a winapp2 download lands
            self.assertTrue(self.wait_until(gui.run_button_get_sensitive))
            gui.cb_refresh_operations()
            return True

        try:
            with mock.patch.object(gui, '_confirm_delete',
                                   side_effect=fake_confirm_delete), \
                    mock.patch.object(gui, 'show_infobar') as show_infobar:
                gui.shred_paths([dirname])
                show_infobar.assert_called_once()
                self.assertTrue(self.wait_until(gui.run_button_get_sensitive))
            self.assertExists(dirname)
            self.assertNotIn('_gui', backends)
        finally:
            options.set('delete_confirmation', False)
            backends.pop('_gui', None)

    def test_shred_paths_leaves_shred_started_during_confirmation(self):
        """A shred started during another's confirmation runs to the end"""
        dirname = self.mkdtemp(prefix='bleachbit-test-shred-outer')
        self.write_file(os.path.join(dirname, 'file'))
        other = self.mkdtemp(prefix='bleachbit-test-shred-inner')
        for i in range(20):
            self.write_file(os.path.join(other, f'file{i}'))
        gui = self.get_window()
        options.set('delete_confirmation', True)
        self.refresh_gui()
        confirmations = []

        def fake_confirm_delete(*_args, **_kwargs):
            confirmations.append(True)
            if len(confirmations) == 1:
                # e.g. the orphaned wipe file prompt, once the preview is done
                self.assertTrue(self.wait_until(gui.run_button_get_sensitive))
                gui.shred_paths([other])
            return True

        try:
            with mock.patch.object(gui, '_confirm_delete',
                                   side_effect=fake_confirm_delete), \
                    mock.patch.object(gui, 'show_infobar') as show_infobar, \
                    mock.patch.object(gui, 'worker_done',
                                      wraps=gui.worker_done) as worker_done:
                gui.shred_paths([dirname])
                show_infobar.assert_called_once()
                self.assertTrue(self.wait_until(gui.run_button_get_sensitive))

            self.assertEqual(
                [False, True], [call.args[1] for call in worker_done.call_args_list])
            self.assertNotExists(other)
            self.assertExists(dirname)
        finally:
            options.set('delete_confirmation', False)
            backends.pop('_gui', None)

    def test_delete_refused_after_reload_during_confirmation(self):
        """Clean does not start while a reload begun in its dialog runs"""
        gui = self.get_window()
        self._setup_new_cleaner(gui)

        def fake_confirm_delete(*_args, **_kwargs):
            gui.cb_refresh_operations()
            return True

        with mock.patch.object(gui, '_confirm_delete',
                               side_effect=fake_confirm_delete), \
                mock.patch.object(gui, 'show_infobar') as show_infobar, \
                mock.patch.object(gui, 'preview_or_run_operations') as start:
            gui.cb_run_option(None, True, self._NEW_CLEANER_ID,
                              self._NEW_OPTION_ID)
            gui.cb_run_option(None, False, self._NEW_CLEANER_ID,
                              self._NEW_OPTION_ID)
            gui.run_operations(None)
            self.refresh_gui()
        start.assert_not_called()
        self.assertEqual(3, show_infobar.call_count)
        self.assertTrue(gui.run_button_get_sensitive())

    def test_preview_or_run_operations_refused_while_worker_runs(self):
        """Starting a second operation does not replace a running worker"""
        gui = self.get_window()
        self._setup_new_cleaner(gui)
        operations = {self._NEW_CLEANER_ID: [self._NEW_OPTION_ID]}
        gui.preview_or_run_operations(False, operations)
        first_worker = gui.worker
        first_source = gui._worker_source
        first_start_time = gui.start_time
        with mock.patch.object(gui, 'show_infobar') as show_infobar:
            gui.preview_or_run_operations(True, operations)
        show_infobar.assert_called_once()
        self.assertIs(gui.worker, first_worker)
        self.assertEqual(gui._worker_source, first_source)
        self.assertEqual(gui.start_time, first_start_time)
        self.refresh_gui()
        self.assertIsNone(gui._worker_source)
        self.assertTrue(gui.run_button_get_sensitive())

    def test_shred_instance_shreds_its_paths_once(self):
        """A shred instance runs on its own and shreds its paths only once"""
        from bleachbit.GuiApplication import Bleachbit
        paths = [os.path.join(self.tempdir, 'shred-once')]
        with mock.patch('bleachbit.GuiApplication.GUI') as gui_cls:
            gui_cls.shred_paths.return_value = False
            app = Bleachbit(uac=False, shred_paths=paths, auto_exit=False)
            self.assertTrue(app.get_flags() & Gio.ApplicationFlags.NON_UNIQUE)
            app.do_activate()
            app.do_activate()
            self.refresh_gui()
        gui_cls.shred_paths.assert_called_once_with(
            gui_cls.return_value, paths)

    def _setup_new_cleaner(self, gui):
        def _create_cleaner_file_in_directory(dirname):
            cleaner_content = ('<?xml version="1.0" encoding="UTF-8"?>'
                               '<cleaner id="{}">'
                               '<label>Test run_operations</label>'
                               '<option id="{}">'
                               '<label>Test1</label>'
                               '<description>Delete files in a test directory</description>'
                               '<action command="delete" search="walk.all" path="{}"/>'
                               '</option>'
                               '</cleaner>'.format(self._NEW_CLEANER_ID, self._NEW_OPTION_ID, dirname))
            cleaner_filename = os.path.join(
                dirname, 'test_run_operations_cleaner.xml')
            self.write_file(cleaner_filename, cleaner_content, 'w')
            return cleaner_filename

        def _load_new_cleaner_in_gui(gui):
            # to load the new test cleaner with id 'test_run_operations'
            gui.cb_refresh_operations()
            self.refresh_gui()

        dirname = self.mkdtemp(prefix='bleachbit-test-run_operations')
        cleaner_filename = _create_cleaner_file_in_directory(dirname)
        self.assertExists(cleaner_filename)
        with mock.patch('bleachbit.CleanerML.list_cleanerml_files') as mock_list_cleanerml_files:
            with mock.patch('bleachbit.RecognizeCleanerML.cleaner_change_dialog') as mock_cleaner_change_dialog:
                mock_list_cleanerml_files.return_value = [cleaner_filename]
                mock_cleaner_change_dialog.return_value = None
                _load_new_cleaner_in_gui(gui)
        file_to_clean = self.mkstemp(prefix="somefile", dir=dirname)
        self.assertExists(file_to_clean)
        return file_to_clean

    def test_orphaned_wipe_prompt_waits_for_idle(self):
        """The orphaned wipe prompt is skipped while an operation runs"""
        gui = self.get_window()
        with mock.patch('bleachbit.GuiBasic.message_dialog') as dialog:
            gui.set_sensitive(False)
            self.addCleanup(gui.set_sensitive, True)
            self.assertFalse(
                gui._prompt_orphaned_wipe_files(['/does/not/exist']))
            dialog.assert_not_called()

            gui.set_sensitive(True)
            gui._prompt_orphaned_wipe_files(['/does/not/exist'])
            dialog.assert_called_once()

    @common.skipUnlessWindows
    def test_prompt_appdata_permissions(self):
        """Confirming the permissions prompt repairs the configuration folder ACL"""
        gui = self.get_window()
        with mock.patch('bleachbit.GuiBasic.message_dialog',
                        return_value=Gtk.ResponseType.NO) as dialog, \
                mock.patch('bleachbit.Windows.fix_appdata_acl') as fix_acl:
            gui._prompt_appdata_permissions()
            dialog.assert_called_once()
            fix_acl.assert_not_called()

        with mock.patch('bleachbit.GuiBasic.message_dialog',
                        return_value=Gtk.ResponseType.YES) as dialog, \
                mock.patch('bleachbit.Windows.fix_appdata_acl') as fix_acl, \
                mock.patch('bleachbit.Windows.has_broken_appdata_acl',
                           return_value=False), \
                mock.patch.object(gui, 'append_text') as append_text:
            gui._prompt_appdata_permissions()
            dialog.assert_called_once()
            fix_acl.assert_called_once_with()
            self.assertIn('Repaired permissions',
                          append_text.call_args.args[0])

        # A failed or ineffective repair is reported, not claimed as success.
        for fix_side_effect, has_broken in ((OSError('fail'), False),
                                            (None, True)):
            with mock.patch('bleachbit.GuiBasic.message_dialog',
                            return_value=Gtk.ResponseType.YES), \
                    mock.patch('bleachbit.Windows.fix_appdata_acl',
                               side_effect=fix_side_effect), \
                    mock.patch('bleachbit.Windows.has_broken_appdata_acl',
                               return_value=has_broken), \
                    mock.patch.object(gui, 'append_text') as append_text:
                gui._prompt_appdata_permissions()
                self.assertIn('Could not fully repair',
                              append_text.call_args.args[0])

        with mock.patch('bleachbit.Windows.shell.IsUserAnAdmin', return_value=False), \
                mock.patch('bleachbit.Windows.has_broken_appdata_acl') as has_broken:
            self.assertFalse(gui.check_appdata_permissions())
            has_broken.assert_not_called()

    def test_update_found_mid_operation(self):
        """An update found mid-operation leaves Preview/Clean disabled"""
        class SyncThread:
            def __init__(self, target=None, args=(), daemon=None):
                self.target = target
                self.args = args
                self.daemon = daemon

            def start(self):
                self.target(*self.args)

        gui = self.get_window()
        gui.set_sensitive(False)
        self.addCleanup(gui.set_sensitive, True)
        self.addCleanup(gui.update_button.hide)
        with mock.patch('bleachbit.GuiUtil.threading.Thread', SyncThread), \
                mock.patch('bleachbit.Update.check_updates',
                           return_value=[('99.0', 'https://example.invalid/')]):
            gui.check_online_updates()
            self.refresh_gui()
        self.assertTrue(gui.update_button.get_visible())
        self.assertFalse(gui.run_button.get_sensitive())
        self.assertTrue(gui.stop_button.get_sensitive())

    def test_app_menu_reloads_only_after_setup_translation(self):
        """The app menu is rebuilt only after setup_translation() runs again"""
        gui = self.get_window()
        menu = gui.menu_button.get_menu_model()
        gui.update_headerbar_labels()
        self.assertIs(gui.menu_button.get_menu_model(), menu)
        setup_translation()
        gui.update_headerbar_labels()
        self.assertIsNot(gui.menu_button.get_menu_model(), menu)

    def test_headerbar_labels_follow_language(self):
        """The button labels are translated again on every update"""
        gui = self.get_window()
        with mock.patch('bleachbit.GuiWindow._', side_effect=lambda msg: f'[{msg}]'):
            gui.update_headerbar_labels()
            self.assertEqual(gui.preview_button.get_label(), '[Preview]')
            self.assertEqual(gui.run_button.get_label(), '[Clean]')
            self.assertEqual(gui.stop_button.get_label(), '[Abort]')
        gui.update_headerbar_labels()

    def test_size_allocate_keeps_normal_size(self):
        """A maximized or fullscreen size must not replace the saved size"""
        gui = self.get_window()
        options.set('window_width', 800)
        options.set('window_height', 600)
        options.set('window_maximized', False)
        options.set('window_fullscreen', False)
        gdk_window = gui.get_window()
        for state in (Gdk.WindowState.MAXIMIZED, Gdk.WindowState.FULLSCREEN):
            with self.subTest(state=state):
                with mock.patch.object(gdk_window, 'get_state', return_value=state), \
                        mock.patch.object(gui, 'get_size', return_value=(1920, 1080)):
                    gui.on_size_allocate(gui, None)
                self.assertEqual(options.get('window_width'), 800)
                self.assertEqual(options.get('window_height'), 600)
        with mock.patch.object(gui, '_maximize_pending', True), \
                mock.patch.object(gdk_window, 'get_state', return_value=Gdk.WindowState(0)), \
                mock.patch.object(gui, 'get_size', return_value=(640, 480)):
            gui.on_size_allocate(gui, None)
        self.assertEqual(options.get('window_width'), 800)
        self.assertEqual(options.get('window_height'), 600)
        tiled_fullscreen = Gdk.WindowState.FULLSCREEN | Gdk.WindowState.TILED
        with mock.patch.object(gdk_window, 'get_state', return_value=tiled_fullscreen), \
                mock.patch.object(gui, 'get_size', return_value=(960, 1080)):
            gui.on_size_allocate(gui, None)
        self.assertEqual(options.get('window_width'), 960)
        self.assertEqual(options.get('window_height'), 1080)

    def test_configure_saves_only_position(self):
        """configure-event must not save a size inflated by stale decorations"""
        gui = self.get_window()
        self.refresh_gui()
        options.set('window_width', 800)
        options.set('window_height', 600)
        with mock.patch.object(gui.get_window(), 'get_state',
                               return_value=Gdk.WindowState(0)), \
                mock.patch.object(gui, 'get_position', return_value=(70, 80)), \
                mock.patch.object(gui, 'get_size', return_value=(654, 494)):
            gui.on_configure_event(gui, None)
        self.assertEqual(options.get('window_x'), 70)
        self.assertEqual(options.get('window_y'), 80)
        self.assertEqual(options.get('window_width'), 800)
        self.assertEqual(options.get('window_height'), 600)

    def test_save_geometry_skips_unready_window(self):
        """Geometry is not saved before on_show() or after the window is gone"""
        gui = self.get_window()
        self.refresh_gui()
        options.set('window_width', 800)
        options.set('window_height', 600)
        with mock.patch.object(gui.get_window(), 'get_state',
                               return_value=Gdk.WindowState(0)), \
                mock.patch.object(gui, 'get_size', return_value=(640, 480)):
            with mock.patch.object(gui, '_geometry_ready', False):
                gui.on_size_allocate(gui, None)
            with mock.patch.object(gui, 'get_window', return_value=None):
                gui.on_size_allocate(gui, None)
        self.assertEqual(options.get('window_width'), 800)
        self.assertEqual(options.get('window_height'), 600)

    def test_exit_saves_geometry(self):
        gui = self.get_window()
        self.refresh_gui()
        for handler, method in ((gui.on_delete_event, 'close'),
                                (gui.on_quit, 'commit')):
            with self.subTest(method=method):
                options.set('window_width', 800)
                options.set('window_height', 600)
                with mock.patch.object(gui.get_window(), 'get_state',
                                       return_value=Gdk.WindowState(0)), \
                        mock.patch.object(gui, 'get_size', return_value=(640, 480)), \
                        mock.patch.object(options, method) as flush, \
                        mock.patch('bleachbit.GuiWindow.Gtk.main_level', return_value=1), \
                        mock.patch('bleachbit.GuiWindow.Gtk.main_quit'):
                    handler(gui, None)
                    self.assertEqual(options.get('window_width'), 640)
                    self.assertEqual(options.get('window_height'), 480)
                    flush.assert_called_once_with()

    def test_unmaximize_restores_saved_size(self):
        """A maximized start must restore the remembered size"""
        gui = self.get_window()
        screen = gui.get_screen()
        if hasattr(screen, 'supports_net_wm_hint') and not screen.supports_net_wm_hint(
                Gdk.atom_intern('_NET_WM_STATE_MAXIMIZED_VERT', False)):
            self.skipTest('Window manager does not support maximize')
        deadline = time.monotonic() + 0.1
        while time.monotonic() < deadline:
            self.refresh_gui(0.02)
        monitor = gui.get_display().get_monitor_at_window(gui.get_window())
        info = monitor.get_workarea()
        width, height = 820, 640
        if info.width < width + 120 or info.height < height + 120:
            width, height = 640, 480
        x, y = info.x + 60, info.y + 60
        options.set('remember_geometry', True)
        options.set('window_fullscreen', False)
        options.set('window_maximized', True)
        options.set('window_x', x)
        options.set('window_y', y)
        options.set('window_width', width)
        options.set('window_height', height)

        def restore_window():
            if gui.get_window() is None:
                return
            if gui.is_maximized():
                gui.unmaximize()
            gui.resize(800, 600)
            deadline = time.monotonic() + 0.5
            while time.monotonic() < deadline and gui.get_size() != (800, 600):
                self.refresh_gui(0.02)
            self.refresh_gui()
        self.addCleanup(restore_window)

        gui.on_show(gui)
        deadline = time.monotonic() + 2
        while time.monotonic() < deadline and not gui.is_maximized():
            self.refresh_gui(0.02)
        self.assertTrue(gui.is_maximized())
        self.assertEqual(options.get('window_width'), width)
        self.assertEqual(options.get('window_height'), height)
        gui.unmaximize()
        deadline = time.monotonic() + 2
        while time.monotonic() < deadline and (
                gui.is_maximized() or gui.get_size() != (width, height) or
                (options.get('window_width'), options.get('window_height')) != (width, height)):
            self.refresh_gui(0.02)
        self.assertFalse(gui.is_maximized())
        alloc = gui.get_size()
        minimum = gui.get_preferred_size()[0]
        self.assertEqual(
            (alloc.width, alloc.height), (width, height),
            f'state={gui.get_window().get_state()}, '
            f'minimum={(minimum.width, minimum.height)}, '
            f'allocation={(gui.get_allocated_width(), gui.get_allocated_height())}')
        self.assertEqual(options.get('window_width'), width)
        self.assertEqual(options.get('window_height'), height)
        if 'Wayland' not in type(screen.get_display()).__name__:
            pos = gui.get_position()
            self.assertEqual((pos.root_x, pos.root_y), (x, y))

    def test_refresh_supersedes_running_registration(self):
        """A new refresh stops the registration still running"""
        gui = self.get_window()
        with mock.patch.object(gui.tree_store, 'refresh_rows',
                               wraps=gui.tree_store.refresh_rows) as refresh_rows:
            gui.cb_refresh_operations()
            gui.cb_refresh_operations()
            self.refresh_gui()
        refresh_rows.assert_called_once_with()

    def test_refresh_disables_operations_until_registered(self):
        """Preview and Clean wait for the cleaners to be registered again"""
        gui = self.get_window()
        gui.cb_refresh_operations()
        self.assertFalse(gui.run_button_get_sensitive())
        self.assertFalse(gui.preview_button.get_sensitive())
        self.assertFalse(gui.stop_button.get_sensitive())
        self.refresh_gui()
        self.assertTrue(gui.run_button_get_sensitive())
        self.assertFalse(gui.stop_button.get_sensitive())

    def test_refresh_rows_sorts_once(self):
        """refresh_rows() fills the store unsorted and leaves it sorted by id"""
        gui = self.get_window()
        model = gui.view.get_model()
        gui.tree_store.refresh_rows()
        self.assertEqual((3, Gtk.SortType.ASCENDING),
                         model.get_sort_column_id())
        ids = [row[2].lower() for row in model]
        self.assertGreater(len(ids), 1)
        self.assertEqual(sorted(ids), ids)

    def test_refresh_waits_for_running_worker(self):
        """A refresh asked for while a worker runs starts after it is done"""
        gui = self.get_window()
        self._setup_new_cleaner(gui)
        operations = {self._NEW_CLEANER_ID: [self._NEW_OPTION_ID]}
        with mock.patch.object(gui.tree_store, 'refresh_rows',
                               wraps=gui.tree_store.refresh_rows) as refresh_rows:
            gui.preview_or_run_operations(False, operations)
            gui.cb_refresh_operations()
            self.refresh_gui()
        self.assertEqual(0, gui.worker.total_errors)
        refresh_rows.assert_called_once_with()
        self.assertTrue(gui.run_button_get_sensitive())

    def test_refresh_waits_for_warning_dialog(self):
        """A refresh asked for in a warning dialog starts after the toggle"""
        gui = self.get_window()
        model = Gtk.TreeStore(GObject.TYPE_STRING, GObject.TYPE_BOOLEAN,
                              GObject.TYPE_PYOBJECT, GObject.TYPE_STRING,
                              GObject.TYPE_STRING)
        parent_iter = model.append(None, ("Warned", False, "warned", "", ""))
        for option_id in ("plain", "risky", "after"):
            model.append(parent_iter, (option_id, False, option_id, "", ""))

        def refresh_and_confirm(*_args):
            # e.g. a winapp2 download lands while the dialog is open
            gui.cb_refresh_operations()
            self.refresh_gui()
            self.assertTrue(gui.run_button_get_sensitive())
            return True, False

        def warning(option_id):
            return "Be careful." if option_id == "risky" else None

        # pylint: disable-next=possibly-used-before-assignment
        tdm = TreeDisplayModel()
        with mock.patch.object(gui.tree_store, 'refresh_rows',
                               wraps=gui.tree_store.refresh_rows) as refresh_rows:
            with mock.patch('bleachbit.GuiTreeModels.options') as mock_options, \
                    mock.patch('bleachbit.GuiTreeModels.backends',
                               {"warned": mock.Mock(get_warning=warning)}), \
                    mock.patch('bleachbit.GuiBasic.warning_confirm_dialog',
                               side_effect=refresh_and_confirm):
                mock_options.get.return_value = True  # expert_mode on
                mock_options.get_warning_preference.return_value = False
                tdm.col1_toggled_cb(None, "0", model, gui)
            self.assertFalse(gui.run_button_get_sensitive())
            self.refresh_gui()
        refresh_rows.assert_called_once_with()
        self.assertTrue(gui.run_button_get_sensitive())
        self.assertEqual([True, True, True],
                         [row[1] for row in model[parent_iter].iterchildren()])

    def test_registration_done_leaves_running_worker_alone(self):
        """Finishing a reload re-enables the window only when nothing runs"""
        gui = self.get_window()
        gui.set_sensitive(False)
        self.addCleanup(gui.set_sensitive, True)
        with mock.patch.object(gui, '_worker_source', 1):
            gui.cb_register_cleaners_done()
        self.assertFalse(gui.run_button_get_sensitive())
        self.assertTrue(gui.stop_button.get_sensitive())

        with mock.patch.object(gui.tree_store, 'refresh_rows',
                               side_effect=RuntimeError), \
                self.assertRaises(RuntimeError):
            gui.cb_register_cleaners_done()
        self.assertTrue(gui.run_button_get_sensitive())

        def failing_register(*_args, **_kwargs):
            yield True
            raise RuntimeError('registration failed')

        with mock.patch('bleachbit.GuiWindow.register_cleaners',
                        side_effect=failing_register), \
                common.capture_glib_exceptions() as glib_errors:
            gui.cb_refresh_operations()
            self.refresh_gui()
        self.assertEqual([RuntimeError], [error[0] for error in glib_errors])
        self.assertTrue(gui.run_button_get_sensitive())
        self.assertFalse(gui.stop_button.get_sensitive())
        self.assertFalse(gui.progressbar.get_visible())

    def test_run_operations(self):
        gui = self.get_window()
        file_to_clean = self._setup_new_cleaner(gui)
        self._put_checkmark_on_cleaner(
            gui, self._NEW_CLEANER_ID, self._NEW_OPTION_ID)

        with mock.patch('bleachbit.GuiWindow.GUI._confirm_delete', return_value=True):
            self.assertTrue(gui._confirm_delete(False, False))
            # same as b = self.click_button(gui, _("Clean"))
            gui.run_operations(None)

        self.refresh_gui()
        self.assertNotExists(file_to_clean)

    def test_on_destroy_stops_running_worker(self):
        """Closing the window stops the operation that is still running"""
        gui = self.get_window()
        file_to_clean = self._setup_new_cleaner(gui)
        self.addCleanup(gui.set_sensitive, True)
        operations = {self._NEW_CLEANER_ID: [self._NEW_OPTION_ID]}
        with mock.patch.object(gui, 'worker_done') as worker_done:
            gui.preview_or_run_operations(True, operations)
            gui.on_destroy(gui)
            self.refresh_gui()
        worker_done.assert_not_called()
        self.assertExists(file_to_clean)

    def test_on_destroy_from_inside_worker_step(self):
        """A stop from inside the step, as on Ctrl+C, still closes the worker"""
        gui = self.get_window()
        self.addCleanup(gui.set_sensitive, True)
        closed = []

        def run(_worker):
            try:
                yield True
                gui.on_destroy(gui)
                yield True
            finally:
                closed.append(True)

        with mock.patch('bleachbit.Worker.Worker.run', run), \
                common.capture_glib_exceptions() as glib_errors:
            gui.preview_or_run_operations(False, {'system': ['tmp']})
            self.refresh_gui()
        self.assertEqual([], glib_errors)
        self.assertIsNone(gui._worker_source)
        self.assertEqual([True], closed)

    def test_refresh_waits_for_worker_started_in_dialog(self):
        """A worker started by the CleanerML dialog's loop delays the reload"""
        gui = self.get_window()
        gui.recognized_cleanerml = False
        self.addCleanup(setattr, gui, 'recognized_cleanerml', True)
        self.addCleanup(setattr, gui, '_refresh_pending', False)

        def start_worker(_parent):
            gui._worker_source = 1

        with mock.patch('bleachbit.RecognizeCleanerML.RecognizeCleanerML',
                        side_effect=start_worker), \
                mock.patch('bleachbit.GuiWindow.register_cleaners') as register, \
                mock.patch.object(gui, '_worker_source', None):
            self.assertFalse(gui.cb_refresh_operations())
            self.assertTrue(gui._refresh_pending)
            self.assertTrue(gui.recognized_cleanerml)
        register.assert_not_called()

    def test_cb_run_option(self):
        gui = self.get_window()
        file_to_clean = self._setup_new_cleaner(gui)

        for really_delete, assert_method in [(False, self.assertExists), (True, self.assertNotExists)]:
            with mock.patch('bleachbit.GuiWindow.GUI._confirm_delete', return_value=True):
                self.assertTrue(gui._confirm_delete(False, False))
                gui.cb_run_option(
                    None, really_delete, self._NEW_CLEANER_ID, self._NEW_OPTION_ID
                )  # activated from context menu

            self.refresh_gui()
            assert_method(file_to_clean)

    def test_context_menu_cookie_manager(self):
        """Test cookie manager helper returns correct value for options"""
        gui = self.get_window()

        class _DummyAction:
            def __init__(self, action_key):
                self.action_key = action_key

        cookie_cleaner_id = '_unit_test_cookie_cleaner'
        non_cookie_cleaner_id = '_unit_test_non_cookie_cleaner'
        saved_cookie_cleaner = backends.get(cookie_cleaner_id)
        saved_non_cookie_cleaner = backends.get(non_cookie_cleaner_id)

        try:
            cookie_cleaner = Cleaner()
            cookie_cleaner.add_action('cookies', _DummyAction('cookie'))
            non_cookie_cleaner = Cleaner()
            non_cookie_cleaner.add_action('logs', _DummyAction('delete'))

            backends[cookie_cleaner_id] = cookie_cleaner
            backends[non_cookie_cleaner_id] = non_cookie_cleaner

            self.assertTrue(
                gui._option_has_cookie_command(cookie_cleaner_id, 'cookies'),
                'Expected test cookie cleaner option to be detected as cookie action')
            self.assertFalse(
                gui._option_has_cookie_command(non_cookie_cleaner_id, 'logs'),
                'Expected test non-cookie cleaner option to not be detected as cookie action')
        finally:
            if saved_cookie_cleaner is None:
                backends.pop(cookie_cleaner_id, None)
            else:
                backends[cookie_cleaner_id] = saved_cookie_cleaner
            if saved_non_cookie_cleaner is None:
                backends.pop(non_cookie_cleaner_id, None)
            else:
                backends[non_cookie_cleaner_id] = saved_non_cookie_cleaner

    def test_parent_unchecked_when_all_children_blocked_by_expert_mode(self):
        """When all children are blocked by expert mode, parent must stay unchecked."""

        model = Gtk.TreeStore(
            GObject.TYPE_STRING,   # 0: name
            GObject.TYPE_BOOLEAN,  # 1: active
            GObject.TYPE_PYOBJECT,  # 2: id
            GObject.TYPE_STRING,   # 3: size
            GObject.TYPE_STRING,   # 4: icon
        )
        parent_iter = model.append(
            None, ("TestCleaner", False, "test_cleaner", "", ""))
        model.append(parent_iter, ("Option1", False, "option1", "", ""))
        model.append(parent_iter, ("Option2", False, "option2", "", ""))

        # pylint: disable-next=possibly-used-before-assignment
        tdm = TreeDisplayModel()

        def blocked_warning(_option_id):
            return "This option requires expert mode."

        with mock.patch('bleachbit.GuiTreeModels.backends',
                        {"test_cleaner": mock.Mock(get_warning=blocked_warning)}), \
                mock.patch('bleachbit.GuiTreeModels.options') as mock_options:
            mock_options.get.return_value = False  # expert_mode off
            mock_options.get_warning_preference.return_value = False

            parent_path = model.get_path(parent_iter)
            tdm.col1_toggled_cb(None, str(parent_path), model, None)

        self.assertFalse(model[parent_iter][1],
                         "Parent should remain unchecked when all children are blocked by expert mode")

    def test_parent_unchecked_when_child_blocked_or_cancelled(self):
        """A warning option that stays off leaves its cleaner unchecked"""

        def blocked_warning(_option_id):
            return "This option requires expert mode."

        # Blocked without expert mode, then cancelled in the dialog
        for expert_mode, answer in ((False, None), (True, (False, False))):
            with self.subTest(expert_mode=expert_mode):
                model = Gtk.TreeStore(
                    GObject.TYPE_STRING,   # 0: name
                    GObject.TYPE_BOOLEAN,  # 1: active
                    GObject.TYPE_PYOBJECT,  # 2: id
                    GObject.TYPE_STRING,   # 3: size
                    GObject.TYPE_STRING,   # 4: icon
                )
                parent_iter = model.append(
                    None, ("TestCleaner", False, "test_cleaner", "", ""))
                model.append(
                    parent_iter, ("Option1", False, "option1", "", ""))
                # pylint: disable-next=possibly-used-before-assignment
                tdm = TreeDisplayModel()
                with mock.patch('bleachbit.GuiTreeModels.backends',
                                {"test_cleaner": mock.Mock(get_warning=blocked_warning)}), \
                        mock.patch('bleachbit.GuiTreeModels.options') as mock_options, \
                        mock.patch('bleachbit.GuiBasic.warning_confirm_dialog',
                                   return_value=answer):
                    mock_options.get.return_value = expert_mode
                    mock_options.get_warning_preference.return_value = False
                    tdm.col1_toggled_cb(None, "0:0", model, None)
                self.assertFalse(model[parent_iter][1])

    def test_toggle_follows_option_when_tree_rebuilt_during_warning(self):
        """A toggle confirmed after the tree was rebuilt checks that option"""
        model = Gtk.TreeStore(
            GObject.TYPE_STRING,   # 0: name
            GObject.TYPE_BOOLEAN,  # 1: active
            GObject.TYPE_PYOBJECT,  # 2: id
            GObject.TYPE_STRING,   # 3: size
            GObject.TYPE_STRING,   # 4: icon
        )

        def fill(cleaners):
            model.clear()
            for cleaner_id, option_ids in cleaners:
                parent_iter = model.append(
                    None, (cleaner_id, False, cleaner_id, "", ""))
                for option_id in option_ids:
                    model.append(
                        parent_iter, (option_id, False, option_id, "", ""))

        fill([("aaa", ["a1"]), ("warned", ["plain", "risky"])])

        def rebuild_and_confirm(*_args):
            # New rows now sort before the clicked one
            fill([("aaa", ["a1"]), ("added", ["x1", "x2"]),
                  ("warned", ["plain", "risky"])])
            return True, False

        def warning(option_id):
            return "Be careful." if option_id == "risky" else None

        # pylint: disable-next=possibly-used-before-assignment
        tdm = TreeDisplayModel()
        with mock.patch('bleachbit.GuiTreeModels.backends',
                        {"warned": mock.Mock(get_warning=warning)}), \
                mock.patch('bleachbit.GuiTreeModels.options') as mock_options, \
                mock.patch('bleachbit.GuiBasic.warning_confirm_dialog',
                           side_effect=rebuild_and_confirm):
            mock_options.get.return_value = True  # expert_mode on
            mock_options.get_warning_preference.return_value = False
            tdm.col1_toggled_cb(None, "1:1", model, None)

        checked = [row[2] for row in model if row[1]]
        checked += [child[2] for row in model
                    for child in row.iterchildren() if child[1]]
        self.assertEqual(["warned", "risky"], checked)

    def test_cascade_survives_tree_change_during_warning(self):
        """Enabling a cleaner checks every option after a warning dialog"""
        model = Gtk.TreeStore(GObject.TYPE_STRING, GObject.TYPE_BOOLEAN,
                              GObject.TYPE_PYOBJECT, GObject.TYPE_STRING,
                              GObject.TYPE_STRING)
        option_ids = ["cache", "history", "password", "session", "vacuum"]

        def fill(checked):
            model.clear()
            parent_iter = model.append(
                None, ("Chromium", "chromium" in checked, "chromium", "", ""))
            for option_id in option_ids:
                model.append(parent_iter, (option_id, option_id in checked,
                                           option_id, "", ""))

        def rebuild(*_args):
            # Like refresh_rows(), which restores the saved states
            checked = {row[2] for row in model if row[1]}
            checked.update(row[2] for row in model[0].iterchildren() if row[1])
            fill(checked)
            return True, False

        def resort(*_args):
            model.swap(model.get_iter("0:2"), model.get_iter("0:4"))
            return True, False

        def warning(option_id):
            return "Be careful." if option_id == "password" else None

        # pylint: disable-next=possibly-used-before-assignment
        tdm = TreeDisplayModel()
        with mock.patch('bleachbit.GuiTreeModels.options') as mock_options, \
                mock.patch('bleachbit.GuiTreeModels.backends',
                           {"chromium": mock.Mock(get_warning=warning)}), \
                mock.patch('bleachbit.GuiBasic.warning_confirm_dialog',
                           side_effect=rebuild) as dialog:
            mock_options.get.return_value = True  # expert_mode on
            mock_options.get_warning_preference.return_value = False
            for change in (rebuild, resort):
                with self.subTest(change=change.__name__):
                    fill(set())
                    dialog.side_effect = change
                    tdm.col1_toggled_cb(None, "0", model, None)
                    self.assertTrue(model[0][1])
                    unchecked = [row[2] for row in model[0].iterchildren()
                                 if not row[1]]
                    self.assertEqual([], unchecked)

    def test_on_quit_commits_pending_options(self):
        """Regression test: quitting via on_quit() (Ctrl+Q/Ctrl+W, and
        Cmd+Q on macOS) must flush pending option changes to disk, the
        same way closing the window via its own close button does
        through on_delete_event() -> options.close().

        Before the fix, on_quit() called Gtk.main_quit()/self.destroy()
        directly without ever emitting delete-event, so a setting
        change still waiting on the delayed flush timer
        (Options.__schedule_flush) would be lost if the process exited
        via this path before that timer fired.
        """
        with mock.patch('bleachbit.GuiWindow.options.commit') as mock_commit, \
                mock.patch('bleachbit.GuiWindow.Gtk.main_level', return_value=1), \
                mock.patch('bleachbit.GuiWindow.Gtk.main_quit') as mock_main_quit:
            self.app._window.on_quit()

        mock_commit.assert_called_once()
        mock_main_quit.assert_called_once()

        with mock.patch('bleachbit.GuiWindow.options.commit') as mock_commit, \
                mock.patch('bleachbit.GuiWindow.Gtk.main_level', return_value=0), \
                mock.patch.object(self.app._window, 'destroy') as mock_destroy:
            self.app._window.on_quit()

        mock_commit.assert_called_once()
        mock_destroy.assert_called_once()

    def test_ctrl_c_while_window_is_built(self):
        """Ctrl+C during GUI() destroys the window without showing it"""
        window = self.app._window
        half_built = mock.Mock()
        self.app._window = None
        try:
            with mock.patch('bleachbit.GuiApplication.GUI',
                            side_effect=lambda **_kw: self.app.quit()), \
                    mock.patch('bleachbit.GuiApplication.Gtk.Application.quit') as mock_quit, \
                    mock.patch.object(self.app, 'get_windows', return_value=[half_built]):
                self.app.do_activate()
            self.assertIsNone(self.app._window)
        finally:
            self.app._window = window
        mock_quit.assert_called_once_with(self.app)
        half_built.destroy.assert_called_once_with()