zapret-kvn/tests/test_app_nodes_page_view.py
loop-uh 9edbcf4c6b
Some checks failed
Windows project source guards / test (push) Failing after 32s
Смена сервера всегда называет источник: [select] в логе, «Следующий» без фокуса
Инцидент 28.09.2026: сервер сменился Tiraru → Titiru (мёртвый Hysteria), а в
логе был только [core-switch] без причины — явное действие (кнопка
«Следующий», трей, таблица) было неотличимо от сбоя программы.

- SelectionSource (application/selection_source.py): кто сменил сервер и
  ручной ли это выбор. set_selected_node/switch_next_node принимают
  обязательный source= вместо reset_auto_switch; ручной источник сбрасывает
  учёт авто-переключения, автоматика (auto/smart/rotation) — нет.
- Каждая смена пишет одну строку
  [select] source=<код> (<описание>): «было» node_ref=… → «стало» node_ref=…
  node_ref совпадает с [runtime-map] и строками ядер. Покрыты и неявные
  смены: импорт, удаление, скрытие, подписка, восстановление Hysteria.
- UI передаёт жест: двойной клик/Enter/меню таблицы, трей, главная;
  сигнал NodesPage.selected_node_changed → connect_node_requested(id, source).
- Кнопка «Следующий» на главной не берёт фокус клавиатуры: пробел/Enter
  «мимо» больше не меняют сервер.
- tests/test_selection_source.py: AST-инвариант — каждый вызов команды смены
  передаёт source=, а .selected_node_id присваивают только функции из
  белого списка, которые пишут [select]. switch_prev_node (мёртвый) удалён.
2026-09-28 21:35:12 +03:00

397 lines
17 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.

"""NodesPage view tests (AC8 compact layout, AC11 apply_view_settings).
NOTE on the module name: unittest discover imports test modules in
alphabetical order, and ``tests/test_engine_process_stop.py`` (and others)
create a bare ``QCoreApplication`` at import time, which cannot host
widgets and cannot be upgraded in-process. This module must therefore be
imported *first* among app-creating modules so it can install a full
``QApplication`` (offscreen); being a ``QCoreApplication`` subclass, it
satisfies every later ``QCoreApplication.instance() or ...`` bootstrap.
Keep the ``test_app_*`` prefix so it sorts before ``test_engine_*``.
"""
import os
import unittest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtCore import Qt
from PyQt6.QtTest import QTest
from PyQt6.QtWidgets import QApplication, QHeaderView
from xray_fluent.application.selection_source import SelectionSource
from xray_fluent.profiles.models import AppSettings, Node
from xray_fluent.ui.nodes_page import _COLUMN_WIDTHS, _FLAG_ICON_SIZE, _ROW_HEIGHT, NodesPage
from xray_fluent.ui.nodes_table_model import (
COL_ADDRESS,
COL_NAME,
COL_PING,
COL_SPEED,
COL_TYPE,
COLUMN_BY_KEY,
COLUMN_KEYS,
DEFAULT_VISIBLE_COLUMNS,
GROUP_KEY_ROLE,
NODE_ID_ROLE,
SORT_KEYS,
)
from xray_fluent.ui.nodes_view import NodesDelegate
_existing = QApplication.instance()
if _existing is not None and not isinstance(_existing, QApplication):
raise RuntimeError(
"A bare QCoreApplication was created before test_app_nodes_page_view "
"was imported; widget tests need a QApplication. Keep this module "
"alphabetically before any QCoreApplication-creating test module."
)
_APP = _existing or QApplication([])
def _node_order(page: NodesPage) -> list[str]:
"""Серверы после фильтра в порядке отображения."""
return page._table_model.visible_node_ids()
# Одна страница на модуль: пачка deleteLater полных страниц в одном процессе
# роняет Windows-прогон (access violation в деструкторах Qt). Каждый тест
# начинает с одинакового состояния через _reset_page().
_shared_page: NodesPage | None = None
# Узкое окно: сумма ширин видимых колонок по умолчанию больше viewport, так
# что flex-раскладке нечего раздавать соседям (как у нераскрытой страницы).
_NARROW_SIZE = (500, 400)
def _stop_timers(page: NodesPage) -> None:
for timer in (page._column_layout_timer, page._viewport_layout_timer, page._search_timer,
page._ping_batch_timer, page._speed_progress_timer, page._table_model._relayout_timer):
timer.stop()
def _reset_page() -> NodesPage:
global _shared_page
if _shared_page is None:
_shared_page = NodesPage()
page = _shared_page
_stop_timers(page)
page.search_edit.clear()
page.search_edit.hide()
page.set_subscriptions([])
page.apply_view_settings(AppSettings())
page._clear_filters()
page.table.clearSelection()
page.set_active_node(None)
page._table_model.set_endpoints_revealed(False)
page.set_nodes([])
page.resize(*_NARROW_SIZE)
page.show()
_APP.processEvents()
_stop_timers(page)
# Раскладка колонок по узкому viewport без отложенного таймера.
page.apply_view_settings(AppSettings())
_stop_timers(page)
return page
class NodesPageViewTestCase(unittest.TestCase):
def setUp(self) -> None:
self.page = _reset_page()
def tearDown(self) -> None:
_stop_timers(self.page)
def connect(self, signal, slot) -> None:
signal.connect(slot)
self.addCleanup(signal.disconnect, slot)
class NodesPageCompactLayoutTests(NodesPageViewTestCase):
"""AC8: compact table — 30px rows, ~18x13 flag icon, default visible columns."""
def test_row_height_constant_is_28(self) -> None:
self.assertEqual(_ROW_HEIGHT, 28)
def test_table_rows_are_28px(self) -> None:
self.page.set_nodes([Node(name="A")])
model = self.page._table_model
self.assertEqual(model.rowCount(), 2) # заголовок группы + сервер
for row in range(model.rowCount()):
self.assertEqual(model.index(row, 0).data(Qt.ItemDataRole.SizeHintRole).height(), 28)
self.assertEqual(self.page.table.rowHeight(row), 28)
def test_flag_icon_size_is_18x13(self) -> None:
self.assertEqual((_FLAG_ICON_SIZE.width(), _FLAG_ICON_SIZE.height()), (18, 13))
icon_size = self.page.table.iconSize()
self.assertEqual((icon_size.width(), icon_size.height()), (18, 13))
def test_default_visible_columns_include_type_and_masked_address(self) -> None:
expected = ["name", "type", "ping", "speed"]
self.assertEqual(list(DEFAULT_VISIBLE_COLUMNS), expected)
self.assertEqual(self.page.visible_column_keys(), expected)
def test_columns_are_movable_and_user_resizable(self) -> None:
header = self.page.table.horizontalHeader()
self.assertTrue(header.sectionsMovable())
# AC1: every column, including the "name" flex column, is Interactive;
# ResizeMode.Stretch is not used anywhere.
for col in range(header.count()):
self.assertEqual(
header.sectionResizeMode(col),
QHeaderView.ResizeMode.Interactive,
f"column {COLUMN_KEYS[col]} must be Interactive",
)
def test_type_column_has_compact_default_width(self) -> None:
self.assertEqual(_COLUMN_WIDTHS[COL_TYPE], 110)
def test_type_column_is_clamped_to_compact_interactive_range(self) -> None:
# AC7: the live drag is clamped only by the generous sanity bounds
# (type: 44..200); values inside the range are never rolled back.
header = self.page.table.horizontalHeader()
header.resizeSection(COL_TYPE, 150)
self.assertEqual(header.sectionSize(COL_TYPE), 150)
header.resizeSection(COL_TYPE, 300)
self.assertEqual(header.sectionSize(COL_TYPE), 200)
header.resizeSection(COL_TYPE, 20)
self.assertEqual(header.sectionSize(COL_TYPE), 90)
def test_reveal_button_masks_again_after_mouse_and_keyboard_release(self) -> None:
self.page.set_nodes(
[Node(id="secret", server="secret.example", port=8443, scheme="vless")]
)
model = self.page._table_model
index = model.index(model.row_of_node("secret"), COL_ADDRESS)
button = self.page.reveal_addresses_btn
QTest.mousePress(button, Qt.MouseButton.LeftButton)
self.assertEqual(index.data(), "secret.example:8443")
QTest.mouseRelease(button, Qt.MouseButton.LeftButton)
self.assertEqual(index.data(), "********")
button.setFocus()
QTest.keyPress(button, Qt.Key.Key_Space)
self.assertEqual(index.data(), "secret.example:8443")
QTest.keyRelease(button, Qt.Key.Key_Space)
self.assertEqual(index.data(), "********")
class NodesPageApplyViewSettingsTests(NodesPageViewTestCase):
"""AC11: apply_view_settings round-trip and view_prefs_changed emissions."""
def setUp(self) -> None:
super().setUp()
self.prefs: list[dict] = []
self.connect(self.page.view_prefs_changed, self.prefs.append)
self.nodes = [
Node(id="a", name="Alpha", group="EU", tags=["fast"], ping_ms=50, sort_order=0),
Node(id="b", name="Bravo", group="EU", tags=["fast"], ping_ms=200, sort_order=1),
Node(id="c", name="Charlie", group="EU", tags=["fast"], ping_ms=None, sort_order=2),
Node(id="d", name="Delta", group="US", tags=["slow"], ping_ms=500, sort_order=3),
]
def test_apply_view_settings_round_trip_without_pref_emissions(self) -> None:
settings = AppSettings(
nodes_sort_key="ping",
nodes_sort_desc=True,
nodes_group_filter="EU",
nodes_tag_filter="fast",
nodes_visible_columns=["name", "address", "ping"],
nodes_column_widths={"type": 110, "address": 240},
nodes_column_order=["name", "address", "type", "ping", "speed"],
)
self.page.apply_view_settings(settings)
self.assertEqual(self.prefs, [], "apply_view_settings must not emit view_prefs_changed")
# Group/tag filters are deferred until set_nodes populates the combos.
self.page.set_nodes(self.nodes)
self.assertEqual(self.prefs, [], "applying deferred filters must not emit view_prefs_changed")
self.assertEqual(self.page.group_filter.currentText(), "EU")
self.assertEqual(self.page.tag_filter.currentText(), "fast")
self.assertEqual(self.page.visible_column_keys(), ["name", "address", "ping"])
self.assertEqual(self.page.column_widths()["type"], 110)
self.assertEqual(self.page.column_widths()["address"], 240)
self.assertEqual(self.page.column_order()[:3], ["name", "address", "type"])
# Sort key "ping" descending is applied to the model; "d" is filtered
# out (group US), None-ping stays last even in descending order.
self.assertEqual(_node_order(self.page), ["b", "a", "c"])
# Reading the preferences back reproduces exactly what was applied.
self.page._emit_view_prefs()
self.assertEqual(len(self.prefs), 1)
self.assertEqual(
self.prefs[0],
{
"nodes_sort_key": "ping",
"nodes_sort_desc": True,
"nodes_group_filter": "EU",
"nodes_tag_filter": "fast",
"nodes_source_filter": "",
"nodes_visible_columns": ["name", "address", "ping"],
"nodes_column_widths": self.page.column_widths(),
"nodes_column_order": self.page.column_order(),
"nodes_column_layout_version": 2,
"nodes_group_by": "source",
"nodes_collapsed_groups": [],
"nodes_favorites_only": False,
},
)
def test_legacy_visibility_is_migrated_to_type_and_address(self) -> None:
settings = AppSettings(
nodes_visible_columns=["name", "ping", "speed"],
nodes_column_layout_version=0,
)
self.page.apply_view_settings(settings)
self.assertEqual(
self.page.visible_column_keys(),
["name", "type", "ping", "speed"],
)
def test_user_sort_change_emits_view_prefs(self) -> None:
self.page.set_nodes(self.nodes)
self.assertEqual(self.prefs, [])
# User picks the "ping" sort key in the combo box.
self.page.sort_combo.setCurrentIndex(SORT_KEYS.index("ping"))
self.assertEqual(len(self.prefs), 1)
self.assertEqual(self.prefs[0]["nodes_sort_key"], "ping")
self.assertFalse(self.prefs[0]["nodes_sort_desc"])
self.assertEqual(_node_order(self.page), ["a", "b", "d", "c"])
# User toggles the sort direction.
self.page.sort_order_btn.click()
self.assertEqual(len(self.prefs), 2)
self.assertEqual(self.prefs[1]["nodes_sort_key"], "ping")
self.assertTrue(self.prefs[1]["nodes_sort_desc"])
self.assertEqual(_node_order(self.page), ["d", "b", "a", "c"])
class NodesPageColumnLayoutTests(NodesPageViewTestCase):
def test_resize_preserves_all_column_widths(self):
self.page.resize(1000, 600)
self.page.show()
_APP.processEvents()
before = self.page.column_widths()
self.page.resize(1800, 800)
# Width redistribution is coalesced into the next frame.
QTest.qWait(50)
_APP.processEvents()
self.assertEqual(before, self.page.column_widths())
self.assertEqual(self.page.table.horizontalHeader().sectionSize(COL_NAME), 360)
header = self.page.table.horizontalHeader()
used = sum(header.sectionSize(i) for i in range(header.count()) if not header.isSectionHidden(i))
self.assertEqual(used, self.page.table.viewport().width())
def test_refresh_and_selection_do_not_request_a_server_switch(self):
# Выбор ≠ подключение: ни обновление списка, ни программное
# выделение не переключают сервер.
requests = []
self.connect(self.page.connect_node_requested, lambda node_id, source: requests.append((node_id, source)))
for _ in range(3):
self.page.set_nodes([Node(id='one'), Node(id='two')], 'one')
self.assertEqual(self.page._selected_ids(), {'one'})
self.assertEqual(requests, [])
self.page._select_node('two')
self.assertEqual(self.page._selected_ids(), {'two'})
self.assertEqual(requests, [])
self.page._connect_node('two', SelectionSource.NODES_MENU)
self.assertEqual(requests, [('two', SelectionSource.NODES_MENU)])
def test_find_shortcut_shows_search_and_escape_clears_it(self):
self.page.show()
self.page.activateWindow()
self.page.table.setFocus()
_APP.processEvents()
self.assertTrue(self.page.search_edit.isHidden())
QTest.keyClick(self.page.table, Qt.Key.Key_F, Qt.KeyboardModifier.ControlModifier)
self.assertFalse(self.page.search_edit.isHidden())
self.page.search_edit.setText('missing')
QTest.keyClick(self.page.search_edit, Qt.Key.Key_Escape)
self.assertTrue(self.page.search_edit.isHidden())
self.assertEqual(self.page.search_edit.text(), '')
def test_name_drag_does_not_resize_neighbors_and_is_persisted(self):
header = self.page.table.horizontalHeader()
others = [header.sectionSize(c) for c in (COL_TYPE, COL_PING, COL_SPEED)]
header.resizeSection(COL_NAME, 500)
self.assertEqual(self.page.column_widths()["name"], 500)
self.assertEqual(others, [header.sectionSize(c) for c in (COL_TYPE, COL_PING, COL_SPEED)])
header.resizeSection(COL_NAME, 5000)
self.assertEqual(header.sectionSize(COL_NAME), 640)
def test_programmatic_resize_does_not_save_preferences(self):
events = []
self.connect(self.page.view_prefs_changed, events.append)
self.page.resize(1600, 900)
self.page._relayout_flex_column()
self.assertEqual(events, [])
class NodesActiveRowFillSeamTests(NodesPageViewTestCase):
"""The translucent active-row fill must not double up at column edges."""
def test_fill_has_no_alpha_doubling_at_column_boundaries(self) -> None:
nodes = [
Node(id=f"n{i}", name=f"Node {i}", server=f"s{i}.example", port=443, scheme="vless")
for i in range(6)
]
self.page.set_nodes(nodes)
self.page._table_model.set_active_node_id("n2")
self.page.resize(1000, 400)
self.page.show()
_APP.processEvents()
table = self.page.table
model = self.page._table_model
rect = table.visualRect(model.index(model.row_of_node("n2"), 0))
image = table.viewport().grab().toImage()
header = table.horizontalHeader()
boundaries = [
header.sectionPosition(col)
for col in range(header.count())
if not header.isSectionHidden(col)
and 0 < header.sectionPosition(col) < image.width() - 8
]
self.assertTrue(boundaries)
# Scan just inside the fill's top edge, above flag icons and glyphs.
y = rect.top() + 4
reference = image.pixelColor(boundaries[0] // 2, y)
for boundary in boundaries:
for x in range(boundary - 6, boundary + 7):
color = image.pixelColor(x, y)
deviation = max(
abs(color.red() - reference.red()),
abs(color.green() - reference.green()),
abs(color.blue() - reference.blue()),
)
self.assertLessEqual(
deviation,
3,
f"seam at x={x} near boundary {boundary}: "
f"{color.getRgb()} vs {reference.getRgb()}",
)
class NodesDelegateTests(NodesPageViewTestCase):
def test_rows_are_painted_by_nodes_delegate_without_bold_servers(self):
self.page.set_nodes([Node(id="a", name="A")])
self.page.set_active_node("a")
self.assertIsInstance(self.page.table.itemDelegate(), NodesDelegate)
model = self.page._table_model
# Заголовок группы — жирный; активный сервер выделяется заливкой
# акцентом в делегате, а не жирным шрифтом.
header = model.index(0, 0)
self.assertEqual(header.data(GROUP_KEY_ROLE), "source:local")
self.assertTrue(header.data(Qt.ItemDataRole.FontRole).bold())
node_index = model.index(model.row_of_node("a"), 0)
self.assertIsNone(node_index.data(Qt.ItemDataRole.FontRole))
self.assertEqual(model.active_node_id(), "a")
if __name__ == "__main__":
unittest.main()