A single click waits for the system double-click time (a double click opens the preset text instead of activating it), and for that half second nothing on the tile changed, so the click looked ignored. The clicked tile now gets the accent fill of an active tile right away (no check mark, so it reads as 'chosen, not active yet'). When the activation fires, the marker slides to a tile that is already filled instead of lighting it up from nothing. The highlight is dropped by a double click, shift click, a button click or another click, and by a timer if the activation fails. The wait itself is unchanged. Tests compare the rendered tile pixels (before / after click / double click / shift click / two quick clicks / after activation). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
214 lines
9.4 KiB
Python
214 lines
9.4 KiB
Python
from __future__ import annotations
|
|
|
|
import inspect
|
|
import os
|
|
import unittest
|
|
from unittest.mock import Mock
|
|
|
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
|
|
from PyQt6.QtCore import QPoint
|
|
from PyQt6.QtWidgets import QApplication
|
|
|
|
from ui.presets_menu import delegate as preset_delegate
|
|
from ui.presets_menu import common as preset_common
|
|
from ui.presets_menu.model import PresetListModel
|
|
from ui.presets_menu import view as preset_view
|
|
from presets.ui.common.user_presets_page import UserPresetsPageBase
|
|
|
|
|
|
class PresetDragIndicatorTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
cls._app = QApplication.instance() or QApplication([])
|
|
|
|
def test_drop_marker_maps_targets_to_clear_visual_modes(self) -> None:
|
|
self.assertEqual(
|
|
preset_view.preset_drop_marker_for_target(2, "folder"),
|
|
{"row": 2, "mode": "folder"},
|
|
)
|
|
self.assertEqual(
|
|
preset_view.preset_drop_marker_for_target(4, "preset"),
|
|
{"row": 4, "mode": "before"},
|
|
)
|
|
self.assertEqual(
|
|
preset_view.preset_drop_marker_for_target(-1, "empty"),
|
|
{"row": -1, "mode": ""},
|
|
)
|
|
|
|
def test_drop_target_uses_lower_half_as_after_row(self) -> None:
|
|
self.assertEqual(
|
|
preset_view.preset_drop_target_for_position(4, "preset", y=109, row_top=100, row_height=20),
|
|
{"marker": {"row": 4, "mode": "before"}, "destination_kind": "preset", "destination_row": 4},
|
|
)
|
|
self.assertEqual(
|
|
preset_view.preset_drop_target_for_position(4, "preset", y=112, row_top=100, row_height=20),
|
|
{"marker": {"row": 4, "mode": "after"}, "destination_kind": "preset_after", "destination_row": 4},
|
|
)
|
|
|
|
def test_adjacent_preset_gap_has_one_canonical_drop_target(self) -> None:
|
|
lower_half_target = preset_view.preset_drop_target_for_position(
|
|
4,
|
|
"preset",
|
|
y=112,
|
|
row_top=100,
|
|
row_height=20,
|
|
)
|
|
|
|
self.assertEqual(
|
|
preset_common.preset_canonical_drop_target_for_next_row(
|
|
lower_half_target,
|
|
next_row=5,
|
|
next_kind="preset",
|
|
),
|
|
{"marker": {"row": 5, "mode": "before"}, "destination_kind": "preset", "destination_row": 5},
|
|
)
|
|
|
|
def test_view_clears_drop_marker_when_drag_finishes_or_leaves(self) -> None:
|
|
view_source = inspect.getsource(preset_view.LinkedWheelListView)
|
|
|
|
self.assertIn("set_drop_marker", view_source)
|
|
self.assertIn("dragLeaveEvent", view_source)
|
|
self.assertIn("self.set_drop_marker(-1, \"\")", view_source)
|
|
|
|
def test_internal_drag_does_not_depend_on_windows_ole_drop_target(self) -> None:
|
|
view_source = inspect.getsource(preset_view.LinkedWheelListView.mouseMoveEvent)
|
|
finish_source = inspect.getsource(preset_view.LinkedWheelListView._finish_internal_drag)
|
|
|
|
self.assertIn("self._drag_source = (kind, source_id)", view_source)
|
|
self.assertIn("self._update_internal_drag", view_source)
|
|
self.assertNotIn("QDrag", view_source)
|
|
self.assertNotIn("restore_windows_qt_file_drop", view_source)
|
|
self.assertIn("self.item_dropped.emit", finish_source)
|
|
|
|
def test_internal_mouse_drag_emits_the_resolved_preset_move(self) -> None:
|
|
view = preset_view.LinkedWheelListView()
|
|
self.addCleanup(view.deleteLater)
|
|
view.resize(500, 300)
|
|
view._drag_source = ("preset", "A.txt")
|
|
view._drop_target_at = Mock(
|
|
return_value=(
|
|
{"marker": {"row": 2, "mode": "before"}, "destination_kind": "preset"},
|
|
"B.txt",
|
|
"games",
|
|
)
|
|
)
|
|
dropped: list[tuple[str, str, str, str, str]] = []
|
|
view.item_dropped.connect(lambda *args: dropped.append(tuple(args)))
|
|
|
|
self.assertTrue(view._finish_internal_drag(QPoint(20, 20)))
|
|
|
|
self.assertEqual(dropped, [("preset", "A.txt", "preset", "B.txt", "games")])
|
|
self.assertIsNone(view._drag_source)
|
|
|
|
def test_view_updates_only_drop_marker_rows(self) -> None:
|
|
payload_source = inspect.getsource(preset_view.LinkedWheelListView.set_drop_marker_payload)
|
|
update_source = inspect.getsource(preset_view.LinkedWheelListView._update_drop_marker_rows)
|
|
|
|
self.assertIn("_update_drop_marker_rows", payload_source)
|
|
self.assertNotIn("viewport().update()", payload_source)
|
|
self.assertIn("viewport().update(rect", update_source)
|
|
|
|
def test_preset_current_index_helper_skips_already_selected_index(self) -> None:
|
|
class _Index:
|
|
def __init__(self, row: int) -> None:
|
|
self.row = row
|
|
|
|
def __eq__(self, other) -> bool:
|
|
return isinstance(other, _Index) and self.row == other.row
|
|
|
|
index = _Index(3)
|
|
view = Mock()
|
|
view.currentIndex.return_value = index
|
|
|
|
self.assertFalse(preset_common.set_current_index_if_changed(view, index))
|
|
|
|
view.setCurrentIndex.assert_not_called()
|
|
|
|
def test_view_sends_destination_folder_with_drop(self) -> None:
|
|
view_source = inspect.getsource(preset_view.LinkedWheelListView)
|
|
|
|
self.assertIn("item_dropped = pyqtSignal(str, str, str, str, str)", view_source)
|
|
self.assertIn("PresetListModel.FolderKeyRole", view_source)
|
|
self.assertIn("destination_folder_key", view_source)
|
|
self.assertIn(
|
|
"self.item_dropped.emit(source_kind, source_id, destination_kind, destination_id, destination_folder_key)",
|
|
view_source,
|
|
)
|
|
|
|
def test_delegate_draws_folder_and_before_row_drop_markers(self) -> None:
|
|
delegate_source = inspect.getsource(preset_delegate.PresetListDelegate)
|
|
|
|
self.assertIn("_paint_drop_marker", delegate_source)
|
|
self.assertIn('marker.get("mode") == "folder"', delegate_source)
|
|
self.assertIn('marker.get("mode") in ("before", "after")', delegate_source)
|
|
self.assertIn("drag_marker_visible", delegate_source)
|
|
self.assertIn("active=(is_active or clicked) and not drag_marker_visible", delegate_source)
|
|
self.assertIn("show_active_marker=False", delegate_source)
|
|
|
|
def test_delegate_action_updates_only_affected_preset_row(self) -> None:
|
|
click_source = inspect.getsource(preset_delegate.PresetListDelegate._handle_action_click)
|
|
clear_source = inspect.getsource(preset_delegate.PresetListDelegate._clear_pending_destructive)
|
|
shake_source = inspect.getsource(preset_delegate.PresetListDelegate._advance_pending_shake)
|
|
|
|
self.assertIn("_update_preset_row_view", click_source)
|
|
self.assertIn("_update_pending_destructive_row", clear_source)
|
|
self.assertIn("_update_pending_destructive_row", shake_source)
|
|
self.assertNotIn("viewport().update()", click_source)
|
|
self.assertNotIn("viewport().update()", clear_source)
|
|
self.assertNotIn("viewport().update()", shake_source)
|
|
|
|
def test_model_moves_preset_row_without_full_reload(self) -> None:
|
|
model = PresetListModel()
|
|
model.set_rows(
|
|
[
|
|
{"kind": "folder", "folder_key": "common", "name": "Общие", "count": 1, "is_collapsed": False},
|
|
{"kind": "preset", "file_name": "A.txt", "name": "A", "folder_key": "common"},
|
|
{"kind": "folder", "folder_key": "game-filter", "name": "Game filter", "count": 1, "is_collapsed": False},
|
|
{"kind": "preset", "file_name": "B.txt", "name": "B", "folder_key": "game-filter"},
|
|
]
|
|
)
|
|
model.beginResetModel = Mock(side_effect=AssertionError("move must not reset the whole preset list"))
|
|
|
|
self.assertTrue(model.move_preset("A.txt", "preset_after", "B.txt", "game-filter"))
|
|
|
|
rows = [
|
|
(
|
|
model.index(row, 0).data(PresetListModel.KindRole),
|
|
model.index(row, 0).data(PresetListModel.FolderKeyRole),
|
|
model.index(row, 0).data(PresetListModel.FileNameRole),
|
|
model.index(row, 0).data(PresetListModel.CountRole),
|
|
)
|
|
for row in range(model.rowCount())
|
|
]
|
|
|
|
self.assertIn(("preset", "game-filter", "A.txt", 0), rows)
|
|
self.assertNotIn(("preset", "common", "A.txt", 0), rows)
|
|
self.assertIn(("folder", "common", "", 0), rows)
|
|
self.assertIn(("folder", "game-filter", "", 2), rows)
|
|
|
|
def test_model_keeps_folder_count_when_preset_moves_inside_same_folder(self) -> None:
|
|
model = PresetListModel()
|
|
model.set_rows(
|
|
[
|
|
{"kind": "folder", "folder_key": "common", "name": "Общие", "count": 2, "is_collapsed": False},
|
|
{"kind": "preset", "file_name": "A.txt", "name": "A", "folder_key": "common"},
|
|
{"kind": "preset", "file_name": "B.txt", "name": "B", "folder_key": "common"},
|
|
]
|
|
)
|
|
model.beginResetModel = Mock(side_effect=AssertionError("move must not reset the whole preset list"))
|
|
|
|
self.assertTrue(model.move_preset("A.txt", "preset_after", "B.txt", "common"))
|
|
|
|
self.assertEqual(model.index(0, 0).data(PresetListModel.CountRole), 2)
|
|
|
|
def test_local_preset_move_preserves_scroll_position(self) -> None:
|
|
move_source = inspect.getsource(UserPresetsPageBase._apply_preset_move_locally)
|
|
|
|
self.assertIn("capture_presets_view_state", move_source)
|
|
self.assertIn("restore_presets_view_state", move_source)
|
|
self.assertNotIn("ensure_preset_list_current_index", move_source)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|