zapret-kvn/tests/test_app_dashboard_layout.py
loop-uh bfb8214a60
Some checks failed
Windows project source guards / test (push) Has been cancelled
Режим TUN держит локальный прокси на 127.0.0.1:1390 — Telegram с прокси не обрывается
Пользователь вписал прокси ZapretKVN в Telegram, переключился в «VPN (TUN)»,
и Telegram перестал подключаться вовсе (16.10.2026): TUN-план не открывал
SOCKS/HTTP, а сессия при этом записывала порты по умолчанию, на которых никто
не слушал. Решение владельца — прокси в обоих режимах.

plan_singbox_runtime добавляет тот же mixed/http, что режим «Прокси», через
_ensure_singbox_proxy_runtime_contract, но с listen=127.0.0.1: раздачу прокси в
сеть включают режимом «Прокси», в TUN её никто не просил. Порты своей сессии
передаются как разрешённые — переподключение их сохраняет. Панель показывает
адрес и в TUN (подсказка: только для программ этого компьютера), кнопка
«Добавить в Telegram» — только в режиме «Прокси». Тест «TUN не объявляет
порты» закреплял прежний контракт и заменён на обратный.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:00:07 +03:00

358 lines
15 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.

"""Dashboard layout tests: wordWrap invariant (AC8) and adaptive grid (AC10).
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).
"""
from __future__ import annotations
import os
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
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_layout "
"was imported; widget tests need a QApplication."
)
app = _existing or QApplication([])
from xray_fluent.profiles.models import AppSettings, Node
from xray_fluent.ui.dashboard_page import DashboardPage
from xray_fluent.ui.main_window import MainWindow
def _routing_card_position(page: DashboardPage) -> tuple[int, int, int, int]:
grid = page._cards_grid
index = grid.indexOf(page.routing_card)
assert index >= 0, "routing_card is not in the cards grid"
return grid.getItemPosition(index)
class DashboardWordWrapTest(unittest.TestCase):
"""AC8: variable-length labels of the connection/routing cards wrap."""
LABELS = (
"connection_status_label",
"connection_target_label",
"connection_engine_label",
"connection_ports_label",
"routing_mode_label",
"routing_dns_label",
"routing_rules_label",
"routing_bypass_label",
)
def test_labels_have_word_wrap(self) -> None:
page = DashboardPage()
for name in self.LABELS:
with self.subTest(label=name):
label = getattr(page, name)
self.assertTrue(label.wordWrap(), f"{name} must have wordWrap")
page.deleteLater()
QApplication.processEvents()
def test_minimum_size_hint_with_typical_texts(self) -> None:
# AC9 companion: with the (long) default TUN/proxy explanation texts
# rendered, the page minimum must still fit the 860px window minimum.
page = DashboardPage()
page.set_settings_snapshot(AppSettings())
page._do_refresh_dashboard()
self.assertLessEqual(page.minimumSizeHint().width(), 860)
page.deleteLater()
QApplication.processEvents()
def test_next_server_button_is_mouse_only(self) -> None:
# Пробел/Enter при фокусе на «Следующий» незаметно меняли сервер
# (инцидент 28.09.2026): кнопка не берёт фокус клавиатуры.
from PyQt6.QtCore import Qt
page = DashboardPage()
self.assertEqual(page.next_server_btn.focusPolicy(), Qt.FocusPolicy.NoFocus)
page.deleteLater()
QApplication.processEvents()
def test_selected_server_endpoint_is_masked(self) -> None:
page = DashboardPage()
page.set_selected_node(
Node(name="Server", scheme="vless", server="secret.example", port=443)
)
page._do_refresh_dashboard()
self.assertEqual(page.connection_target_label.text(), "VLESS · ********")
self.assertEqual(page.server_name_label.text(), "Server")
for label in (page.connection_target_label, page.server_name_label, page.connection_status_label):
self.assertNotIn("secret.example", label.text())
self.assertNotIn("secret.example", page.connection_scene._server_caption)
page.deleteLater()
QApplication.processEvents()
def test_generated_awg_name_does_not_reveal_endpoint_on_dashboard(self):
page = DashboardPage()
page.set_selected_node(Node(name="awg-203.0.113.8:443", scheme="awg", server="203.0.113.8", port=443))
page._do_refresh_dashboard()
self.assertNotIn("203.0.113.8", page.connection_target_label.text())
self.assertNotIn("203.0.113.8", page.server_name_label.text())
self.assertNotIn("203.0.113.8", page.connection_scene._server_caption)
page.deleteLater()
QApplication.processEvents()
def test_connected_proxy_shows_effective_ports_and_mixed_compatibility(self) -> None:
page = DashboardPage()
settings = AppSettings(tun_mode=False, proxy_engine="singbox")
page.set_settings_snapshot(settings)
page.set_proxy_ports(1392, 1393)
page.set_connection(True)
page._do_refresh_dashboard()
self.assertFalse(page.connection_ports_label.isHidden())
self.assertEqual(
page.connection_ports_label.text(),
"SOCKS5: 127.0.0.1:1392 · HTTP: 127.0.0.1:1393",
)
self.assertIn("Порт 1392 принимает и HTTP", page.connection_ports_label.toolTip())
# TUN держит тот же прокси на loopback: адрес остаётся на панели
# (вписанный в Telegram прокси не обрывается при смене режима).
page.set_tun_mode(True)
page._do_refresh_dashboard()
self.assertFalse(page.connection_ports_label.isHidden())
self.assertIn("только программам этого компьютера", page.connection_ports_label.toolTip())
page.deleteLater()
QApplication.processEvents()
def test_add_to_telegram_opens_socks_link_with_effective_port(self) -> None:
page = DashboardPage()
page.set_settings_snapshot(AppSettings(tun_mode=False, proxy_engine="singbox"))
page.set_proxy_ports(1392, 1393)
page.set_connection(True)
page._do_refresh_dashboard()
self.assertFalse(page.proxy_address_row.isHidden())
self.assertFalse(page.telegram_proxy_btn.isHidden())
with patch("xray_fluent.ui.dashboard_page.QDesktopServices.openUrl", return_value=True) as open_url:
page.telegram_proxy_btn.click()
open_url.assert_called_once()
self.assertEqual(open_url.call_args.args[0].toString(), "tg://socks?server=127.0.0.1&port=1392")
page.set_tun_mode(True)
page._do_refresh_dashboard()
self.assertFalse(page.proxy_address_row.isHidden())
self.assertTrue(page.telegram_proxy_btn.isHidden())
page.deleteLater()
QApplication.processEvents()
def test_add_to_telegram_without_telegram_explains_manual_setup(self) -> None:
page = DashboardPage()
page.set_settings_snapshot(AppSettings(tun_mode=False, proxy_engine="singbox"))
page.set_proxy_ports(1390, 1391)
page.set_connection(True)
page._do_refresh_dashboard()
with (
patch("xray_fluent.ui.dashboard_page.QDesktopServices.openUrl", return_value=False),
patch("xray_fluent.ui.dashboard_page.InfoBar.warning") as warning,
):
page.telegram_proxy_btn.click()
warning.assert_called_once()
self.assertIn("порт 1390", warning.call_args.args[1])
page.deleteLater()
QApplication.processEvents()
def test_add_to_telegram_does_nothing_without_live_proxy(self) -> None:
page = DashboardPage()
page.set_settings_snapshot(AppSettings(tun_mode=False, proxy_engine="singbox"))
page._do_refresh_dashboard()
self.assertTrue(page.proxy_address_row.isHidden())
with patch("xray_fluent.ui.dashboard_page.QDesktopServices.openUrl") as open_url:
page._add_proxy_to_telegram()
open_url.assert_not_called()
page.deleteLater()
QApplication.processEvents()
class DashboardAdaptiveGridTest(unittest.TestCase):
"""AC10: routing card moves to row 1 below 900px, back at >= 900px;
the four traffic tiles fold into a 2×2 grid on the narrow layout."""
def test_routing_card_reflows_on_resize(self) -> None:
page = DashboardPage()
try:
page.resize(850, 600)
page.show()
QApplication.processEvents()
row, col, _rspan, cspan = _routing_card_position(page)
self.assertEqual((row, col), (1, 0), "narrow: routing card in second row")
self.assertEqual(cspan, 2, "narrow: routing card spans both columns")
stats = page._stats_grid
self.assertEqual(stats.getItemPosition(stats.indexOf(page.session_tile))[:2], (1, 1))
page.resize(1200, 700)
QApplication.processEvents()
row, col, _rspan, cspan = _routing_card_position(page)
self.assertEqual((row, col), (0, 1), "wide: routing card back in first row")
self.assertEqual(cspan, 1)
self.assertEqual(stats.getItemPosition(stats.indexOf(page.session_tile))[:2], (0, 3))
# Reflow must not recreate widgets: same processes card position.
grid = page._cards_grid
conn_row, conn_col, _r, _c = grid.getItemPosition(
grid.indexOf(page.processes_card)
)
self.assertEqual((conn_row, conn_col), (0, 0))
finally:
page.hide()
page.deleteLater()
QApplication.processEvents()
def test_resize_around_threshold_is_stable(self) -> None:
page = DashboardPage()
try:
page.resize(1000, 700)
page.show()
QApplication.processEvents()
self.assertEqual(_routing_card_position(page)[:2], (0, 1))
for width, expected in ((899, (1, 0)), (900, (0, 1)), (850, (1, 0)), (1200, (0, 1))):
page.resize(width, 700)
QApplication.processEvents()
self.assertEqual(
_routing_card_position(page)[:2], expected, f"width={width}"
)
finally:
page.hide()
page.deleteLater()
QApplication.processEvents()
class DashboardEffectivePortsWiringTest(unittest.TestCase):
def test_connection_event_forwards_active_session_ports(self) -> None:
dashboard = Mock()
controller = Mock()
controller.state.settings.tun_mode = False
controller.get_effective_proxy_ports.return_value = (1392, 1393)
window = SimpleNamespace(
dashboard_page=dashboard,
controller=controller,
tray_connect_action=None,
_deferred_dashboard_metrics=None,
_deferred_process_stats=None,
_has_deferred_process_stats=False,
_refresh_tray_tooltip=Mock(),
)
MainWindow._on_connection_changed(window, True)
dashboard.set_connection.assert_called_once_with(True)
dashboard.set_proxy_ports.assert_called_once_with(1392, 1393)
def test_tun_connection_advertises_its_loopback_proxy_ports(self) -> None:
"""С 16.10.2026 TUN держит локальный прокси на 127.0.0.1: панель его называет."""
dashboard = Mock()
controller = Mock()
controller.state.settings.tun_mode = True
controller.get_effective_proxy_ports.return_value = (1390, 1391)
window = SimpleNamespace(
dashboard_page=dashboard,
controller=controller,
tray_connect_action=None,
_deferred_dashboard_metrics=None,
_deferred_process_stats=None,
_has_deferred_process_stats=False,
_refresh_tray_tooltip=Mock(),
)
MainWindow._on_connection_changed(window, True)
dashboard.set_proxy_ports.assert_called_once_with(1390, 1391)
class DashboardActiveModeWiringTest(unittest.TestCase):
"""Плитки сверяют выбор с режимом сессии: он приходит раньше «занято»."""
def test_transition_state_pushes_session_mode_before_busy(self) -> None:
calls = Mock()
controller = Mock()
controller.active_tun_mode.return_value = True
window = SimpleNamespace(dashboard_page=calls, controller=controller, tray_connect_action=None)
MainWindow._on_transition_state_changed(window, False, "")
self.assertEqual(
[c[0] for c in calls.method_calls],
["set_active_tun_mode", "set_transition_busy"],
)
calls.set_active_tun_mode.assert_called_once_with(True)
def test_connection_event_pushes_session_mode(self) -> None:
dashboard = Mock()
controller = Mock()
controller.state.settings.tun_mode = True
controller.active_tun_mode.return_value = None
window = SimpleNamespace(
dashboard_page=dashboard,
controller=controller,
tray_connect_action=None,
_deferred_dashboard_metrics=None,
_deferred_process_stats=None,
_has_deferred_process_stats=False,
_refresh_tray_tooltip=Mock(),
)
MainWindow._on_connection_changed(window, False)
dashboard.set_active_tun_mode.assert_called_once_with(None)
def test_controller_reports_mode_of_running_session_only(self) -> None:
from xray_fluent.application.controller import AppController
session = SimpleNamespace(tun_mode=True)
self.assertTrue(AppController.active_tun_mode(SimpleNamespace(_active_session=session, connected=True)))
self.assertIsNone(AppController.active_tun_mode(SimpleNamespace(_active_session=session, connected=False)))
self.assertIsNone(AppController.active_tun_mode(SimpleNamespace(_active_session=None, connected=True)))
class DashboardDensityTests(unittest.TestCase):
"""Графика панели плавно следует окну, текст — только две плотности."""
def test_hero_scales_smoothly_within_bounds(self) -> None:
from xray_fluent.ui.dashboard_page import hero_sizes
sizes = [hero_sizes(h) for h in range(300, 1600, 50)]
orbs = [orb for orb, _pad, _graph in sizes]
self.assertEqual(orbs, sorted(orbs)) # монотонно растёт с окном
self.assertGreaterEqual(min(orbs), 96)
self.assertLessEqual(max(orbs), 156)
mid = hero_sizes(700)[0]
self.assertTrue(96 < mid < 156) # между пределами — плавно, не ступенькой
def test_page_applies_sizes_and_keeps_orb_inside_scene(self) -> None:
from xray_fluent.ui.dashboard_page import DashboardPage, hero_sizes
page = DashboardPage()
for height in (480, 700, 1100):
page._apply_hero_size(height)
orb, padding, graph = hero_sizes(height)
self.assertEqual(page.connection_orb.width(), orb)
self.assertEqual(page.connection_scene.height(), orb + padding)
self.assertEqual(page.traffic_graph.height(), graph)
page._apply_density(True)
page._apply_density(False)
# Страницу не удаляем: пакетный deleteLater страниц на Windows падает
# (см. память ui-theme-architecture) — держим до конца процесса.
_KEEP_ALIVE.append(page)
_KEEP_ALIVE: list = []
if __name__ == "__main__":
unittest.main()