Жалоба 15.10.2026: на «Тексте профиля» стрелка «назад» ничего не делала, а Alt+← работал. Причина: в сборке Nuitka отписка по методу (signal.disconnect(obj.method)) не находит подписку — метод там при каждом обращении новый объект compiled_method; ошибка глоталась. У стрелки оставались два обработчика: библиотечный qrouter.pop переключал страницу, журнал тут же возвращал её обратно. Из исходников всё работало, поэтому стенд не ловил. Проверено на win10 модулем, собранным Nuitka: отписка по методу — «is not connected», 2 обработчика; по квитанции — 1. - utils/qt_signal_links: подписка снимается по объекту соединения, который вернул connect() (disconnect_link, SignalLinks). - Стрелка «назад»: отвязываются все прежние обработчики, библиотечный список страниц выключен целиком; если обработчиков не один — ошибка в журнале. - Те же молчаливые отписки в сборке исправлены везде: всплывающие меню и выпадающие списки (обработчики копились с каждым меню), страницы оркестратора (каждая настройка добавляла по обработчику), Telegram Proxy, подписка Premium, проверка текста профиля, списки и анимация строк, подсказка стратегий. Карточки больше не получают вторую подписку на смену темы: библиотека её в конструкторе не создаёт. - architecture_checks запрещает отписку по обработчику. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
234 lines
9.5 KiB
Python
234 lines
9.5 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
import unittest
|
||
from unittest.mock import MagicMock, Mock, patch
|
||
|
||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||
|
||
from PyQt6.QtWidgets import QApplication, QWidget
|
||
|
||
from app.page_names import PageName
|
||
from telegram_proxy.config.upstream_catalog import UpstreamCatalog
|
||
from telegram_proxy.proxy.upstream_controller import UpstreamRuntimeSnapshot
|
||
|
||
|
||
def _feature(*, pending: bool = False, running: bool = True) -> MagicMock:
|
||
feature = MagicMock()
|
||
feature.has_pending_settings_saves.return_value = pending
|
||
feature.get_proxy_manager.return_value.upstream_state = None
|
||
feature.get_proxy_manager.return_value.is_running = running
|
||
return feature
|
||
|
||
|
||
def _fallback_snapshot() -> UpstreamRuntimeSnapshot:
|
||
return UpstreamRuntimeSnapshot(
|
||
selected_preset_id="nl",
|
||
selected_name="Нидерланды",
|
||
active_preset_id="ee",
|
||
active_name="Эстония",
|
||
state="fallback",
|
||
generation=1,
|
||
)
|
||
|
||
|
||
def _catalog() -> UpstreamCatalog:
|
||
return UpstreamCatalog(
|
||
[
|
||
{"id": "nl", "name": "Нидерланды", "type": "socks5", "host": "10.0.0.1", "port": 1080},
|
||
{"id": "ee", "name": "Эстония", "type": "socks5", "host": "10.0.0.2", "port": 1080},
|
||
]
|
||
)
|
||
|
||
|
||
class TelegramProxyAdvancedRouteTests(unittest.TestCase):
|
||
def test_route_is_hidden_nested_page_under_telegram_proxy(self) -> None:
|
||
from ui.navigation.schema import INNER_PAGE_NAMES, PAGE_CLEANUP_ORDER, get_page_spec
|
||
|
||
spec = get_page_spec(PageName.TELEGRAM_PROXY_ADVANCED)
|
||
|
||
self.assertEqual(spec.module_name, "telegram_proxy.ui.advanced_page")
|
||
self.assertEqual(spec.class_name, "TelegramProxyAdvancedPage")
|
||
self.assertFalse(spec.is_top_level)
|
||
self.assertTrue(spec.is_hidden)
|
||
self.assertIsNone(spec.sidebar_group)
|
||
self.assertEqual(spec.breadcrumb_parent, PageName.TELEGRAM_PROXY)
|
||
self.assertNotIn(PageName.TELEGRAM_PROXY_ADVANCED, INNER_PAGE_NAMES)
|
||
# Вложенная страница закрывается раньше основной.
|
||
self.assertLess(
|
||
PAGE_CLEANUP_ORDER.index(PageName.TELEGRAM_PROXY_ADVANCED),
|
||
PAGE_CLEANUP_ORDER.index(PageName.TELEGRAM_PROXY),
|
||
)
|
||
|
||
def test_page_deps_open_each_other(self) -> None:
|
||
from ui.page_composition import PAGE_DEPS_BUILDERS
|
||
from ui.page_deps.system import (
|
||
build_telegram_proxy_advanced_page_kwargs,
|
||
build_telegram_proxy_page_kwargs,
|
||
)
|
||
|
||
self.assertIn(PageName.TELEGRAM_PROXY_ADVANCED, PAGE_DEPS_BUILDERS)
|
||
show_page = Mock()
|
||
feature = Mock()
|
||
|
||
advanced_kwargs = build_telegram_proxy_advanced_page_kwargs(
|
||
page_name=PageName.TELEGRAM_PROXY_ADVANCED,
|
||
telegram_proxy_feature=feature,
|
||
show_page=show_page,
|
||
)
|
||
main_kwargs = build_telegram_proxy_page_kwargs(
|
||
page_name=PageName.TELEGRAM_PROXY,
|
||
runtime_feature=Mock(),
|
||
telegram_proxy_feature=feature,
|
||
show_page=show_page,
|
||
)
|
||
|
||
self.assertIs(advanced_kwargs["telegram_proxy_feature"], feature)
|
||
advanced_kwargs["open_telegram_proxy"]()
|
||
# Строка пути «Telegram Proxy» — шаг «наверх»: назад, если пришли оттуда.
|
||
show_page.assert_called_once_with(PageName.TELEGRAM_PROXY, up=True)
|
||
show_page.reset_mock()
|
||
main_kwargs["open_advanced_settings"]()
|
||
show_page.assert_called_once_with(PageName.TELEGRAM_PROXY_ADVANCED, allow_internal=True)
|
||
|
||
def test_not_in_search_index(self) -> None:
|
||
from app.search_index import SEARCH_ENTRIES
|
||
|
||
self.assertFalse(any(entry.page_name == PageName.TELEGRAM_PROXY_ADVANCED for entry in SEARCH_ENTRIES))
|
||
|
||
|
||
class TelegramProxyAdvancedPageTests(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls) -> None:
|
||
cls.app = QApplication.instance() or QApplication([])
|
||
|
||
def _page(self, feature=None, open_telegram_proxy=None):
|
||
from telegram_proxy.ui.advanced_page import TelegramProxyAdvancedPage
|
||
|
||
host = QWidget()
|
||
self.addCleanup(host.deleteLater)
|
||
return TelegramProxyAdvancedPage(
|
||
host,
|
||
telegram_proxy_feature=feature or _feature(),
|
||
open_telegram_proxy=open_telegram_proxy or (lambda: None),
|
||
)
|
||
|
||
def test_reload_waits_for_pending_saves(self) -> None:
|
||
feature = _feature(pending=True)
|
||
page = self._page(feature)
|
||
flushed_listener = feature.add_settings_flushed_listener.call_args.args[0]
|
||
|
||
page.on_page_activated()
|
||
|
||
feature.create_page_initial_state_worker.assert_not_called()
|
||
self.assertTrue(page._reload_after_flush)
|
||
|
||
feature.has_pending_settings_saves.return_value = False
|
||
flushed_listener("")
|
||
|
||
feature.create_page_initial_state_worker.assert_called_once()
|
||
self.assertFalse(page._reload_after_flush)
|
||
|
||
def test_result_arriving_while_saves_pending_is_dropped_and_rerequested(self) -> None:
|
||
feature = _feature()
|
||
page = self._page(feature)
|
||
page._apply_settings_state = Mock()
|
||
page._state_runtime.request_id = 5
|
||
feature.has_pending_settings_saves.return_value = True
|
||
|
||
page._on_state_loaded(5, Mock())
|
||
|
||
page._apply_settings_state.assert_not_called()
|
||
self.assertTrue(page._reload_after_flush)
|
||
|
||
def test_runtime_state_is_shown_in_server_row_description(self) -> None:
|
||
page = self._page()
|
||
page._apply_upstream_catalog(_catalog())
|
||
page._upstream_toggle.setChecked(True, block_signals=True)
|
||
page._apply_upstream_preset_ui(0)
|
||
|
||
page._on_upstream_state_changed(
|
||
UpstreamRuntimeSnapshot(
|
||
selected_preset_id="nl",
|
||
selected_name="Нидерланды",
|
||
active_preset_id="ee",
|
||
active_name="Эстония",
|
||
state="fallback",
|
||
generation=1,
|
||
)
|
||
)
|
||
|
||
self.assertEqual(
|
||
page._upstream_preset_row.contentLabel.text(),
|
||
"Выбрано: Нидерланды · Сейчас используется: Эстония (резерв)",
|
||
)
|
||
self.assertFalse(page._upstream_preset_row.isHidden())
|
||
|
||
def test_stale_runtime_state_is_not_shown(self) -> None:
|
||
feature = _feature(running=True)
|
||
page = self._page(feature)
|
||
page._apply_upstream_catalog(_catalog())
|
||
page._upstream_toggle.setChecked(True, block_signals=True)
|
||
page._apply_upstream_preset_ui(0)
|
||
page._on_upstream_state_changed(_fallback_snapshot())
|
||
default = "Выберите сервер из списка или переключитесь на ручной ввод"
|
||
|
||
# Пользователь выбрал другой сервер: старый «Сейчас используется» уже неверен.
|
||
page._upstream_preset_row.combo.setCurrentIndex(1)
|
||
self.assertEqual(page._upstream_preset_row.contentLabel.text(), default)
|
||
|
||
# Прокси остановлен: прошлый снимок не показываем.
|
||
page._on_upstream_state_changed(_fallback_snapshot())
|
||
feature.get_proxy_manager.return_value.is_running = False
|
||
page._refresh_upstream_preset_description()
|
||
self.assertEqual(page._upstream_preset_row.contentLabel.text(), default)
|
||
|
||
def test_missing_catalog_hint_is_server_row_description(self) -> None:
|
||
page = self._page()
|
||
page._apply_upstream_catalog(UpstreamCatalog())
|
||
page._upstream_toggle.setChecked(True, block_signals=True)
|
||
page._apply_upstream_preset_ui(0)
|
||
|
||
self.assertFalse(page._upstream_preset_row.isHidden())
|
||
self.assertIn("Доступен только ручной ввод", page._upstream_preset_row.contentLabel.text())
|
||
self.assertFalse(page._upstream_address_row.isHidden())
|
||
|
||
def test_edits_are_saved_through_facade(self) -> None:
|
||
feature = _feature()
|
||
page = self._page(feature)
|
||
|
||
page._pool_size_spin.setValue(6)
|
||
page._cloudflare_toggle._switch_button.setChecked(True) # как клик пользователя
|
||
|
||
feature.request_settings_save.assert_any_call("pool_size", value=6, restart="schedule")
|
||
feature.request_settings_save.assert_any_call("cloudflare_enabled", enabled=True, restart="now")
|
||
self.assertFalse(page._cloudflare_domains_row.isHidden())
|
||
|
||
def test_breadcrumb_root_returns_to_telegram_proxy(self) -> None:
|
||
open_telegram_proxy = Mock()
|
||
page = self._page(open_telegram_proxy=open_telegram_proxy)
|
||
|
||
page._breadcrumb.setCurrentItem("telegram_proxy")
|
||
|
||
open_telegram_proxy.assert_called_once_with()
|
||
self.assertEqual(len(page._breadcrumb.items), 2)
|
||
|
||
def test_cleanup_unsubscribes_without_stopping_proxy(self) -> None:
|
||
feature = _feature()
|
||
page = self._page(feature)
|
||
manager = feature.get_proxy_manager.return_value
|
||
|
||
link = manager.upstream_state_changed.connect.return_value
|
||
|
||
with patch("telegram_proxy.ui.advanced_page.disconnect_link") as disconnect_link:
|
||
page.cleanup()
|
||
|
||
feature.remove_settings_flushed_listener.assert_called_once_with(page._on_settings_flushed)
|
||
# Подписка снимается по квитанции connect(), а не по обработчику.
|
||
disconnect_link.assert_called_once_with(link)
|
||
manager.upstream_state_changed.disconnect.assert_not_called()
|
||
manager.cleanup.assert_not_called()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|