Some checks failed
Windows project source guards / test (push) Failing after 32s
Инцидент 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 (мёртвый) удалён.
397 lines
17 KiB
Python
397 lines
17 KiB
Python
"""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()
|