zapret-kvn/tests/test_app_dashboard_motion.py
loop-uh 6ac583e90c
Some checks failed
Windows project source guards / test (push) Failing after 39s
Панель: текст слева, сцена справа, подключение — только кнопкой питания
Кнопка «Запустить/Остановить» дублировала кнопку питания и убрана.
Сфера стала полноценной кнопкой: фокус по Tab, Enter и пробел, рамка
фокуса с клавиатуры, подсказка и имя для диктора с действием.
Карточка подключения ниже: состояние текстом слева, сцена справа, на
узком окне сцена над текстом. Пояснение, что идёт через туннель, ушло
из шапки в подпись переключателя «Системный прокси Windows».
2026-09-26 21:51:54 +03:00

703 lines
31 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Главная панель: бюджет CPU анимаций, плитки режима, приложения, боковое меню.
Keep the ``test_app_*`` prefix: widget test modules must sort before
``tests/test_engine_process_stop.py`` which creates a bare QCoreApplication
at import time (see tests/test_app_nodes_page_view.py).
Общая страница создаётся один раз на модуль и не уничтожается (см.
предупреждение про крэш Windows в tests/test_app_scrollable_page.py);
единственное исключение — LifecycleTest, который проверяет именно
синхронное разрушение одной страницы.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
import unittest
from unittest.mock import Mock
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication
_existing = QApplication.instance()
if _existing is not None and not isinstance(_existing, QApplication):
raise RuntimeError(
"A bare QCoreApplication was created before test_app_dashboard_motion "
"was imported; widget tests need a QApplication."
)
app = _existing or QApplication([])
from qfluentwidgets import NavigationDisplayMode
from xray_fluent.profiles.models import AppSettings, Node
from xray_fluent.ui.connection_orb import CONNECTED, CONNECTING, ERROR, IDLE
from xray_fluent.ui.dashboard_page import DashboardPage
from xray_fluent.ui.dashboard_widgets import latency_bars
from xray_fluent.ui.main_window import _WINDOW_OWNED_SETTINGS, MainWindow
_page: DashboardPage | None = None
_nav_window_ref = None
def _shared_page() -> DashboardPage:
global _page
if _page is None:
_page = DashboardPage()
_page.resize(1200, 900)
return _page
def _reset(page: DashboardPage) -> None:
page._tun_intent.clear()
page._proxy_intent.clear()
page.set_active_tun_mode(None)
page.set_transition_busy(False)
page.set_runtime_status("idle", "")
page.set_connection(False)
page.set_settings_snapshot(AppSettings(tun_mode=True))
page._do_refresh_dashboard()
class SceneCpuBudgetTest(unittest.TestCase):
"""Таймер кадров только в подключении/подключено и только пока сцена видна."""
def setUp(self) -> None:
self.page = _shared_page()
_reset(self.page)
self.page.show()
QApplication.processEvents()
self.scene = self.page.connection_scene
def tearDown(self) -> None:
self.page.hide()
_reset(self.page)
QApplication.processEvents()
def test_idle_has_no_frame_timers(self) -> None:
self.assertEqual(self.scene.state(), IDLE)
self.assertFalse(self.scene.is_animating())
self.assertFalse(self.page.connection_orb.is_animating())
def test_connected_animates_and_stops_on_hide(self) -> None:
self.page.set_connection(True)
self.page._do_refresh_dashboard()
self.assertEqual(self.scene.state(), CONNECTED)
self.assertTrue(self.scene.is_animating())
self.page.hide()
QApplication.processEvents()
self.assertFalse(self.scene.is_animating())
self.assertFalse(self.page.connection_orb.is_animating())
def test_connected_without_traffic_rests_and_wakes_on_traffic(self) -> None:
self.page.set_connection(True)
self.page._do_refresh_dashboard()
self.assertTrue(self.scene.is_animating())
self.scene._burst_started = None
self.scene.set_traffic(0.0, 0.0)
self.scene._tick() # первый тихий кадр запоминает начало покоя
self.scene._quiet_since -= 10.0
self.scene._tick()
self.assertFalse(self.scene.is_animating())
self.assertTrue(self.scene.is_resting())
self.page.set_live_metrics(2e6, 1e5, 40)
self.assertTrue(self.scene.is_animating())
def test_error_is_static(self) -> None:
self.page.set_runtime_status("error", "Не удалось запустить sing-box")
self.page._do_refresh_dashboard()
self.assertEqual(self.scene.state(), ERROR)
self.assertFalse(self.scene.is_animating())
self.assertEqual(self.page.connection_state_label.text(), "Ошибка подключения")
self.assertFalse(self.page.logs_btn.isHidden())
def test_starting_runs_probe_animation(self) -> None:
self.page.set_runtime_status("starting", "Запуск")
self.page._do_refresh_dashboard()
self.assertEqual(self.scene.state(), CONNECTING)
self.assertTrue(self.scene.is_animating())
def test_frame_dirty_region_is_the_tunnel_band_through_the_orb(self) -> None:
# Поток огоньков идёт через центр сферы, поэтому центр перерисовывается;
# экономия CPU — кадр трогает только полосу туннеля, а не всю сцену.
self.page.set_connection(True)
self.page._do_refresh_dashboard()
self.scene._burst_started = None
self.scene.grab() # строит кэш и выборку пути
region = self.scene._dirty_region()
self.assertTrue(region.contains(self.page.connection_orb.geometry().center()))
band = region.boundingRect()
self.assertLessEqual(band.height(), 96)
self.assertLess(band.height(), self.scene.height() + 1)
class DashboardHonestStatusTest(unittest.TestCase):
def setUp(self) -> None:
self.page = _shared_page()
_reset(self.page)
def tearDown(self) -> None:
_reset(self.page)
def test_only_tun_is_called_protected(self) -> None:
cases = (
(AppSettings(tun_mode=True), "Защищено"),
(AppSettings(tun_mode=False, enable_system_proxy=True), "Подключено"),
(AppSettings(tun_mode=False, enable_system_proxy=False), "Прокси запущен"),
)
for settings, title in cases:
with self.subTest(title=title):
self.page.set_settings_snapshot(settings)
self.page.set_connection(True)
self.page._do_refresh_dashboard()
self.assertEqual(self.page.connection_state_label.text(), title)
self.page.set_connection(False)
def test_orb_is_the_only_connect_control_and_names_the_action(self) -> None:
self.assertFalse(hasattr(self.page, "toggle_btn"))
self.page.set_settings_snapshot(AppSettings(tun_mode=False, enable_system_proxy=False))
self.page.set_connection(True)
self.page._do_refresh_dashboard()
self.assertEqual(self.page.connection_orb.toolTip(), "Остановить прокси")
# Что идёт через туннель, говорит подпись переключателя прокси, не шапка.
self.assertTrue(self.page.connection_status_label.isHidden())
self.assertIn("ручной настройкой", self.page.proxy_hint_label.text())
self.page.set_settings_snapshot(AppSettings(tun_mode=False, enable_system_proxy=True))
self.page._proxy_intent.request(True)
self.page._do_refresh_dashboard()
self.assertIn("подхватывают прокси сами", self.page.proxy_hint_label.text())
def test_hero_text_left_of_scene_and_stacked_when_narrow(self) -> None:
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QBoxLayout
page = self.page
page._apply_hero_direction(stacked=False)
self.assertEqual(page._hero_layout.direction(), QBoxLayout.Direction.LeftToRight)
self.assertEqual(page._hero_layout.itemAt(0).widget(), page._hero_text)
self.assertEqual(page._hero_layout.itemAt(1).widget(), page.connection_scene)
page._apply_hero_direction(stacked=True)
self.assertEqual(page._hero_layout.direction(), QBoxLayout.Direction.BottomToTop)
self.assertTrue(page.connection_state_label.alignment() & Qt.AlignmentFlag.AlignHCenter)
page._apply_hero_direction(stacked=False)
self.assertTrue(page.connection_state_label.alignment() & Qt.AlignmentFlag.AlignLeft)
def test_idle_invites_to_connect(self) -> None:
self.page._do_refresh_dashboard()
self.assertEqual(self.page.connection_state_label.text(), "Не подключено")
self.assertIn("кнопку питания", self.page.connection_status_label.text())
class ModeTilesTest(unittest.TestCase):
def setUp(self) -> None:
self.page = _shared_page()
_reset(self.page)
self.emitted: list[bool] = []
self.page.tun_toggled.connect(self.emitted.append)
def tearDown(self) -> None:
self.page.tun_toggled.disconnect(self.emitted.append)
_reset(self.page)
def test_tiles_follow_settings(self) -> None:
self.assertTrue(self.page.vpn_tile.isChecked())
self.assertFalse(self.page.proxy_tile.isChecked())
self.assertTrue(self.page.proxy_options.isHidden())
self.page.set_settings_snapshot(AppSettings(tun_mode=False))
self.page._do_refresh_dashboard()
self.assertFalse(self.page.vpn_tile.isChecked())
self.assertTrue(self.page.proxy_tile.isChecked())
self.assertFalse(self.page.proxy_options.isHidden())
def test_clicking_other_mode_emits_once_and_current_mode_is_noop(self) -> None:
self.page.vpn_tile.clicked.emit()
self.assertEqual(self.emitted, [])
self.page.proxy_tile.clicked.emit()
self.assertEqual(self.emitted, [False])
def test_tiles_disabled_while_busy(self) -> None:
self.page.set_transition_busy(True)
self.assertFalse(self.page.vpn_tile.isEnabled())
self.assertFalse(self.page.proxy_tile.isEnabled())
self.assertFalse(self.page.connection_orb.isEnabled())
class ProcessesAndServerTest(unittest.TestCase):
def setUp(self) -> None:
self.page = _shared_page()
_reset(self.page)
def tearDown(self) -> None:
_reset(self.page)
@staticmethod
def _stat(exe: str, up: int, down: int, proxy: int):
return SimpleNamespace(
exe=exe, upload=up, download=down, proxy_bytes=proxy, direct_bytes=up + down - proxy,
down_speed=0.0, up_speed=0.0, connections=1, total_connections=1, top_host="example.org",
)
def test_top_apps_ranked_and_session_total_is_cumulative_proxy_bytes(self) -> None:
self.page.set_connection(True)
stats = [
self._stat("small.exe", 10, 10, 20),
self._stat("chrome.exe", 1000, 3000, 4000),
self._stat("game.exe", 500, 500, 0),
]
self.page.set_process_stats(stats)
self.page._do_refresh_dashboard()
names = [name for name, _value, _share in self.page.process_bars.items()]
self.assertEqual(names, ["chrome.exe", "game.exe", "small.exe"])
self.assertEqual(self.page.process_bars.items()[0][2], 1.0)
self.assertEqual(self.page.traffic_session_label.text(), self.page._format_bytes(4020))
def test_unavailable_stats_are_shown_honestly(self) -> None:
self.page.set_connection(True)
self.page.set_process_stats(None)
self.page._do_refresh_dashboard()
self.assertEqual(self.page.traffic_session_label.text(), "--")
self.assertIn("недоступна", self.page.process_summary_label.text())
self.assertEqual(self.page.process_bars.items(), [])
def test_next_server_needs_more_than_one_node(self) -> None:
nodes = [Node(name="A", scheme="vless", server="a.example", port=1)]
self.page.set_nodes(nodes, nodes[0].id)
self.page._do_refresh_dashboard()
self.assertFalse(self.page.next_server_btn.isEnabled())
nodes.append(Node(name="B", scheme="vless", server="b.example", port=1))
self.page.set_nodes(nodes, nodes[0].id)
self.page._do_refresh_dashboard()
self.assertTrue(self.page.next_server_btn.isEnabled())
self.page.set_nodes([], None)
def test_signal_bars_scale(self) -> None:
self.assertEqual([latency_bars(v) for v in (None, 40, 120, 250, 900)], [0, 4, 3, 2, 1])
def _nav_window(mode, expanding: bool, *, width: int = 1400, saved: bool = False):
panel = SimpleNamespace(
displayMode=mode,
expandAni=Mock(property=Mock(return_value=expanding)),
minimumExpandWidth=1100,
expand=Mock(),
)
settings = AppSettings(nav_expanded=saved)
controller = Mock()
controller.state.settings = settings
window = SimpleNamespace(
navigationInterface=SimpleNamespace(panel=panel),
controller=controller,
_geometry_persistence_ready=True,
_nav_expanded_pref=saved,
width=lambda: width,
)
window._sync_nav_to_preference = lambda: MainWindow._sync_nav_to_preference(window)
return window, panel, settings, controller
class NavPreferenceTest(unittest.TestCase):
def test_click_expand_is_saved(self) -> None:
window, _panel, settings, controller = _nav_window(NavigationDisplayMode.EXPAND, True)
MainWindow._on_nav_menu_clicked(window)
self.assertTrue(settings.nav_expanded)
controller.schedule_save.assert_called_once()
def test_click_collapse_is_saved(self) -> None:
window, _panel, settings, _controller = _nav_window(NavigationDisplayMode.EXPAND, False, saved=True)
MainWindow._on_nav_menu_clicked(window)
self.assertFalse(settings.nav_expanded)
self.assertFalse(window._nav_expanded_pref)
def test_overlay_menu_is_not_a_preference(self) -> None:
for expanding in (True, False):
with self.subTest(expanding=expanding):
window, _panel, settings, controller = _nav_window(NavigationDisplayMode.MENU, expanding, saved=True)
MainWindow._on_nav_menu_clicked(window)
self.assertTrue(settings.nav_expanded)
controller.schedule_save.assert_not_called()
def test_restore_expands_only_on_wide_window(self) -> None:
window, panel, settings, _controller = _nav_window(NavigationDisplayMode.COMPACT, False, saved=True)
MainWindow._restore_nav_preference(window, settings)
panel.expand.assert_called_once_with(useAni=False)
window, panel, settings, _controller = _nav_window(
NavigationDisplayMode.COMPACT, False, width=1000, saved=True
)
MainWindow._restore_nav_preference(window, settings)
panel.expand.assert_not_called()
def test_collapsed_preference_never_expands(self) -> None:
window, panel, settings, _controller = _nav_window(NavigationDisplayMode.COMPACT, False, saved=False)
MainWindow._restore_nav_preference(window, settings)
MainWindow._sync_nav_to_preference(window)
panel.expand.assert_not_called()
def test_auto_collapse_keeps_preference_and_widening_restores(self) -> None:
# Библиотека свернула меню на узком окне: клика не было, предпочтение живо.
window, panel, settings, _controller = _nav_window(
NavigationDisplayMode.COMPACT, False, width=1000, saved=True
)
MainWindow._sync_nav_to_preference(window)
panel.expand.assert_not_called()
window.width = lambda: 1300
MainWindow._sync_nav_to_preference(window)
panel.expand.assert_called_once_with(useAni=False)
self.assertTrue(settings.nav_expanded)
def test_real_navigation_panel_click_order(self) -> None:
"""На настоящей панели qfluentwidgets: toggle() панели срабатывает раньше нашего слота."""
from PyQt6.QtCore import QEventLoop, QTimer
from qfluentwidgets import FluentWindow
global _nav_window_ref
window = FluentWindow()
_nav_window_ref = window # общий на процесс, не уничтожаем (см. докстринг модуля)
window.resize(1400, 800)
window.show()
QApplication.processEvents()
# Экран может ужать окно (у отключённой сессии Windows ~1036 px):
# порог развёртывания считаем от фактической ширины, иначе панель
# уходит во всплывающий режим MENU, который предпочтением не считается.
window.navigationInterface.setMinimumExpandWidth(max(1, window.width() - 100))
settings = AppSettings(nav_expanded=False)
controller = Mock()
controller.state.settings = settings
facade = SimpleNamespace(
navigationInterface=window.navigationInterface,
controller=controller,
_geometry_persistence_ready=True,
_nav_expanded_pref=False,
)
button = window.navigationInterface.panel.menuButton
button.clicked.connect(lambda: MainWindow._on_nav_menu_clicked(facade))
def settle() -> None:
loop = QEventLoop()
QTimer.singleShot(300, loop.quit)
loop.exec()
button.click()
settle()
self.assertTrue(settings.nav_expanded)
button.click()
settle()
self.assertFalse(settings.nav_expanded)
window.hide()
def test_setting_roundtrip(self) -> None:
self.assertTrue(AppSettings.from_dict(AppSettings(nav_expanded=True).to_dict()).nav_expanded)
self.assertFalse(AppSettings.from_dict({}).nav_expanded)
class SettingsPageSaveGuardTest(unittest.TestCase):
def test_window_owned_fields_survive_settings_page_snapshot(self) -> None:
self.assertIn("nav_expanded", _WINDOW_OWNED_SETTINGS)
self.assertIn("nodes_column_widths", _WINDOW_OWNED_SETTINGS)
self.assertIn("window_width", _WINDOW_OWNED_SETTINGS)
current = AppSettings(nav_expanded=True, window_width=1500, nodes_column_widths={"name": 240})
controller = Mock()
controller.state.settings = current
window = SimpleNamespace(controller=controller)
stale = AppSettings(nav_expanded=False, window_width=1000, theme="dark")
MainWindow._on_settings_page_saved(window, stale)
saved = controller.update_settings.call_args.args[0]
self.assertTrue(saved.nav_expanded)
self.assertEqual(saved.window_width, 1500)
self.assertEqual(saved.nodes_column_widths, {"name": 240})
self.assertEqual(saved.theme, "dark")
self.assertIsNot(saved.nodes_column_widths, current.nodes_column_widths)
class LifecycleTest(unittest.TestCase):
"""Нет циклов «C++-владение ↔ __dict__»: окно разрушается сразу, пока жив QApplication."""
def test_frame_gate_holds_no_strong_reference_to_window(self) -> None:
import gc
import weakref
from PyQt6.QtWidgets import QWidget
from xray_fluent.ui.motion import FrameGate
window = QWidget()
child = QWidget(window)
gate = FrameGate(child)
gate.set_interval(40)
window.show()
QApplication.processEvents()
self.assertTrue(gate.is_running())
destroyed = []
window.destroyed.connect(lambda *_: destroyed.append(True))
ref = weakref.ref(window)
del window, child, gate
gc.collect()
self.assertIsNone(ref())
self.assertEqual(destroyed, [True])
def test_connected_dashboard_is_freed_without_gc(self) -> None:
import gc
import weakref
page = DashboardPage()
page.resize(1000, 800)
page.show()
page.set_connection(True)
page._do_refresh_dashboard()
QApplication.processEvents()
self.assertTrue(page.connection_scene.is_animating())
ref = weakref.ref(page)
gc.disable()
try:
del page
self.assertIsNone(ref(), "dashboard page is kept alive by a reference cycle")
finally:
gc.enable()
def test_dispose_windows_deletes_idle_windows_and_keeps_running_threads(self) -> None:
import threading
from PyQt6 import sip
from PyQt6.QtCore import QThread
from PyQt6.QtWidgets import QWidget
from xray_fluent.ui.qt_lifecycle import dispose_windows
release = threading.Event()
class _Blocker(QThread):
def run(self) -> None:
release.wait(5)
idle = QWidget()
busy = QWidget()
thread = _Blocker(busy)
thread.start()
try:
windows = [idle, busy]
kept = dispose_windows(windows)
self.assertTrue(sip.isdeleted(idle))
self.assertFalse(sip.isdeleted(busy))
self.assertEqual(kept, [busy])
self.assertEqual(windows, [busy])
finally:
release.set()
thread.wait(5000)
dispose_windows(windows)
self.assertTrue(sip.isdeleted(busy))
def test_dispose_all_widgets_sweeps_orphan_top_levels(self) -> None:
from PyQt6 import sip
from PyQt6.QtWidgets import QMenu, QWidget
from xray_fluent.ui.qt_lifecycle import dispose_all_widgets
from unittest.mock import patch
from xray_fluent.ui import qt_lifecycle
window = QWidget()
orphan_menu = QMenu() # как меню трея без родителя
# Подменяем список окон верхнего уровня: настоящий снёс бы общие
# страницы других тестовых модулей в этом процессе.
fake_app = SimpleNamespace(topLevelWidgets=lambda: [orphan_menu])
with patch.object(qt_lifecycle.QApplication, "instance", return_value=fake_app):
dispose_all_widgets([window])
self.assertTrue(sip.isdeleted(window))
self.assertTrue(sip.isdeleted(orphan_menu))
class TunnelLoadScaleTest(unittest.TestCase):
"""Огоньки следуют реальной скорости до 1 Гбит/с; полоса меняется только под нагрузкой."""
def test_level_scale_distinguishes_high_speeds(self) -> None:
from xray_fluent.ui.dashboard_widgets import ConnectionScene
level = ConnectionScene._traffic_level
mbit = 125_000.0
points = [level(v) for v in (1_000.0, 1_000_000.0, 50 * mbit, 100 * mbit, 500 * mbit, 1000 * mbit)]
self.assertEqual(points, sorted(points))
self.assertAlmostEqual(level(1000 * mbit), 1.0, places=3)
self.assertEqual(level(5000 * mbit), 1.0) # потолок — 1 Гбит/с
self.assertGreater(level(500 * mbit) - level(50 * mbit), 0.2) # 500 и 50 Мбит заметно различаются
self.assertEqual(level(0), 0.0)
def test_background_traffic_does_not_thicken_the_tunnel(self) -> None:
from xray_fluent.ui.dashboard_widgets import ConnectionScene
scene = ConnectionScene()
try:
for bps, expect_load in ((20_000.0, False), (1_000_000.0, False), (60_000_000.0, True)):
scene._level = ConnectionScene._traffic_level(bps)
self.assertEqual(scene.load_level() > 0.05, expect_load, bps)
scene._level = 1.0
self.assertEqual(scene.load_level(), 1.0)
finally:
_KEEP_SCENES.append(scene)
_KEEP_SCENES: list = []
class SystemProxySwitchIntentTest(unittest.TestCase):
"""Клик по «Системному прокси» не откатывается, пока Windows применяет изменение."""
def setUp(self) -> None:
from xray_fluent.platform.windows.proxy_manager import SystemProxyState
self.State = SystemProxyState
self.page = _shared_page()
_reset(self.page)
self.page.set_settings_snapshot(AppSettings(tun_mode=False, enable_system_proxy=True))
self.page.set_connection(True)
self.page.set_system_proxy_state(self._registry(enabled=True))
self.emitted: list[bool] = []
self.page.proxy_toggled.connect(self.emitted.append)
def tearDown(self) -> None:
self.page.proxy_toggled.disconnect(self.emitted.append)
self.page._proxy_intent.clear()
self.page.set_system_proxy_state(None)
_reset(self.page)
def _registry(self, *, enabled: bool):
fields = getattr(self.State, "__dataclass_fields__", {})
values = {name: None for name in fields}
values.update(supported=True, enabled=enabled, is_ours=enabled)
try:
return self.State(**{k: v for k, v in values.items() if k in fields})
except TypeError:
return SimpleNamespace(supported=True, enabled=enabled, is_ours=enabled)
def test_click_off_shows_intent_until_windows_catches_up(self) -> None:
switch = self.page.proxy_switch
self.assertTrue(switch.isChecked())
switch.setChecked(False) # пользователь кликнул «выкл»
self.assertEqual(self.emitted, [False])
# Настройки уже «выкл», а реестр Windows ещё «вкл» — раньше тут был откат.
self.page.set_settings_snapshot(AppSettings(tun_mode=False, enable_system_proxy=False))
self.page.set_system_proxy_state(self._registry(enabled=True))
self.assertFalse(switch.isChecked())
self.assertTrue(switch.text.endswith("…"))
# Очередь прокси применила изменение — пришёл свежий снимок.
self.page.set_system_proxy_state(self._registry(enabled=False))
self.assertFalse(switch.isChecked())
self.assertEqual(switch.text, "Выкл")
def test_intent_times_out_to_the_truth(self) -> None:
switch = self.page.proxy_switch
switch.setChecked(False)
self.page._proxy_intent._deadline = 0.0 # таймаут истёк
self.page.set_system_proxy_state(self._registry(enabled=True))
self.assertTrue(switch.isChecked())
self.assertEqual(switch.text, "Вкл")
class ModeTileIntentTest(unittest.TestCase):
"""Плитка VPN/Прокси показывает выбор, пока переход применяет его к сессии."""
def setUp(self) -> None:
self.page = _shared_page()
_reset(self.page)
# Подключено в режиме прокси: сессия — прокси, настройка — прокси.
self.page.set_settings_snapshot(AppSettings(tun_mode=False, enable_system_proxy=True))
self.page.set_connection(True)
self.page.set_active_tun_mode(False)
self.emitted: list[bool] = []
self.page.tun_toggled.connect(self._on_toggled)
def tearDown(self) -> None:
self.page.tun_toggled.disconnect(self._on_toggled)
_reset(self.page)
def _on_toggled(self, tun: bool) -> None:
# Как MainWindow: настройка меняется синхронно, сессия — после перехода.
self.emitted.append(tun)
self.page.set_settings_snapshot(AppSettings(tun_mode=tun, enable_system_proxy=not tun))
def _assert_shows(self, tun: bool, *, applying: bool) -> None:
self.assertEqual(self.page.vpn_tile.isChecked(), tun)
self.assertEqual(self.page.proxy_tile.isChecked(), not tun)
chosen = self.page.vpn_tile if tun else self.page.proxy_tile
other = self.page.proxy_tile if tun else self.page.vpn_tile
self.assertEqual(chosen.title_label.text().endswith("…"), applying)
self.assertEqual(chosen.toolTip(), "Применяется…" if applying else "")
self.assertFalse(other.is_applying())
def test_click_shows_intent_until_session_switches(self) -> None:
self.page.vpn_tile.clicked.emit()
self.assertEqual(self.emitted, [True])
self._assert_shows(True, applying=True)
# Переход начался, старая сессия ещё жива — откатываться нельзя.
self.page.set_active_tun_mode(False)
self.page.set_transition_busy(True)
self._assert_shows(True, applying=True)
# Старая сессия остановлена, новой ещё нет — факта нет, выбор держится.
self.page.set_active_tun_mode(None)
self.page.set_connection(False)
self._assert_shows(True, applying=True)
# TUN-сессия поднялась — обычная подпись.
self.page.set_active_tun_mode(True)
self.page.set_connection(True)
self._assert_shows(True, applying=False)
self.assertEqual(self.page.vpn_tile.title_label.text(), "VPN (TUN)")
self.page.set_transition_busy(False)
self._assert_shows(True, applying=False)
def test_abandoned_transition_shows_the_running_session(self) -> None:
self.page.vpn_tile.clicked.emit()
self.page.set_transition_busy(True)
self._assert_shows(True, applying=True)
# Переход отменён до остановки старой сессии (например, DNS сервера
# для Zapret не определился): «занято» снято, работает прокси.
self.page.set_active_tun_mode(False)
self.page.set_transition_busy(False)
self._assert_shows(False, applying=False)
def test_intent_times_out_to_the_running_session(self) -> None:
self.page.vpn_tile.clicked.emit()
self.page._tun_intent._deadline = 0.0 # таймаут истёк
self.page.set_active_tun_mode(False)
self._assert_shows(False, applying=False)
# Плитка показывает факт — клик по VPN снова запрашивает переход.
self.page.vpn_tile.clicked.emit()
self.assertEqual(self.emitted, [True, True])
self._assert_shows(True, applying=True)
def test_timeout_without_session_falls_back_to_setting(self) -> None:
self.page.vpn_tile.clicked.emit()
self.page.set_active_tun_mode(None)
self.page.set_connection(False)
self.page._tun_intent._deadline = 0.0
self.page.set_transition_busy(True)
# Сессии нет, факта нет — показываем настройку (не «Прокси» из None).
self._assert_shows(True, applying=False)
def test_second_click_retargets_intent(self) -> None:
self.page.vpn_tile.clicked.emit()
self._assert_shows(True, applying=True)
# Второй клик до начала перехода: побеждает последний выбор, и он уже
# совпадает с работающей сессией — ждать нечего.
self.page.proxy_tile.clicked.emit()
self.assertEqual(self.emitted, [True, False])
self._assert_shows(False, applying=False)
# Устаревшее «VPN» не возвращается, когда переход отработает.
self.page.set_transition_busy(True)
self.page.set_transition_busy(False)
self._assert_shows(False, applying=False)
def test_disconnected_click_applies_at_once(self) -> None:
self.page.set_active_tun_mode(None)
self.page.set_connection(False)
self.page.vpn_tile.clicked.emit()
# Без подключения фактом служит настройка — она уже сменилась.
self._assert_shows(True, applying=False)
if __name__ == "__main__":
unittest.main()