519 lines
22 KiB
Python
519 lines
22 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from unittest.mock import Mock, patch
|
||
|
||
|
||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||
|
||
from PyQt6.QtCore import QEvent, QUrl
|
||
from PyQt6.QtWidgets import QApplication
|
||
|
||
|
||
class _DropEvent:
|
||
def __init__(self, event_type, urls) -> None:
|
||
self._event_type = event_type
|
||
self._mime_data = SimpleNamespace(
|
||
hasUrls=lambda: True,
|
||
urls=lambda: list(urls),
|
||
)
|
||
self.accepted = False
|
||
|
||
def type(self):
|
||
return self._event_type
|
||
|
||
def mimeData(self): # noqa: N802
|
||
return self._mime_data
|
||
|
||
def acceptProposedAction(self) -> None: # noqa: N802
|
||
self.accepted = True
|
||
|
||
|
||
class _Receiver:
|
||
def __init__(self, window) -> None:
|
||
self._window = window
|
||
|
||
def window(self):
|
||
return self._window
|
||
|
||
|
||
class WindowPresetFileDropTests(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls) -> None:
|
||
cls._app = QApplication.instance() or QApplication([])
|
||
|
||
def test_main_window_installs_filter_and_routes_to_current_page(self) -> None:
|
||
from ui.fluent_app_window import ZapretFluentWindow
|
||
from ui.window_preset_file_drop import (
|
||
PresetFileDropOverlay,
|
||
WindowPresetFileDropFilter,
|
||
)
|
||
|
||
with patch(
|
||
"ui.fluent_app_window.enable_windows_file_drop",
|
||
return_value=True,
|
||
) as enable_native_drop:
|
||
window = ZapretFluentWindow()
|
||
self.addCleanup(window.deleteLater)
|
||
event_filter = window._preset_file_drop_filter
|
||
self.addCleanup(self._app.removeEventFilter, event_filter)
|
||
target = SimpleNamespace(import_dropped_preset_files=Mock(return_value=True))
|
||
window.ui_session = SimpleNamespace(
|
||
page_host=SimpleNamespace(current_page=lambda: target),
|
||
)
|
||
|
||
self.assertTrue(window.acceptDrops())
|
||
self.assertIsInstance(event_filter, WindowPresetFileDropFilter)
|
||
self.assertIsInstance(event_filter.overlay, PresetFileDropOverlay)
|
||
self.assertTrue(event_filter.overlay.isHidden())
|
||
self.assertIs(window._current_preset_file_drop_target(), target)
|
||
self.assertTrue(window._windows_file_drop_enabled)
|
||
enable_native_drop.assert_called_once_with(window)
|
||
|
||
def test_windows_window_disables_qt_ole_drop_owner(self) -> None:
|
||
from ui.fluent_app_window import ZapretFluentWindow
|
||
|
||
installed_app_filters: list[object] = []
|
||
real_install = QApplication.installEventFilter
|
||
|
||
def _record_install(app, event_filter):
|
||
installed_app_filters.append(event_filter)
|
||
return real_install(app, event_filter)
|
||
|
||
with (
|
||
patch("ui.fluent_app_window.use_qt_file_drop", return_value=False),
|
||
patch("ui.fluent_app_window.enable_windows_file_drop", return_value=True),
|
||
patch.object(QApplication, "installEventFilter", _record_install),
|
||
):
|
||
window = ZapretFluentWindow()
|
||
self.addCleanup(window.deleteLater)
|
||
event_filter = window._preset_file_drop_filter
|
||
self.addCleanup(self._app.removeEventFilter, event_filter)
|
||
|
||
self.assertFalse(window.acceptDrops())
|
||
self.assertTrue(window._windows_file_drop_enabled)
|
||
# Drag-событий Qt на Windows нет: фильтр на всё приложение не ставится,
|
||
# а подсказка не создаётся вместе с окном при старте.
|
||
self.assertNotIn(event_filter, installed_app_filters)
|
||
self.assertIsNone(event_filter._overlay)
|
||
|
||
def test_rebinds_native_drop_after_qt_recreates_window_handle(self) -> None:
|
||
from ui.fluent_app_window import ZapretFluentWindow
|
||
|
||
with patch(
|
||
"ui.fluent_app_window.enable_windows_file_drop",
|
||
return_value=True,
|
||
) as enable_native_drop:
|
||
window = ZapretFluentWindow()
|
||
self.addCleanup(window.deleteLater)
|
||
event_filter = window._preset_file_drop_filter
|
||
self.addCleanup(self._app.removeEventFilter, event_filter)
|
||
enable_native_drop.reset_mock()
|
||
window.event(QEvent(QEvent.Type.WinIdChange))
|
||
|
||
enable_native_drop.assert_called_once_with(window)
|
||
|
||
def test_collects_only_unique_existing_txt_files(self) -> None:
|
||
from ui.window_preset_file_drop import dropped_preset_file_paths
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
root = Path(tmp)
|
||
first = root / "First.txt"
|
||
second = root / "SECOND.TXT"
|
||
ignored = root / "notes.log"
|
||
first.write_text("first", encoding="utf-8")
|
||
second.write_text("second", encoding="utf-8")
|
||
ignored.write_text("ignored", encoding="utf-8")
|
||
|
||
mime_data = SimpleNamespace(
|
||
hasUrls=lambda: True,
|
||
urls=lambda: [
|
||
QUrl.fromLocalFile(str(first)),
|
||
QUrl.fromLocalFile(str(ignored)),
|
||
QUrl.fromLocalFile(str(second)),
|
||
QUrl.fromLocalFile(str(first)),
|
||
QUrl("https://example.com/remote.txt"),
|
||
],
|
||
)
|
||
|
||
self.assertEqual(
|
||
dropped_preset_file_paths(mime_data),
|
||
[str(first), str(second)],
|
||
)
|
||
|
||
def test_filter_accepts_drag_and_imports_drop_anywhere_in_own_window(self) -> None:
|
||
from ui.window_preset_file_drop import WindowPresetFileDropFilter
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
preset_path = Path(tmp) / "Dragged.txt"
|
||
preset_path.write_text("preset", encoding="utf-8")
|
||
url = QUrl.fromLocalFile(str(preset_path))
|
||
window = object()
|
||
receiver = _Receiver(window)
|
||
target = SimpleNamespace(import_dropped_preset_files=Mock(return_value=True))
|
||
overlay = Mock()
|
||
event_filter = WindowPresetFileDropFilter(
|
||
window,
|
||
target_resolver=lambda: target,
|
||
overlay=overlay,
|
||
)
|
||
|
||
drag_event = _DropEvent(QEvent.Type.DragEnter, [url])
|
||
self.assertTrue(event_filter.eventFilter(receiver, drag_event))
|
||
self.assertTrue(drag_event.accepted)
|
||
target.import_dropped_preset_files.assert_not_called()
|
||
overlay.show_hint.assert_called_once_with([str(preset_path)])
|
||
|
||
drop_event = _DropEvent(QEvent.Type.Drop, [url])
|
||
self.assertTrue(event_filter.eventFilter(receiver, drop_event))
|
||
self.assertTrue(drop_event.accepted)
|
||
target.import_dropped_preset_files.assert_called_once_with([str(preset_path)])
|
||
overlay.show_accepted.assert_called_once_with([str(preset_path)])
|
||
overlay.hide_hint.assert_not_called()
|
||
|
||
leave_event = _DropEvent(QEvent.Type.DragLeave, [])
|
||
self.assertFalse(event_filter.eventFilter(receiver, leave_event))
|
||
self.assertEqual(overlay.hide_hint.call_count, 1)
|
||
|
||
def test_native_drop_uses_the_same_filter_import_path(self) -> None:
|
||
from ui.window_preset_file_drop import (
|
||
WindowPresetFileDropFilter,
|
||
handle_native_preset_file_drop,
|
||
)
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
preset_path = Path(tmp) / "Native.txt"
|
||
preset_path.write_text("preset", encoding="utf-8")
|
||
target = SimpleNamespace(import_dropped_preset_files=Mock(return_value=True))
|
||
event_filter = WindowPresetFileDropFilter(
|
||
object(),
|
||
target_resolver=lambda: target,
|
||
overlay=Mock(),
|
||
)
|
||
window = SimpleNamespace(_preset_file_drop_filter=event_filter)
|
||
|
||
with patch(
|
||
"ui.window_preset_file_drop.windows_dropped_file_paths",
|
||
return_value=[str(preset_path)],
|
||
):
|
||
handled = handle_native_preset_file_drop(window, object())
|
||
|
||
self.assertTrue(handled)
|
||
target.import_dropped_preset_files.assert_called_once_with([str(preset_path)])
|
||
event_filter.overlay.show_accepted.assert_called_once_with([str(preset_path)])
|
||
event_filter.overlay.hide_hint.assert_not_called()
|
||
|
||
def test_native_drop_message_is_consumed_even_without_valid_txt(self) -> None:
|
||
from ui.window_preset_file_drop import handle_native_preset_file_drop
|
||
|
||
event_filter = Mock()
|
||
window = SimpleNamespace(_preset_file_drop_filter=event_filter)
|
||
with patch(
|
||
"ui.window_preset_file_drop.windows_dropped_file_paths",
|
||
return_value=["C:/Temp/ignored.log"],
|
||
):
|
||
handled = handle_native_preset_file_drop(window, object())
|
||
|
||
self.assertTrue(handled)
|
||
event_filter.import_file_paths.assert_called_once_with(["C:/Temp/ignored.log"])
|
||
|
||
def test_non_drop_native_message_is_not_consumed(self) -> None:
|
||
from ui.window_preset_file_drop import handle_native_preset_file_drop
|
||
|
||
window = SimpleNamespace(_preset_file_drop_filter=Mock())
|
||
with patch(
|
||
"ui.window_preset_file_drop.windows_dropped_file_paths",
|
||
return_value=None,
|
||
):
|
||
handled = handle_native_preset_file_drop(window, object())
|
||
|
||
self.assertFalse(handled)
|
||
window._preset_file_drop_filter.import_file_paths.assert_not_called()
|
||
|
||
def test_overlay_covers_whole_window_and_uses_selected_language(self) -> None:
|
||
from ui.window_preset_file_drop import PresetFileDropOverlay
|
||
from PyQt6.QtWidgets import QWidget
|
||
from qfluentwidgets import InfoBar
|
||
|
||
window = QWidget()
|
||
self.addCleanup(window.deleteLater)
|
||
window.resize(900, 600)
|
||
overlay = PresetFileDropOverlay(window, language_resolver=lambda: "en")
|
||
overlay.show_hint(["C:/Temp/My preset.txt"])
|
||
|
||
self.assertEqual(overlay.geometry(), window.rect())
|
||
self.assertFalse(overlay.isHidden())
|
||
self.assertIsInstance(overlay._info_bar, InfoBar)
|
||
self.assertEqual(overlay._title, "Drop the file to import")
|
||
self.assertEqual(overlay._subtitle, "My preset.txt")
|
||
self.assertEqual(overlay._info_bar.title, overlay._title)
|
||
self.assertEqual(overlay._info_bar.content, overlay._subtitle)
|
||
self.assertFalse(overlay._info_bar.titleLabel.isHidden())
|
||
self.assertFalse(overlay._info_bar.contentLabel.isHidden())
|
||
self.assertEqual(overlay._info_bar.accessibleName(), overlay._title)
|
||
self.assertEqual(overlay._info_bar.accessibleDescription(), overlay._subtitle)
|
||
self.assertNotIn("paintEvent", PresetFileDropOverlay.__dict__)
|
||
self.assertIsNone(overlay.graphicsEffect())
|
||
self.assertIsNotNone(overlay._backdrop.graphicsEffect())
|
||
self.assertIsNotNone(overlay._info_bar.graphicsEffect())
|
||
|
||
overlay.hide_immediately()
|
||
self.assertTrue(overlay.isHidden())
|
||
|
||
def test_overlay_flashes_accepted_message_and_auto_hides(self) -> None:
|
||
from ui.window_preset_file_drop import (
|
||
ACCEPTED_FLASH_DURATION_MS,
|
||
PresetFileDropOverlay,
|
||
)
|
||
from PyQt6.QtWidgets import QWidget
|
||
|
||
window = QWidget()
|
||
self.addCleanup(window.deleteLater)
|
||
window.resize(900, 600)
|
||
overlay = PresetFileDropOverlay(window, language_resolver=lambda: "en")
|
||
|
||
overlay.show_accepted(["C:/Temp/My preset.txt"])
|
||
|
||
self.assertFalse(overlay.isHidden())
|
||
self.assertEqual(overlay._title, "File accepted — importing…")
|
||
self.assertEqual(overlay._subtitle, "My preset.txt")
|
||
self.assertTrue(overlay._auto_hide_timer.isActive())
|
||
self.assertEqual(overlay._auto_hide_timer.interval(), ACCEPTED_FLASH_DURATION_MS)
|
||
|
||
# Наведение снова показывает подсказку и отменяет авто-скрытие броска.
|
||
overlay.show_hint(["C:/Temp/My preset.txt"])
|
||
self.assertFalse(overlay._auto_hide_timer.isActive())
|
||
self.assertEqual(overlay._title, "Drop the file to import")
|
||
|
||
overlay.hide_immediately()
|
||
self.assertTrue(overlay.isHidden())
|
||
self.assertFalse(overlay._auto_hide_timer.isActive())
|
||
|
||
def test_overlay_hover_hint_shows_generic_text_and_respects_accepted_flash(self) -> None:
|
||
from ui.window_preset_file_drop import PresetFileDropOverlay
|
||
from PyQt6.QtWidgets import QWidget
|
||
|
||
window = QWidget()
|
||
self.addCleanup(window.deleteLater)
|
||
window.resize(900, 600)
|
||
overlay = PresetFileDropOverlay(window, language_resolver=lambda: "en")
|
||
|
||
overlay.show_hover_hint()
|
||
self.assertFalse(overlay.isHidden())
|
||
self.assertEqual(overlay._title, "Drop the file to import")
|
||
self.assertEqual(overlay._subtitle, "TXT preset or ZIP archive")
|
||
self.assertFalse(overlay._auto_hide_timer.isActive())
|
||
|
||
# Пока показана плашка «файл принят», конец наведения её не гасит.
|
||
overlay.show_accepted(["C:/Temp/My preset.txt"])
|
||
overlay.hide_hover_hint()
|
||
self.assertFalse(overlay._hide_after_animation)
|
||
self.assertFalse(overlay.isHidden())
|
||
|
||
overlay._auto_hide_timer.stop()
|
||
overlay.hide_hover_hint()
|
||
self.assertTrue(overlay._hide_after_animation)
|
||
|
||
overlay.hide_immediately()
|
||
self.assertTrue(overlay.isHidden())
|
||
|
||
def test_filter_shows_hover_hint_only_for_import_capable_page(self) -> None:
|
||
from ui.window_preset_file_drop import WindowPresetFileDropFilter
|
||
|
||
overlay = Mock()
|
||
importing_filter = WindowPresetFileDropFilter(
|
||
object(),
|
||
target_resolver=lambda: SimpleNamespace(
|
||
import_dropped_preset_files=Mock(return_value=True),
|
||
),
|
||
overlay=overlay,
|
||
)
|
||
self.assertTrue(importing_filter.show_hover_hint())
|
||
overlay.show_hover_hint.assert_called_once_with()
|
||
|
||
importing_filter.hide_hover_hint()
|
||
overlay.hide_hover_hint.assert_called_once_with()
|
||
|
||
ordinary_overlay = Mock()
|
||
ordinary_filter = WindowPresetFileDropFilter(
|
||
object(),
|
||
target_resolver=lambda: object(),
|
||
overlay=ordinary_overlay,
|
||
)
|
||
self.assertFalse(ordinary_filter.show_hover_hint())
|
||
ordinary_overlay.show_hover_hint.assert_not_called()
|
||
|
||
def test_main_window_creates_drag_hover_detector(self) -> None:
|
||
from ui.fluent_app_window import ZapretFluentWindow
|
||
from ui.windows_drag_hover_detector import WindowsDragHoverDetector
|
||
|
||
with patch(
|
||
"ui.fluent_app_window.enable_windows_file_drop",
|
||
return_value=True,
|
||
):
|
||
window = ZapretFluentWindow()
|
||
self.addCleanup(window.deleteLater)
|
||
event_filter = window._preset_file_drop_filter
|
||
self.addCleanup(self._app.removeEventFilter, event_filter)
|
||
|
||
detector = window._drag_hover_detector
|
||
self.assertIsInstance(detector, WindowsDragHoverDetector)
|
||
self.assertIs(detector._on_hover_start.__self__, event_filter)
|
||
self.assertIs(detector._on_hover_end.__self__, event_filter)
|
||
|
||
def test_failed_import_hides_overlay_without_accepted_flash(self) -> None:
|
||
from ui.window_preset_file_drop import WindowPresetFileDropFilter
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
preset_path = Path(tmp) / "Rejected.txt"
|
||
preset_path.write_text("preset", encoding="utf-8")
|
||
target = SimpleNamespace(import_dropped_preset_files=Mock(return_value=False))
|
||
overlay = Mock()
|
||
event_filter = WindowPresetFileDropFilter(
|
||
object(),
|
||
target_resolver=lambda: target,
|
||
overlay=overlay,
|
||
)
|
||
|
||
imported = event_filter.import_file_paths([str(preset_path)])
|
||
|
||
self.assertFalse(imported)
|
||
overlay.show_accepted.assert_not_called()
|
||
overlay.hide_hint.assert_called_once_with()
|
||
|
||
def test_filter_ignores_other_windows_and_pages_without_import_action(self) -> None:
|
||
from ui.window_preset_file_drop import WindowPresetFileDropFilter
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
preset_path = Path(tmp) / "Dragged.txt"
|
||
preset_path.write_text("preset", encoding="utf-8")
|
||
event = _DropEvent(QEvent.Type.Drop, [QUrl.fromLocalFile(str(preset_path))])
|
||
window = object()
|
||
|
||
other_window_filter = WindowPresetFileDropFilter(
|
||
window,
|
||
target_resolver=lambda: SimpleNamespace(import_dropped_preset_files=Mock()),
|
||
)
|
||
self.assertFalse(other_window_filter.eventFilter(_Receiver(object()), event))
|
||
|
||
ordinary_page_filter = WindowPresetFileDropFilter(
|
||
window,
|
||
target_resolver=lambda: object(),
|
||
)
|
||
self.assertFalse(ordinary_page_filter.eventFilter(_Receiver(window), event))
|
||
self.assertFalse(event.accepted)
|
||
|
||
def test_user_presets_page_queues_every_dropped_file(self) -> None:
|
||
from presets.ui.common.user_presets_page import UserPresetsPageBase
|
||
|
||
page = UserPresetsPageBase.__new__(UserPresetsPageBase)
|
||
page._cleanup_in_progress = False
|
||
page._request_preset_bulk_action = Mock(return_value=True)
|
||
|
||
result = UserPresetsPageBase.import_dropped_preset_files(
|
||
page,
|
||
["C:/Temp/First.txt", "C:/Temp/Second.ZIP"],
|
||
)
|
||
|
||
self.assertTrue(result)
|
||
self.assertEqual(
|
||
page._request_preset_bulk_action.call_args_list,
|
||
[
|
||
unittest.mock.call("import", file_path="C:/Temp/First.txt"),
|
||
unittest.mock.call("import", file_path="C:/Temp/Second.ZIP"),
|
||
],
|
||
)
|
||
|
||
def test_drop_delegate_takes_over_drop_and_suppresses_overlay(self) -> None:
|
||
from ui.window_preset_file_drop import WindowPresetFileDropFilter
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
preset_path = Path(tmp) / "Delegated.txt"
|
||
preset_path.write_text("preset", encoding="utf-8")
|
||
url = QUrl.fromLocalFile(str(preset_path))
|
||
window = object()
|
||
receiver = _Receiver(window)
|
||
target = SimpleNamespace(import_dropped_preset_files=Mock(return_value=True))
|
||
overlay = Mock()
|
||
event_filter = WindowPresetFileDropFilter(
|
||
window,
|
||
target_resolver=lambda: target,
|
||
overlay=overlay,
|
||
)
|
||
delegate = SimpleNamespace(handle_dropped_preset_files=Mock(return_value=True))
|
||
event_filter.set_drop_delegate(delegate)
|
||
|
||
drag_event = _DropEvent(QEvent.Type.DragEnter, [url])
|
||
self.assertTrue(event_filter.eventFilter(receiver, drag_event))
|
||
overlay.show_hint.assert_not_called()
|
||
|
||
drop_event = _DropEvent(QEvent.Type.Drop, [url])
|
||
self.assertTrue(event_filter.eventFilter(receiver, drop_event))
|
||
delegate.handle_dropped_preset_files.assert_called_once_with([str(preset_path)])
|
||
target.import_dropped_preset_files.assert_not_called()
|
||
overlay.show_accepted.assert_not_called()
|
||
|
||
event_filter.set_drop_delegate(None)
|
||
second_drop = _DropEvent(QEvent.Type.Drop, [url])
|
||
self.assertTrue(event_filter.eventFilter(receiver, second_drop))
|
||
target.import_dropped_preset_files.assert_called_once_with([str(preset_path)])
|
||
|
||
def test_native_drop_respects_drop_delegate(self) -> None:
|
||
from ui.window_preset_file_drop import (
|
||
WindowPresetFileDropFilter,
|
||
handle_native_preset_file_drop,
|
||
)
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
preset_path = Path(tmp) / "NativeDelegated.txt"
|
||
preset_path.write_text("preset", encoding="utf-8")
|
||
target = SimpleNamespace(import_dropped_preset_files=Mock(return_value=True))
|
||
event_filter = WindowPresetFileDropFilter(
|
||
object(),
|
||
target_resolver=lambda: target,
|
||
overlay=Mock(),
|
||
)
|
||
delegate = SimpleNamespace(handle_dropped_preset_files=Mock(return_value=True))
|
||
event_filter.set_drop_delegate(delegate)
|
||
window = SimpleNamespace(_preset_file_drop_filter=event_filter)
|
||
|
||
with patch(
|
||
"ui.window_preset_file_drop.windows_dropped_file_paths",
|
||
return_value=[str(preset_path)],
|
||
):
|
||
handled = handle_native_preset_file_drop(window, object())
|
||
|
||
self.assertTrue(handled)
|
||
delegate.handle_dropped_preset_files.assert_called_once_with([str(preset_path)])
|
||
target.import_dropped_preset_files.assert_not_called()
|
||
|
||
def test_dead_delegate_falls_back_to_page(self) -> None:
|
||
from ui.window_preset_file_drop import WindowPresetFileDropFilter
|
||
|
||
class _Delegate:
|
||
def handle_dropped_preset_files(self, paths):
|
||
return True
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
preset_path = Path(tmp) / "Fallback.txt"
|
||
preset_path.write_text("preset", encoding="utf-8")
|
||
target = SimpleNamespace(import_dropped_preset_files=Mock(return_value=True))
|
||
event_filter = WindowPresetFileDropFilter(
|
||
object(),
|
||
target_resolver=lambda: target,
|
||
overlay=Mock(),
|
||
)
|
||
delegate = _Delegate()
|
||
event_filter.set_drop_delegate(delegate)
|
||
del delegate # weakref фильтра должен отпустить удалённый диалог
|
||
|
||
self.assertTrue(event_filter.import_file_paths([str(preset_path)]))
|
||
target.import_dropped_preset_files.assert_called_once_with([str(preset_path)])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|