All checks were successful
Zapret source guards / validate (push) Successful in 7s
DNS записывается за доли секунды, и прежняя дуга не успевала пройти и половину оборота. Теперь по щелчку от верхней точки значка бежит комета (яркая голова со свечением, тающий хвост) и всегда проходит полный круг за 0,65 с; оборот значка со вспышкой — только когда круг замкнулся. Если запись дольше, комета кружит, пока DNS не встанет. То же у большого значка панели «Сейчас». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
309 lines
12 KiB
Python
309 lines
12 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
import unittest
|
||
from unittest.mock import patch
|
||
|
||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||
|
||
from PyQt6.QtCore import QEvent, QPoint, QPointF, Qt
|
||
from PyQt6.QtGui import QFocusEvent, QKeyEvent, QMouseEvent
|
||
from PyQt6.QtWidgets import QApplication
|
||
|
||
from dns.ui.provider_grid import ADD_TILE_KEY, DnsProviderGrid, DnsTile, GridTexts, latency_text, tile_accessible_text
|
||
|
||
|
||
def _tiles() -> list[DnsTile]:
|
||
return [
|
||
DnsTile(kind="group", title="Популярные", counter="2"),
|
||
DnsTile(kind="provider", key="Cloudflare", title="Cloudflare", note="Быстрый", address="1.1.1.1", selected=True),
|
||
DnsTile(kind="provider", key="Google DNS", title="Google DNS", address="8.8.8.8", latency="ok", latency_ms=18.4, fastest=True),
|
||
DnsTile(kind="group", title="Свои DNS"),
|
||
DnsTile(kind="provider", key="Дом", title="Дом", address="192.168.1.1", custom=True),
|
||
DnsTile(kind="add", key=ADD_TILE_KEY, title="Свой DNS", note="Добавить свой адрес"),
|
||
]
|
||
|
||
|
||
def _key(grid: DnsProviderGrid, key: Qt.Key, modifiers=Qt.KeyboardModifier.NoModifier) -> None:
|
||
grid.keyPressEvent(QKeyEvent(QEvent.Type.KeyPress, key, modifiers))
|
||
|
||
|
||
class DnsProviderGridTests(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls) -> None:
|
||
cls._app = QApplication.instance() or QApplication([])
|
||
|
||
def _grid(self) -> DnsProviderGrid:
|
||
grid = DnsProviderGrid()
|
||
self.addCleanup(grid.deleteLater)
|
||
grid.resize(560, 100)
|
||
grid.set_tiles(_tiles())
|
||
return grid
|
||
|
||
def test_layout_places_groups_on_own_rows_and_grows_height(self) -> None:
|
||
grid = self._grid()
|
||
|
||
cloudflare = grid.tile_rect("Cloudflare")
|
||
google = grid.tile_rect("Google DNS")
|
||
custom = grid.tile_rect("Дом")
|
||
self.assertEqual(cloudflare.top(), google.top())
|
||
self.assertGreater(custom.top(), cloudflare.bottom())
|
||
self.assertGreaterEqual(grid.height(), grid.tile_rect(ADD_TILE_KEY).bottom())
|
||
|
||
def test_keyboard_moves_between_tiles_and_activates(self) -> None:
|
||
grid = self._grid()
|
||
activated: list[str] = []
|
||
added: list[bool] = []
|
||
grid.activated.connect(activated.append)
|
||
grid.add_clicked.connect(lambda: added.append(True))
|
||
|
||
grid._set_cursor(1)
|
||
_key(grid, Qt.Key.Key_Right)
|
||
_key(grid, Qt.Key.Key_Return)
|
||
_key(grid, Qt.Key.Key_End)
|
||
_key(grid, Qt.Key.Key_Space)
|
||
|
||
self.assertEqual(activated, ["Google DNS"])
|
||
self.assertEqual(added, [True])
|
||
self.assertIn("Свой DNS", grid.accessibleDescription())
|
||
|
||
def test_focus_starts_on_selected_tile_and_reads_state(self) -> None:
|
||
grid = self._grid()
|
||
|
||
grid._cursor = -1
|
||
grid.focusInEvent(QFocusEvent(QEvent.Type.FocusIn))
|
||
|
||
self.assertEqual(grid._key_at(grid._cursor), "Cloudflare")
|
||
self.assertIn("Cloudflare, выбран", grid.accessibleDescription())
|
||
|
||
def test_context_menu_only_for_custom_servers(self) -> None:
|
||
grid = self._grid()
|
||
wanted: list[str] = []
|
||
grid.context_menu_wanted.connect(lambda key, _pos: wanted.append(key))
|
||
|
||
grid._set_cursor(1)
|
||
_key(grid, Qt.Key.Key_Menu)
|
||
grid._set_cursor(4)
|
||
_key(grid, Qt.Key.Key_F10, Qt.KeyboardModifier.ShiftModifier)
|
||
center = QPointF(grid.tile_rect("Дом").center())
|
||
grid.mouseReleaseEvent(
|
||
QMouseEvent(
|
||
QEvent.Type.MouseButtonRelease,
|
||
center,
|
||
center,
|
||
Qt.MouseButton.RightButton,
|
||
Qt.MouseButton.NoButton,
|
||
Qt.KeyboardModifier.NoModifier,
|
||
)
|
||
)
|
||
|
||
self.assertEqual(wanted, ["Дом", "Дом"])
|
||
|
||
def test_mouse_click_activates_tile_under_cursor(self) -> None:
|
||
grid = self._grid()
|
||
activated: list[str] = []
|
||
grid.activated.connect(activated.append)
|
||
point = QPointF(grid.tile_rect("Cloudflare").center())
|
||
|
||
for event_type, buttons in (
|
||
(QEvent.Type.MouseButtonPress, Qt.MouseButton.LeftButton),
|
||
(QEvent.Type.MouseButtonRelease, Qt.MouseButton.NoButton),
|
||
):
|
||
event = QMouseEvent(event_type, point, point, Qt.MouseButton.LeftButton, buttons, Qt.KeyboardModifier.NoModifier)
|
||
if event_type == QEvent.Type.MouseButtonPress:
|
||
grid.mousePressEvent(event)
|
||
else:
|
||
grid.mouseReleaseEvent(event)
|
||
|
||
self.assertEqual(activated, ["Cloudflare"])
|
||
self.assertEqual(grid.index_at(QPoint(-5, -5)), -1)
|
||
|
||
def test_texts_follow_interface_language(self) -> None:
|
||
tile = DnsTile(kind="provider", key="G", title="Google", address="8.8.8.8", latency="ok", latency_ms=0.4, fastest=True)
|
||
english = GridTexts(ms="{ms} ms", selected="selected", not_selected="not selected", fastest="fastest")
|
||
|
||
self.assertEqual(latency_text(tile), "1 мс")
|
||
self.assertEqual(latency_text(tile, english), "1 ms")
|
||
self.assertEqual(latency_text(DnsTile(kind="provider", latency="timeout")), "нет ответа")
|
||
self.assertEqual(latency_text(DnsTile(kind="provider")), "")
|
||
self.assertEqual(tile_accessible_text(tile, english), "Google, not selected, 8.8.8.8, 1 ms, fastest")
|
||
|
||
def test_paint_does_not_fail_for_every_tile_state(self) -> None:
|
||
grid = self._grid()
|
||
grid.set_tiles(
|
||
[
|
||
*_tiles(),
|
||
DnsTile(kind="provider", key="P", title="P", pending=True, latency="measuring", has_ipv6=True, has_doh=True),
|
||
DnsTile(kind="provider", key="T", title="T", latency="timeout", color="#673ab7"),
|
||
]
|
||
)
|
||
grid.flash("Cloudflare")
|
||
|
||
image = grid.grab()
|
||
|
||
self.assertFalse(image.isNull())
|
||
|
||
|
||
class DnsProviderGridMotionTests(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls) -> None:
|
||
cls._app = QApplication.instance() or QApplication([])
|
||
|
||
def _grid(self, enabled: bool = True) -> DnsProviderGrid:
|
||
item = patch("dns.ui.provider_grid.are_live_animations_enabled", return_value=enabled)
|
||
item.start()
|
||
self.addCleanup(item.stop)
|
||
grid = DnsProviderGrid()
|
||
self.addCleanup(grid.deleteLater)
|
||
grid.resize(560, 100)
|
||
self.now = [100.0]
|
||
grid._clock = lambda: self.now[0]
|
||
return grid
|
||
|
||
@staticmethod
|
||
def _tiles(*, pending: str = "", selected: str = "") -> list[DnsTile]:
|
||
return [
|
||
DnsTile(kind="provider", key=key, title=key, pending=key == pending, selected=key in (pending, selected), color="#f48120")
|
||
for key in ("Cloudflare", "Google DNS")
|
||
]
|
||
|
||
def test_fast_apply_still_draws_full_circle_before_settle(self) -> None:
|
||
from dns.ui.provider_grid import CHARGE_MS
|
||
|
||
grid = self._grid()
|
||
grid.set_tiles(self._tiles(selected="Cloudflare"))
|
||
self.assertFalse(grid.is_charging())
|
||
|
||
grid.set_tiles(self._tiles(pending="Google DNS"))
|
||
self.assertTrue(grid.is_charging("Google DNS"))
|
||
|
||
# Windows записала DNS за 0,1 с: комета ещё не замкнула круг — оборота нет.
|
||
self.now[0] += 0.1
|
||
grid.set_tiles(self._tiles(selected="Google DNS"))
|
||
self.assertTrue(grid.is_charging("Google DNS"))
|
||
self.assertEqual(grid.settling_keys(), [])
|
||
self.assertEqual(grid.tile("Google DNS").pending, False)
|
||
|
||
self.now[0] += CHARGE_MS / 1000.0
|
||
grid._tick()
|
||
self.assertFalse(grid.is_charging())
|
||
self.assertEqual(grid.settling_keys(), ["Google DNS"])
|
||
|
||
def test_slow_apply_keeps_circling_and_settles_right_away(self) -> None:
|
||
grid = self._grid()
|
||
grid.set_tiles(self._tiles(pending="Google DNS"))
|
||
|
||
self.now[0] += 3.0
|
||
grid._tick()
|
||
self.assertTrue(grid.is_charging("Google DNS"))
|
||
|
||
grid.set_tiles(self._tiles(selected="Google DNS"))
|
||
self.assertFalse(grid.is_charging())
|
||
self.assertEqual(grid.settling_keys(), ["Google DNS"])
|
||
|
||
def test_failed_apply_does_not_play_settle(self) -> None:
|
||
grid = self._grid()
|
||
grid.set_tiles(self._tiles(pending="Google DNS"))
|
||
|
||
grid.set_tiles(self._tiles(selected="Cloudflare"))
|
||
|
||
self.assertEqual(grid.settling_keys(), [])
|
||
self.assertFalse(grid.is_charging())
|
||
|
||
def test_no_motion_when_live_animations_are_off(self) -> None:
|
||
grid = self._grid(enabled=False)
|
||
grid.set_tiles(self._tiles(pending="Google DNS"))
|
||
self.assertFalse(grid.is_charging())
|
||
|
||
grid.set_tiles(self._tiles(selected="Google DNS"))
|
||
self.assertEqual(grid.settling_keys(), [])
|
||
|
||
def test_comet_runs_one_full_circle_then_orbits(self) -> None:
|
||
from dns.ui.provider_grid import CHARGE_MS, comet_geometry
|
||
|
||
self.assertEqual(comet_geometry(0.0)[0], 0.0)
|
||
self.assertAlmostEqual(comet_geometry(CHARGE_MS / 2)[0], 180.0, places=3)
|
||
head, tail, looping = comet_geometry(CHARGE_MS - 1)
|
||
self.assertGreater(head, 359.0)
|
||
self.assertFalse(looping)
|
||
self.assertEqual(comet_geometry(CHARGE_MS + 200)[1:], (140.0, True))
|
||
|
||
def test_every_motion_frame_paints(self) -> None:
|
||
grid = self._grid()
|
||
grid.set_tiles(self._tiles(pending="Google DNS"))
|
||
for step in (0.0, 0.2, 0.5, 0.9, 2.0):
|
||
self.now[0] = 100.0 + step
|
||
self.assertFalse(grid.grab().isNull())
|
||
grid.set_tiles(self._tiles(selected="Google DNS"))
|
||
for progress in (0.0, 0.3, 0.6, 1.0):
|
||
grid._on_settle_value(progress)
|
||
self.assertFalse(grid.grab().isNull())
|
||
|
||
def test_check_mark_pops_with_overshoot(self) -> None:
|
||
from dns.ui.provider_grid import pop_scale
|
||
|
||
values = [pop_scale(step / 20) for step in range(21)]
|
||
self.assertAlmostEqual(values[0], 0.0, places=6)
|
||
self.assertGreater(max(values), 1.05)
|
||
self.assertAlmostEqual(values[-1], 1.0, places=6)
|
||
|
||
|
||
class DnsNowBadgeMotionTests(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls) -> None:
|
||
cls._app = QApplication.instance() or QApplication([])
|
||
|
||
def _badge(self, enabled: bool = True):
|
||
from dns.ui.now_panel import _Badge
|
||
|
||
item = patch("dns.ui.now_panel.are_live_animations_enabled", return_value=enabled)
|
||
item.start()
|
||
self.addCleanup(item.stop)
|
||
badge = _Badge()
|
||
self.addCleanup(badge.deleteLater)
|
||
badge.show()
|
||
return badge
|
||
|
||
def test_new_server_flips_badge_like_a_coin(self) -> None:
|
||
badge = self._badge()
|
||
|
||
badge.set_icon("fa5s.bolt", "#f48120")
|
||
self.assertTrue(badge.is_flipping())
|
||
for progress in (0.2, 0.5, 0.7, 0.95):
|
||
badge._on_flip_value(progress)
|
||
self.assertFalse(badge.grab().isNull())
|
||
|
||
badge._flip_anim.stop()
|
||
badge.set_icon("fa5s.bolt", "#f48120")
|
||
self.assertFalse(badge.is_flipping())
|
||
|
||
def test_comet_finishes_its_circle_after_busy_ends(self) -> None:
|
||
from dns.ui.provider_grid import CHARGE_MS
|
||
|
||
badge = self._badge()
|
||
now = [10.0]
|
||
badge._clock = lambda: now[0]
|
||
|
||
badge.set_busy(True)
|
||
self.assertTrue(badge.is_busy())
|
||
now[0] += 0.1
|
||
self.assertFalse(badge.grab().isNull())
|
||
badge.set_busy(False)
|
||
self.assertTrue(badge.is_busy()) # круг ещё не замкнут
|
||
|
||
now[0] += CHARGE_MS / 1000.0
|
||
badge._tick()
|
||
self.assertFalse(badge.is_busy())
|
||
|
||
def test_badge_stays_still_when_animations_are_off(self) -> None:
|
||
badge = self._badge(enabled=False)
|
||
|
||
badge.set_icon("fa5s.bolt", "#f48120")
|
||
badge.set_busy(True)
|
||
|
||
self.assertFalse(badge.is_flipping())
|
||
self.assertFalse(badge.is_busy())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|