Some checks failed
Windows project source guards / test (push) Has been cancelled
Пользователь вписал прокси 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>
358 lines
15 KiB
Python
358 lines
15 KiB
Python
"""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()
|