zapretgui/tests/test_preset_sidebar_navigation.py
loop-uh 8bfc2dd3a5
All checks were successful
Zapret source guards / validate (push) Successful in 9s
Windows-сборка / Dev Dev 21.1.7.85 опубликован
Страницы собираются блоками: сначала видимое, остальное позже; появление блока рисуется одной картинкой и стоит процессору вдвое меньше
Владелец попросил (2026-10-08): то, чего не видно, не строить и не рисовать
сразу, а достраивать позже; везде лёгкое появление; главное — общее ядро.

Ядро:
- ui/block_build.py: блоки страницы (LazyBlock) и наполнение списков
  (DeferredFill). Блок первого экрана собирается при показе, без таймеров;
  блок, до которого долистали, — сразу; остальные — по одному в паузах, пока
  молчит фоновая дорожка запуска. Место под несобранное занято заранее;
- BasePage.add_lazy_block / ensure_block / ensure_all_blocks; команды
  страницам, экскурсия и шаг «назад» достраивают страницу сами;
- появление (ui/widgets/stagger_float_in.py) переписано: видимая часть блока
  снимается в картинку один раз, настоящий блок на время полёта спрятан
  маской, один слой на контейнер рисует картинки по общему такту кадров,
  точка в точку экрана. Вид и длительности те же;
- меньше обращений к Qt при сборке: тексты стилей qfluentwidgets
  запоминаются (ui/qfluent_style_cache.py); у виджета со стилем и у привязки
  темы больше нет своих подключений сигналов; надпись подключает «своё меню»,
  только когда его включили; сборка страницы по щелчку ждёт своей очереди с
  фоновой дорожкой запуска, как сборка про запас.

Страницы:
- главная (Zapret 1 и 2): три группы настроек и «Последнее сообщение» —
  четыре блока ниже первого экрана; строители групп разделены;
- сетка карточек итога (BlockCheck, «Проверка домена») создаёт карточки
  порциями по 12: видимые сразу, остальные при прокрутке или в паузе;
- hosts: плитки сервисов выплывают и тогда, когда пришли на открытую страницу;
- полоса ожидания на главной и в проверке домена не крутится скрытой.

Замер на win10, поток окна за 1,3 с после показа страницы (масштаб 100% / 150%):
DNS 269→103 / 271→115 мс, Telegram Proxy 146→62 / 203→93, «Оформление»
145→92 / 274→93, «О программе» 134→58 / 157→77, главная 114→91 / 164→124.
Сборка главной 56→20 мс. Открытие главной рядом с занятым фоновым потоком
(стенд на хосте) 966→282 мс. Тесты ядра прошли и на Windows.

Прежний запрет откладывать настройки главной (test_zapret2_control_lazy_startup)
снят этим решением владельца: прошлые попытки откладывали таймерами и видимое
тоже; теперь первый экран собирается сразу, а откладывается только то, что
ниже края окна.

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

1793 lines
70 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.

from __future__ import annotations
import inspect
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
PROJECT_SRC = Path(__file__).resolve().parents[1] / "src"
if str(PROJECT_SRC) not in sys.path:
sys.path.insert(0, str(PROJECT_SRC))
class PresetSidebarNavigationTests(unittest.TestCase):
def test_winws2_preset_pages_are_visible_under_zapret_settings_header(self) -> None:
from app.page_names import PageName
from settings.mode import ZAPRET2_MODE
from ui.navigation.schema import get_page_spec, get_sidebar_pages_for_method
root_pages = get_sidebar_pages_for_method(ZAPRET2_MODE, sidebar_group="root")
settings_pages = get_sidebar_pages_for_method(ZAPRET2_MODE, sidebar_group="settings")
self.assertEqual(
root_pages[:1],
(PageName.ZAPRET2_MODE_CONTROL,),
)
self.assertEqual(
settings_pages[:3],
(
PageName.ZAPRET2_USER_PRESETS,
PageName.ZAPRET2_PRESET_SETUP,
PageName.DPI_SETTINGS,
),
)
for page_name in (
PageName.ZAPRET2_USER_PRESETS,
PageName.ZAPRET2_PRESET_SETUP,
):
spec = get_page_spec(page_name)
self.assertTrue(spec.is_top_level)
self.assertFalse(spec.is_hidden)
self.assertEqual(spec.sidebar_group, "settings")
def test_winws1_preset_pages_are_visible_under_zapret_settings_header(self) -> None:
from app.page_names import PageName
from settings.mode import ZAPRET1_MODE
from ui.navigation.schema import get_page_spec, get_sidebar_pages_for_method
root_pages = get_sidebar_pages_for_method(ZAPRET1_MODE, sidebar_group="root")
settings_pages = get_sidebar_pages_for_method(ZAPRET1_MODE, sidebar_group="settings")
self.assertEqual(
root_pages[:1],
(PageName.ZAPRET1_MODE_CONTROL,),
)
self.assertEqual(
settings_pages[:3],
(
PageName.ZAPRET1_USER_PRESETS,
PageName.ZAPRET1_PRESET_SETUP,
PageName.DPI_SETTINGS,
),
)
for page_name in (
PageName.ZAPRET1_USER_PRESETS,
PageName.ZAPRET1_PRESET_SETUP,
):
spec = get_page_spec(page_name)
self.assertTrue(spec.is_top_level)
self.assertFalse(spec.is_hidden)
self.assertEqual(spec.sidebar_group, "settings")
def test_only_real_nested_preset_pages_stay_internal(self) -> None:
from app.page_names import PageName
from ui.navigation.schema import INNER_PAGE_NAMES, get_page_spec
self.assertEqual(
INNER_PAGE_NAMES,
frozenset(
{
PageName.ZAPRET2_PROFILE_SETUP,
PageName.ZAPRET2_PROFILE_ORDER,
PageName.ZAPRET2_PRESET_RAW_EDITOR,
PageName.ZAPRET1_PROFILE_SETUP,
PageName.ZAPRET1_PROFILE_ORDER,
PageName.ZAPRET1_PRESET_RAW_EDITOR,
}
),
)
for page_name in INNER_PAGE_NAMES:
spec = get_page_spec(page_name)
self.assertFalse(spec.is_top_level)
self.assertTrue(spec.is_hidden)
def test_profile_order_pages_are_hidden_children_of_preset_setup(self) -> None:
from app.page_names import PageName
from ui.navigation.schema import get_page_spec
winws2_spec = get_page_spec(PageName.ZAPRET2_PROFILE_ORDER)
winws1_spec = get_page_spec(PageName.ZAPRET1_PROFILE_ORDER)
self.assertEqual(winws2_spec.module_name, "profile.ui.profile_order_page")
self.assertEqual(winws2_spec.class_name, "Zapret2ProfileOrderPage")
self.assertEqual(winws2_spec.breadcrumb_parent, PageName.ZAPRET2_PRESET_SETUP)
self.assertFalse(winws2_spec.is_top_level)
self.assertTrue(winws2_spec.is_hidden)
self.assertEqual(winws1_spec.module_name, "profile.ui.profile_order_page")
self.assertEqual(winws1_spec.class_name, "Zapret1ProfileOrderPage")
self.assertEqual(winws1_spec.breadcrumb_parent, PageName.ZAPRET1_PRESET_SETUP)
self.assertFalse(winws1_spec.is_top_level)
self.assertTrue(winws1_spec.is_hidden)
def test_preset_setup_page_deps_open_profile_order_page(self) -> None:
import ui.page_deps.presets as preset_deps
import ui.page_composition as page_composition
deps_source = inspect.getsource(preset_deps.build_preset_setup_page_kwargs)
composition_source = inspect.getsource(page_composition)
self.assertIn("open_profile_order", deps_source)
self.assertIn("resolve_profile_order_page_for_method", deps_source)
self.assertIn("build_profile_order_page_kwargs", composition_source)
def test_control_pages_do_not_build_preset_navigation_cards(self) -> None:
from presets.ui.control.zapret1.page import Zapret1ModeControlPage
from presets.ui.control.zapret2.page import Zapret2ModeControlPage
combined_source = "\n".join(
(
inspect.getsource(Zapret1ModeControlPage._build_ui),
inspect.getsource(Zapret1ModeControlPage._build_program_settings_block),
inspect.getsource(Zapret1ModeControlPage._build_windows_settings_block),
inspect.getsource(Zapret1ModeControlPage._build_fine_tuning_block),
inspect.getsource(Zapret2ModeControlPage._build_ui),
inspect.getsource(Zapret2ModeControlPage._build_program_settings_block),
inspect.getsource(Zapret2ModeControlPage._build_windows_settings_block),
inspect.getsource(Zapret2ModeControlPage._build_fine_tuning_block),
)
)
forbidden_fragments = (
"build_winws1_presets_section",
"build_winws2_presets_section",
"preset_card",
"presets_btn",
"preset_setup_card",
"preset_setup_open_btn",
)
for fragment in forbidden_fragments:
self.assertNotIn(fragment, combined_source)
def test_sidebar_preset_pages_do_not_build_breadcrumbs(self) -> None:
import profile.ui.preset_setup_page as preset_setup_page
import presets.ui.common.user_presets_page as common_user_presets_page
import presets.ui.zapret1.user_presets_page as zapret1_user_presets_page
import presets.ui.zapret2.user_presets_page as zapret2_user_presets_page
combined_source = "\n".join(
(
inspect.getsource(preset_setup_page.PresetSetupPageBase.__init__),
inspect.getsource(common_user_presets_page.UserPresetsPageBase.__init__),
)
)
self.assertNotIn("BreadcrumbBar", combined_source)
self.assertNotIn("_rebuild_breadcrumb", combined_source)
self.assertTrue(
issubclass(
zapret1_user_presets_page.Zapret1UserPresetsPage,
common_user_presets_page.UserPresetsPageBase,
)
)
self.assertTrue(
issubclass(
zapret2_user_presets_page.Zapret2UserPresetsPage,
common_user_presets_page.UserPresetsPageBase,
)
)
def test_user_presets_pages_use_one_common_implementation(self) -> None:
from presets.ui.common.user_presets_page import UserPresetsPageBase
from presets.ui.zapret1.user_presets_page import Zapret1UserPresetsPage
from presets.ui.zapret2.user_presets_page import Zapret2UserPresetsPage
self.assertIs(Zapret1UserPresetsPage.__mro__[1], UserPresetsPageBase)
self.assertIs(Zapret2UserPresetsPage.__mro__[1], UserPresetsPageBase)
self.assertNotIn(
"build_user_presets_page_shell",
inspect.getsource(Zapret1UserPresetsPage),
)
self.assertNotIn(
"build_user_presets_page_shell",
inspect.getsource(Zapret2UserPresetsPage),
)
def test_mode_switch_keeps_equivalent_preset_sidebar_page(self) -> None:
from app.page_names import PageName
from settings.mode import ZAPRET1_MODE, ZAPRET2_MODE
from ui.workflows.mode import resolve_mode_context_page_for_method
self.assertEqual(
resolve_mode_context_page_for_method(PageName.ZAPRET2_USER_PRESETS, ZAPRET1_MODE),
PageName.ZAPRET1_USER_PRESETS,
)
self.assertEqual(
resolve_mode_context_page_for_method(PageName.ZAPRET1_USER_PRESETS, ZAPRET2_MODE),
PageName.ZAPRET2_USER_PRESETS,
)
self.assertEqual(
resolve_mode_context_page_for_method(PageName.ZAPRET2_PRESET_SETUP, ZAPRET1_MODE),
PageName.ZAPRET1_PRESET_SETUP,
)
self.assertEqual(
resolve_mode_context_page_for_method(PageName.ZAPRET1_MODE_CONTROL, ZAPRET2_MODE),
PageName.ZAPRET2_MODE_CONTROL,
)
def test_sidebar_plan_keeps_other_mode_items_created_but_hidden(self) -> None:
from app.page_names import PageName
from settings.mode import ZAPRET2_MODE
from ui.navigation.layout_plan import build_sidebar_group_plans
from ui.navigation.schema import get_nav_visibility
root_plan = next(
group_plan
for group_plan in build_sidebar_group_plans(ZAPRET2_MODE)
if group_plan.group_name == "root"
)
settings_plan = next(
group_plan
for group_plan in build_sidebar_group_plans(ZAPRET2_MODE)
if group_plan.group_name == "settings"
)
visibility = get_nav_visibility(ZAPRET2_MODE)
self.assertEqual(
root_plan.page_names[:1],
(PageName.ZAPRET2_MODE_CONTROL,),
)
self.assertEqual(
settings_plan.page_names[:3],
(
PageName.ZAPRET2_USER_PRESETS,
PageName.ZAPRET2_PRESET_SETUP,
PageName.DPI_SETTINGS,
),
)
self.assertIn(PageName.ZAPRET1_MODE_CONTROL, root_plan.page_names)
self.assertFalse(visibility[PageName.ZAPRET1_MODE_CONTROL])
for page_name in (PageName.ZAPRET1_USER_PRESETS, PageName.ZAPRET1_PRESET_SETUP):
self.assertIn(page_name, settings_plan.page_names)
self.assertFalse(visibility[page_name])
def test_common_sidebar_groups_keep_tools_and_diagnostics_separate(self) -> None:
from app.page_names import PageName
from settings.mode import ZAPRET2_MODE
from ui.navigation.layout_plan import build_sidebar_group_plans
plans = {
group_plan.group_name: group_plan
for group_plan in build_sidebar_group_plans(ZAPRET2_MODE)
}
self.assertEqual(
plans["system"].page_names,
(
PageName.NETWORK,
PageName.HOSTS,
PageName.TELEGRAM_PROXY,
),
)
self.assertEqual(
plans["diagnostics"].page_names,
(
PageName.BLOCKCHECK,
PageName.WINWS_LOG_ANALYZER,
),
)
def test_common_sidebar_labels_use_dns_and_hosts_wording(self) -> None:
from app.page_names import PageName
from app.ui_texts import get_nav_page_label, tr
self.assertEqual(tr("nav.header.system", language="ru"), "Инструменты")
self.assertEqual(get_nav_page_label(PageName.NETWORK, language="ru"), "Настройка DNS")
self.assertEqual(get_nav_page_label(PageName.HOSTS, language="ru"), "Редактор hosts")
self.assertEqual(
tr("page.network.subtitle", language="ru"),
"Выберите DNS-сервер — он сразу встанет на отмеченные сетевые адаптеры. "
"Кнопка «Замерить скорость» покажет, какой сервер отвечает быстрее.",
)
def test_initial_sidebar_build_adds_every_visible_group_and_skips_other_mode_items(self) -> None:
from app.page_names import PageName
from settings.mode import ZAPRET2_MODE
import ui.navigation.sidebar_builder as sidebar_builder
class FakeSignal:
def __init__(self) -> None:
self.connected = []
def connect(self, callback) -> None:
self.connected.append(callback)
class FakeNavigationInterface:
def __init__(self) -> None:
self.headers = []
self.displayModeChanged = FakeSignal()
def addItemHeader(self, text, position):
header = SimpleNamespace(text=text, position=position)
self.headers.append(header)
return header
def setMinimumExpandWidth(self, width) -> None:
self.minimum_expand_width = width
added_pages: list[PageName] = []
session = SimpleNamespace(
nav_scroll_position=None,
ui_language="ru",
sidebar_search_widget_cls=None,
nav_items={},
nav_headers=[],
nav_header_by_group={},
nav_mode_visibility={},
nav_search_query="",
sidebar_search_nav_widget=None,
sidebar_search_model=None,
sidebar_search_completer=None,
sidebar_search_titlebar_attached=False,
pages={},
nav_labels={},
)
window = SimpleNamespace(
ui_session=session,
navigationInterface=FakeNavigationInterface(),
get_launch_method=lambda: ZAPRET2_MODE,
)
with (
patch.object(
sidebar_builder,
"add_nav_item",
side_effect=lambda current_window, page_name, *_args, **_kwargs: added_pages.append(page_name),
),
patch("settings.store.get_ui_state_settings", return_value={"sidebar_expanded": True}),
):
sidebar_builder.init_navigation(window)
# Всё, что пользователь видит в меню, готово к первому показу окна.
for page_name in (
PageName.ZAPRET2_MODE_CONTROL,
PageName.ZAPRET2_USER_PRESETS,
PageName.ZAPRET2_PRESET_SETUP,
PageName.NETWORK,
PageName.LOGS,
):
self.assertIn(page_name, added_pages)
# Пункты другого режима не создаются: их добавит sync_nav_visibility
# при смене режима.
for page_name in (
PageName.ZAPRET1_MODE_CONTROL,
PageName.ZAPRET1_USER_PRESETS,
PageName.ZAPRET1_PRESET_SETUP,
):
self.assertNotIn(page_name, added_pages)
self.assertEqual(len(added_pages), len(set(added_pages)))
def test_initial_sidebar_build_leaves_nothing_for_after_first_paint(self) -> None:
"""Меню и поиск не достраиваются на глазах после показа окна."""
from app.page_names import PageName
from settings.mode import ZAPRET2_MODE
import ui.navigation.sidebar_builder as sidebar_builder
class FakeSignal:
def __init__(self) -> None:
self.connected = []
def connect(self, callback) -> None:
self.connected.append(callback)
class FakeNavigationInterface:
def __init__(self) -> None:
self.headers = []
self.displayModeChanged = FakeSignal()
def addItemHeader(self, text, position):
header = SimpleNamespace(text=text, position=position, setVisible=Mock())
self.headers.append(header)
return header
def setMinimumExpandWidth(self, width) -> None:
self.minimum_expand_width = width
added_pages: list[PageName] = []
interactive_signal = FakeSignal()
session = SimpleNamespace(
nav_scroll_position=None,
ui_language="ru",
sidebar_search_widget_cls=object,
nav_items={},
nav_headers=[],
nav_header_by_group={},
nav_mode_visibility={},
nav_search_query="",
sidebar_search_nav_widget=None,
sidebar_search_model=None,
sidebar_search_completer=None,
sidebar_search_titlebar_attached=False,
pages={},
nav_labels={},
)
window = SimpleNamespace(
ui_session=session,
navigationInterface=FakeNavigationInterface(),
startup_state=SimpleNamespace(interactive_logged=False),
startup_interactive_ready=interactive_signal,
get_launch_method=lambda: ZAPRET2_MODE,
log_startup_metric=Mock(),
)
scheduled: list[int] = []
search_installed_after: list[int] = []
def _fake_add_nav_item(current_window, page_name, *_args, **_kwargs):
added_pages.append(page_name)
session.nav_items[page_name] = SimpleNamespace(setVisible=Mock())
with (
patch.object(
sidebar_builder.QTimer,
"singleShot",
side_effect=lambda delay_ms, callback: scheduled.append(int(delay_ms)),
),
patch.object(
sidebar_builder,
"_install_sidebar_search",
side_effect=lambda current_window: search_installed_after.append(len(added_pages)),
),
patch.object(sidebar_builder, "add_nav_item", side_effect=_fake_add_nav_item),
patch("settings.store.get_ui_state_settings", return_value={"sidebar_expanded": True}),
):
sidebar_builder.init_navigation(window)
for page_name in (
PageName.ZAPRET2_MODE_CONTROL,
PageName.ZAPRET2_USER_PRESETS,
PageName.ZAPRET2_PRESET_SETUP,
PageName.NETWORK,
PageName.LOGS,
):
self.assertIn(page_name, added_pages)
# Поиск ставится один раз и после всех пунктов: подсказки поиска
# собираются из готового меню.
self.assertEqual(search_installed_after, [len(added_pages)])
# Единственный таймер — страховочная перепроверка состояния панели.
self.assertEqual(scheduled, [sidebar_builder.SIDEBAR_INTENT_RECHECK_AFTER_INIT_MS])
# Достройка не ждёт готовности интерфейса: ждать больше нечего.
self.assertEqual(interactive_signal.connected, [])
def test_initial_sidebar_build_restores_saved_expanded_state(self) -> None:
from settings.mode import ZAPRET2_MODE
from ui.navigation.sidebar_state import store_warmed_sidebar_expanded
import ui.navigation.sidebar_builder as sidebar_builder
class FakeSignal:
def connect(self, callback) -> None:
_ = callback
class FakeNavigationInterface:
def __init__(self) -> None:
self.headers = []
self.expand_calls = []
self.displayModeChanged = FakeSignal()
def addItemHeader(self, text, position):
header = SimpleNamespace(text=text, position=position)
self.headers.append(header)
return header
def setMinimumExpandWidth(self, width) -> None:
self.minimum_expand_width = width
def expand(self, useAni=True) -> None:
self.expand_calls.append(useAni)
session = SimpleNamespace(
nav_scroll_position=None,
ui_language="ru",
sidebar_search_widget_cls=None,
nav_items={},
nav_headers=[],
nav_header_by_group={},
nav_mode_visibility={},
nav_search_query="",
sidebar_search_nav_widget=None,
sidebar_search_model=None,
sidebar_search_completer=None,
sidebar_search_titlebar_attached=False,
pages={},
)
nav = FakeNavigationInterface()
window = SimpleNamespace(
ui_session=session,
navigationInterface=nav,
get_launch_method=lambda: ZAPRET2_MODE,
# шире порога 700: на узком окне восстановление не разворачивает
# панель, чтобы не открывать MENU-оверлей поверх контента
width=lambda: 900,
)
store_warmed_sidebar_expanded(True)
with (
patch.object(
sidebar_builder,
"add_nav_item",
side_effect=lambda current_window, page_name, *_args, **_kwargs: None,
),
patch(
"settings.store.get_ui_state_settings",
side_effect=AssertionError("sidebar restore must use warmed state"),
),
):
sidebar_builder.init_navigation(window)
self.assertEqual(nav.expand_calls, [False])
def test_initial_sidebar_build_restores_saved_collapsed_state(self) -> None:
from settings.mode import ZAPRET2_MODE
from ui.navigation.sidebar_state import store_warmed_sidebar_expanded
import ui.navigation.sidebar_builder as sidebar_builder
class FakeSignal:
def connect(self, callback) -> None:
_ = callback
class FakePanel:
def __init__(self) -> None:
self.collapse_calls = 0
def collapse(self) -> None:
self.collapse_calls += 1
class FakeNavigationInterface:
def __init__(self) -> None:
self.headers = []
self.panel = FakePanel()
self.displayModeChanged = FakeSignal()
def addItemHeader(self, text, position):
header = SimpleNamespace(text=text, position=position)
self.headers.append(header)
return header
def setMinimumExpandWidth(self, width) -> None:
self.minimum_expand_width = width
session = SimpleNamespace(
nav_scroll_position=None,
ui_language="ru",
sidebar_search_widget_cls=None,
nav_items={},
nav_headers=[],
nav_header_by_group={},
nav_mode_visibility={},
nav_search_query="",
sidebar_search_nav_widget=None,
sidebar_search_model=None,
sidebar_search_completer=None,
sidebar_search_titlebar_attached=False,
pages={},
)
nav = FakeNavigationInterface()
window = SimpleNamespace(
ui_session=session,
navigationInterface=nav,
get_launch_method=lambda: ZAPRET2_MODE,
)
store_warmed_sidebar_expanded(False)
with (
patch.object(
sidebar_builder,
"add_nav_item",
side_effect=lambda current_window, page_name, *_args, **_kwargs: None,
),
patch(
"settings.store.get_ui_state_settings",
side_effect=AssertionError("sidebar restore must use warmed state"),
),
):
sidebar_builder.init_navigation(window)
self.assertEqual(nav.panel.collapse_calls, 1)
def test_initial_sidebar_build_keeps_indicator_animation_policy_untouched(self) -> None:
from settings.mode import ZAPRET2_MODE
import ui.navigation.sidebar_builder as sidebar_builder
class FakeSignal:
def connect(self, callback) -> None:
_ = callback
class FakePanel:
def __init__(self) -> None:
self.indicator_animation_values = []
def setIndicatorAnimationEnabled(self, enabled: bool) -> None:
self.indicator_animation_values.append(bool(enabled))
class FakeNavigationInterface:
def __init__(self) -> None:
self.headers = []
self.panel = FakePanel()
self.displayModeChanged = FakeSignal()
def addItemHeader(self, text, position):
header = SimpleNamespace(text=text, position=position)
self.headers.append(header)
return header
def setMinimumExpandWidth(self, width) -> None:
self.minimum_expand_width = width
session = SimpleNamespace(
nav_scroll_position=None,
ui_language="ru",
sidebar_search_widget_cls=None,
nav_items={},
nav_headers=[],
nav_header_by_group={},
nav_mode_visibility={},
nav_search_query="",
sidebar_search_nav_widget=None,
sidebar_search_model=None,
sidebar_search_completer=None,
sidebar_search_titlebar_attached=False,
pages={},
)
nav = FakeNavigationInterface()
window = SimpleNamespace(
ui_session=session,
navigationInterface=nav,
get_launch_method=lambda: ZAPRET2_MODE,
)
with (
patch.object(sidebar_builder, "add_nav_item", side_effect=lambda *_args, **_kwargs: None),
patch("settings.store.get_ui_state_settings", return_value={"sidebar_expanded": True}),
):
sidebar_builder.init_navigation(window)
self.assertEqual(nav.panel.indicator_animation_values, [])
def test_sidebar_display_mode_change_is_saved(self) -> None:
from settings.mode import ZAPRET2_MODE
import ui.navigation.sidebar_builder as sidebar_builder
class FakeSignal:
def __init__(self) -> None:
self.connected = []
def connect(self, callback) -> None:
self.connected.append(callback)
def emit(self, display_mode) -> None:
for callback in self.connected:
callback(display_mode)
class FakeNavigationInterface:
def __init__(self) -> None:
self.headers = []
self.displayModeChanged = FakeSignal()
menu_button = SimpleNamespace(event_filter=None)
menu_button.installEventFilter = (
lambda event_filter: setattr(menu_button, "event_filter", event_filter)
)
self.panel = SimpleNamespace(
minimumExpandWidth=700,
isCollapsed=lambda: False,
menuButton=menu_button,
)
def addItemHeader(self, text, position):
header = SimpleNamespace(text=text, position=position)
self.headers.append(header)
return header
def setMinimumExpandWidth(self, width) -> None:
self.minimum_expand_width = width
def expand(self, useAni=True) -> None:
_ = useAni
session = SimpleNamespace(
nav_scroll_position=None,
ui_language="ru",
sidebar_search_widget_cls=None,
nav_items={},
nav_headers=[],
nav_header_by_group={},
nav_mode_visibility={},
nav_search_query="",
sidebar_search_nav_widget=None,
sidebar_search_model=None,
sidebar_search_completer=None,
sidebar_search_titlebar_attached=False,
pages={},
)
nav = FakeNavigationInterface()
window = SimpleNamespace(
ui_session=session,
navigationInterface=nav,
get_launch_method=lambda: ZAPRET2_MODE,
# шире порога 700: только на широком окне COMPACT означает
# осознанное сворачивание, а не responsive-механику библиотеки
width=lambda: 900,
)
from ui.navigation.sidebar_state import store_warmed_sidebar_expanded
store_warmed_sidebar_expanded(True)
self.addCleanup(store_warmed_sidebar_expanded, None)
with (
patch.object(
sidebar_builder,
"add_nav_item",
side_effect=lambda current_window, page_name, *_args, **_kwargs: None,
),
patch("settings.store.get_ui_state_settings", return_value={"sidebar_expanded": True}),
patch("settings.store.set_ui_state_settings") as save_ui_state,
):
create_worker = Mock()
session.sidebar_expanded_save_worker_factory = create_worker
worker = SimpleNamespace(
isRunning=Mock(return_value=False),
saved=SimpleNamespace(connect=Mock()),
failed=SimpleNamespace(connect=Mock()),
finished=SimpleNamespace(connect=Mock()),
start=Mock(),
deleteLater=Mock(),
)
create_worker.return_value = worker
sidebar_builder.init_navigation(window)
from PyQt6.QtCore import QEvent
nav.panel.menuButton.event_filter.eventFilter(
nav.panel.menuButton,
QEvent(QEvent.Type.MouseButtonRelease),
)
nav.displayModeChanged.emit(SimpleNamespace(name="COMPACT"))
create_worker.assert_called_once()
self.assertFalse(create_worker.call_args.kwargs["expanded"])
worker.start.assert_called_once_with()
save_ui_state.assert_not_called()
def test_sidebar_responsive_collapse_on_narrow_window_is_not_saved(self) -> None:
from settings.mode import ZAPRET2_MODE
import ui.navigation.sidebar_builder as sidebar_builder
from ui.navigation.sidebar_state import store_warmed_sidebar_expanded
class FakeSignal:
def __init__(self) -> None:
self.connected = []
def connect(self, callback) -> None:
self.connected.append(callback)
def emit(self, display_mode) -> None:
for callback in self.connected:
callback(display_mode)
class FakeNavigationInterface:
def __init__(self) -> None:
self.headers = []
self.displayModeChanged = FakeSignal()
def addItemHeader(self, text, position):
header = SimpleNamespace(text=text, position=position)
self.headers.append(header)
return header
def setMinimumExpandWidth(self, width) -> None:
self.minimum_expand_width = width
def expand(self, useAni=True) -> None:
_ = useAni
session = SimpleNamespace(
nav_scroll_position=None,
ui_language="ru",
sidebar_search_widget_cls=None,
nav_items={},
nav_headers=[],
nav_header_by_group={},
nav_mode_visibility={},
nav_search_query="",
sidebar_search_nav_widget=None,
sidebar_search_model=None,
sidebar_search_completer=None,
sidebar_search_titlebar_attached=False,
pages={},
)
nav = FakeNavigationInterface()
window = SimpleNamespace(
ui_session=session,
navigationInterface=nav,
get_launch_method=lambda: ZAPRET2_MODE,
width=lambda: 680,
)
store_warmed_sidebar_expanded(True)
self.addCleanup(store_warmed_sidebar_expanded, None)
with (
patch.object(
sidebar_builder,
"add_nav_item",
side_effect=lambda current_window, page_name, *_args, **_kwargs: None,
),
patch("settings.store.get_ui_state_settings", return_value={"sidebar_expanded": True}),
):
create_worker = Mock()
session.sidebar_expanded_save_worker_factory = create_worker
sidebar_builder.init_navigation(window)
nav.displayModeChanged.emit(SimpleNamespace(name="COMPACT"))
create_worker.assert_not_called()
self.assertTrue(session.sidebar_intent_controller.intent)
def test_sidebar_display_mode_save_is_queued_while_worker_runs(self) -> None:
import ui.navigation.sidebar_builder as sidebar_builder
from ui.one_shot_worker_runtime import OneShotWorkerRuntime
worker = SimpleNamespace(isRunning=Mock(return_value=True))
runtime = OneShotWorkerRuntime()
runtime.worker = worker
session = SimpleNamespace(
sidebar_expanded_save_runtime=runtime,
sidebar_expanded_save_worker_factory=Mock(),
)
window = SimpleNamespace(ui_session=session)
sidebar_builder._start_sidebar_expanded_save_worker(window, False)
self.assertFalse(session.sidebar_expanded_save_state.pending)
session.sidebar_expanded_save_worker_factory.assert_not_called()
def test_sidebar_pending_save_restarts_after_event_loop_turn(self) -> None:
import ui.navigation.sidebar_builder as sidebar_builder
from ui.latest_value_worker_state import LatestValueWorkerState
session = SimpleNamespace(
sidebar_expanded_save_state=LatestValueWorkerState(object(), empty_value=None, pending=False),
sidebar_expanded_save_runtime_worker=None,
)
window = SimpleNamespace(ui_session=session)
single_shot = Mock(side_effect=lambda _delay, _callback: None)
with (
patch.object(sidebar_builder, "QTimer", SimpleNamespace(singleShot=single_shot), create=True),
patch.object(sidebar_builder, "_start_sidebar_expanded_save_worker") as start_worker,
):
sidebar_builder._on_sidebar_expanded_save_worker_finished(window, None)
single_shot.assert_called_once()
self.assertEqual(single_shot.call_args.args[0], 0)
start_worker.assert_not_called()
single_shot.call_args.args[1]()
start_worker.assert_called_once_with(window, False)
def test_stale_sidebar_pending_save_finish_does_not_restart_save(self) -> None:
import ui.navigation.sidebar_builder as sidebar_builder
from ui.latest_value_worker_state import LatestValueWorkerState
current_worker = object()
session = SimpleNamespace(
sidebar_expanded_save_state=LatestValueWorkerState(object(), empty_value=None, pending=False),
sidebar_expanded_save_runtime_worker=current_worker,
)
window = SimpleNamespace(ui_session=session)
single_shot = Mock()
with (
patch.object(sidebar_builder, "QTimer", SimpleNamespace(singleShot=single_shot), create=True),
patch.object(sidebar_builder, "_start_sidebar_expanded_save_worker") as start_worker,
):
sidebar_builder._on_sidebar_expanded_save_worker_finished(window, object())
single_shot.assert_not_called()
start_worker.assert_not_called()
self.assertFalse(session.sidebar_expanded_save_state.pending)
self.assertIs(session.sidebar_expanded_save_runtime_worker, current_worker)
def test_mode_switch_reuses_hidden_other_mode_items(self) -> None:
from app.page_names import PageName
from settings.mode import ZAPRET1_MODE, ZAPRET2_MODE
from ui.navigation.schema import get_nav_visibility
import ui.navigation.sidebar_builder as sidebar_builder
class FakeNavItem:
def __init__(self, visible: bool = True) -> None:
self.visible = visible
def setVisible(self, visible: bool) -> None:
self.visible = bool(visible)
class FakeNavigationInterface:
def __init__(self, session) -> None:
self._session = session
def addItem(self, *, routeKey, icon, text, onClick, selectable, position):
_ = routeKey, icon, text, onClick, selectable, position
return FakeNavItem(True)
def insertItem(self, index, *, routeKey, icon, text, onClick, selectable, position):
_ = index, routeKey, icon, text, onClick, selectable, position
return FakeNavItem(True)
old_pages = (
PageName.ZAPRET2_MODE_CONTROL,
PageName.ZAPRET2_USER_PRESETS,
PageName.ZAPRET2_PRESET_SETUP,
)
new_pages = (
PageName.ZAPRET1_MODE_CONTROL,
PageName.ZAPRET1_USER_PRESETS,
PageName.ZAPRET1_PRESET_SETUP,
)
initial_visibility = get_nav_visibility(ZAPRET2_MODE)
session = SimpleNamespace(
nav_items={
page_name: FakeNavItem(visible)
for page_name, visible in initial_visibility.items()
},
nav_icons={},
nav_labels={},
nav_headers=[],
nav_header_by_group={},
nav_search_query="",
nav_mode_visibility={},
nav_scroll_position=None,
default_nav_icon=None,
ui_language="ru",
sidebar_search_model=None,
sidebar_search_completer=None,
page_host=SimpleNamespace(ensure_page=lambda page_name: None),
)
window = SimpleNamespace(
ui_session=session,
navigationInterface=FakeNavigationInterface(session),
get_launch_method=lambda: ZAPRET2_MODE,
)
original_pump = sidebar_builder.pump_startup_ui
try:
sidebar_builder.pump_startup_ui = lambda current_window: self.fail("mode switch should reuse existing nav items")
sidebar_builder.sync_nav_visibility(window, ZAPRET1_MODE)
finally:
sidebar_builder.pump_startup_ui = original_pump
self.assertFalse(any(session.nav_items[page_name].visible for page_name in old_pages))
self.assertTrue(all(session.nav_items[page_name].visible for page_name in new_pages))
def test_nav_visibility_filter_skips_unchanged_widgets(self) -> None:
from app.page_names import PageName
import ui.navigation.sidebar_builder as sidebar_builder
class FakeNavItem:
def __init__(self, visible: bool) -> None:
self.visible = bool(visible)
self.set_visible_calls: list[bool] = []
def isVisible(self) -> bool:
return self.visible
def setVisible(self, visible: bool) -> None:
self.set_visible_calls.append(bool(visible))
self.visible = bool(visible)
visible_item = FakeNavItem(True)
hidden_item = FakeNavItem(False)
header = FakeNavItem(True)
session = SimpleNamespace(
nav_items={
PageName.ZAPRET2_MODE_CONTROL: visible_item,
PageName.ZAPRET1_MODE_CONTROL: hidden_item,
},
nav_labels={
PageName.ZAPRET2_MODE_CONTROL: "Управление Zapret 2",
PageName.ZAPRET1_MODE_CONTROL: "Управление Zapret 1",
},
nav_headers=[(header, (PageName.ZAPRET2_MODE_CONTROL,), "nav.header.root")],
nav_search_query="",
nav_mode_visibility={
PageName.ZAPRET2_MODE_CONTROL: True,
PageName.ZAPRET1_MODE_CONTROL: False,
},
ui_language="ru",
)
window = SimpleNamespace(ui_session=session)
sidebar_builder.apply_nav_visibility_filter(window)
self.assertEqual(visible_item.set_visible_calls, [])
self.assertEqual(hidden_item.set_visible_calls, [])
self.assertEqual(header.set_visible_calls, [])
def test_sidebar_nav_item_exposes_screen_reader_name(self) -> None:
from app.page_names import PageName
from settings.mode import ZAPRET2_MODE
import ui.navigation.sidebar_builder as sidebar_builder
class FakeNavItem:
def __init__(self) -> None:
self._accessible_name = ""
self._accessible_description = ""
self._properties = {}
def accessibleName(self): # noqa: N802
return self._accessible_name
def setAccessibleName(self, value): # noqa: N802
self._accessible_name = str(value)
def accessibleDescription(self): # noqa: N802
return self._accessible_description
def setAccessibleDescription(self, value): # noqa: N802
self._accessible_description = str(value)
def property(self, name):
return self._properties.get(str(name))
def setProperty(self, name, value): # noqa: N802
self._properties[str(name)] = value
class FakeNavigationInterface:
def addItem(self, *, routeKey, icon, text, onClick, selectable, position):
_ = routeKey, icon, text, onClick, selectable, position
return FakeNavItem()
session = SimpleNamespace(
nav_items={},
nav_icons={},
nav_labels={PageName.NETWORK: "DNS и сеть"},
nav_scroll_position=None,
default_nav_icon=None,
ui_language="ru",
page_host=SimpleNamespace(ensure_page=lambda page_name: None),
)
window = SimpleNamespace(
ui_session=session,
navigationInterface=FakeNavigationInterface(),
get_launch_method=lambda: ZAPRET2_MODE,
)
with patch.object(sidebar_builder, "get_eager_page_names_for_method", return_value=()):
sidebar_builder.add_nav_item(window, PageName.NETWORK, None)
item = session.nav_items[PageName.NETWORK]
self.assertEqual(item.accessibleName(), "Открыть раздел: Настройка DNS")
self.assertEqual(
item.accessibleDescription(),
"Открывает раздел Настройка DNS в боковом меню.",
)
self.assertEqual(item.property("screenReaderStateText"), "Открыть раздел: Настройка DNS")
def test_group_build_reads_launch_method_once_not_per_item(self) -> None:
from app.page_names import PageName
import ui.navigation.sidebar_builder as sidebar_builder
from settings.mode import ZAPRET2_MODE
launch_method_reads: list[str] = []
added: list[tuple[PageName, str | None]] = []
def _get_launch_method() -> str:
# Настоящее чтение берёт общий замок настроек. В первую секунду
# после запуска его держат фоновые задачи, и чтение на каждый
# пункт меню задерживало кадр на 65–100 мс.
launch_method_reads.append("read")
return ZAPRET2_MODE
session = SimpleNamespace(
nav_scroll_position=None,
nav_header_by_group={},
nav_headers=[],
ui_language="ru",
)
window = SimpleNamespace(
ui_session=session,
navigationInterface=SimpleNamespace(addItemHeader=lambda *_args: object()),
get_launch_method=_get_launch_method,
)
group_plan = SimpleNamespace(
header_key="",
group_name="settings",
page_names=(PageName.NETWORK, PageName.HOSTS, PageName.APPEARANCE),
)
with patch.object(
sidebar_builder,
"add_nav_item",
side_effect=lambda _window, page_name, _position, **kwargs: added.append(
(page_name, kwargs.get("launch_method"))
),
):
sidebar_builder._add_sidebar_group(window, group_plan, {}, ZAPRET2_MODE)
self.assertEqual(launch_method_reads, [])
self.assertEqual(
added,
[
(PageName.NETWORK, ZAPRET2_MODE),
(PageName.HOSTS, ZAPRET2_MODE),
(PageName.APPEARANCE, ZAPRET2_MODE),
],
)
def test_add_nav_item_uses_given_launch_method_without_settings_read(self) -> None:
from app.page_names import PageName
import ui.navigation.sidebar_builder as sidebar_builder
from settings.mode import ZAPRET2_MODE
class FakeNavItem:
def setVisible(self, _visible) -> None:
pass
def setAccessibleName(self, _text) -> None:
pass
def setAccessibleDescription(self, _text) -> None:
pass
def setProperty(self, _name, _value) -> None:
pass
class FakeNavigationInterface:
def addItem(self, *, routeKey, icon, text, onClick, selectable, position):
_ = routeKey, icon, text, onClick, selectable, position
return FakeNavItem()
session = SimpleNamespace(
nav_items={},
nav_icons={},
nav_labels={PageName.NETWORK: "DNS и сеть"},
nav_scroll_position=None,
default_nav_icon=None,
ui_language="ru",
page_host=SimpleNamespace(ensure_page=lambda page_name: None),
)
def _fail_read() -> str:
raise AssertionError("режим запуска уже передан, читать настройки незачем")
window = SimpleNamespace(
ui_session=session,
navigationInterface=FakeNavigationInterface(),
get_launch_method=_fail_read,
)
seen_methods: list[str] = []
with patch.object(
sidebar_builder,
"get_eager_page_names_for_method",
side_effect=lambda method: seen_methods.append(method) or (),
):
sidebar_builder.add_nav_item(window, PageName.NETWORK, None, launch_method=ZAPRET2_MODE)
self.assertEqual(seen_methods, [ZAPRET2_MODE])
self.assertIn(PageName.NETWORK, session.nav_items)
def test_nav_visibility_filter_keeps_mode_items_hidden_when_state_is_missing(self) -> None:
from app.page_names import PageName
import ui.navigation.sidebar_builder as sidebar_builder
class FakeNavItem:
def __init__(self, visible: bool) -> None:
self.visible = bool(visible)
def isVisible(self) -> bool:
return self.visible
def setVisible(self, visible: bool) -> None:
self.visible = bool(visible)
z2_item = FakeNavItem(True)
z1_item = FakeNavItem(False)
dns_item = FakeNavItem(True)
session = SimpleNamespace(
nav_items={
PageName.ZAPRET2_MODE_CONTROL: z2_item,
PageName.ZAPRET1_MODE_CONTROL: z1_item,
PageName.NETWORK: dns_item,
},
nav_labels={
PageName.ZAPRET2_MODE_CONTROL: "Управление Zapret 2",
PageName.ZAPRET1_MODE_CONTROL: "Управление Zapret 1",
PageName.NETWORK: "Настройка DNS",
},
nav_headers=[],
nav_search_query="",
nav_mode_visibility={
PageName.ZAPRET2_MODE_CONTROL: True,
},
ui_language="ru",
)
window = SimpleNamespace(ui_session=session)
sidebar_builder.apply_nav_visibility_filter(window)
self.assertTrue(z2_item.visible)
self.assertFalse(z1_item.visible)
self.assertTrue(dns_item.visible)
def test_nav_visibility_filter_rechecks_mode_schema_when_state_says_visible(self) -> None:
from app.page_names import PageName
from settings.mode import ZAPRET2_MODE
import ui.navigation.sidebar_builder as sidebar_builder
class FakeNavItem:
def __init__(self, visible: bool) -> None:
self.visible = bool(visible)
def isVisible(self) -> bool:
return self.visible
def setVisible(self, visible: bool) -> None:
self.visible = bool(visible)
z2_item = FakeNavItem(True)
z1_item = FakeNavItem(True)
orchestra_item = FakeNavItem(True)
orchestra_settings_item = FakeNavItem(True)
dns_item = FakeNavItem(True)
session = SimpleNamespace(
nav_items={
PageName.ZAPRET2_MODE_CONTROL: z2_item,
PageName.ZAPRET1_MODE_CONTROL: z1_item,
PageName.ORCHESTRA: orchestra_item,
PageName.ORCHESTRA_SETTINGS: orchestra_settings_item,
PageName.NETWORK: dns_item,
},
nav_labels={
PageName.ZAPRET2_MODE_CONTROL: "Управление Zapret 2",
PageName.ZAPRET1_MODE_CONTROL: "Управление Zapret 1",
PageName.ORCHESTRA: "Оркестратор",
PageName.ORCHESTRA_SETTINGS: "Настройки оркестратора",
PageName.NETWORK: "Настройка DNS",
},
nav_headers=[],
nav_search_query="",
nav_mode_visibility={
PageName.ZAPRET2_MODE_CONTROL: True,
PageName.ZAPRET1_MODE_CONTROL: True,
PageName.ORCHESTRA: True,
PageName.ORCHESTRA_SETTINGS: True,
PageName.NETWORK: True,
},
ui_language="ru",
)
window = SimpleNamespace(ui_session=session, get_launch_method=lambda: ZAPRET2_MODE)
sidebar_builder.apply_nav_visibility_filter(window)
self.assertTrue(z2_item.visible)
self.assertFalse(z1_item.visible)
self.assertFalse(orchestra_item.visible)
self.assertFalse(orchestra_settings_item.visible)
self.assertTrue(dns_item.visible)
def test_mode_switch_reorders_existing_sidebar_items_for_new_mode(self) -> None:
from app.page_names import PageName
from settings.mode import ZAPRET1_MODE, ZAPRET2_MODE
from ui.navigation.layout_plan import build_sidebar_group_plans
import ui.navigation.sidebar_builder as sidebar_builder
class FakeNavItem:
def __init__(self, name: str, visible: bool = True) -> None:
self.name = name
self.visible = visible
def setVisible(self, visible: bool) -> None:
self.visible = bool(visible)
def __repr__(self) -> str:
return self.name
class FakeScrollLayout:
def __init__(self, widgets) -> None:
self.widgets = list(widgets)
def indexOf(self, widget) -> int:
try:
return self.widgets.index(widget)
except ValueError:
return -1
def insertWidget(self, index: int, widget) -> None:
if widget in self.widgets:
self.widgets.remove(widget)
self.widgets.insert(index, widget)
class FakeNavigationInterface:
def __init__(self, layout: FakeScrollLayout) -> None:
self.panel = SimpleNamespace(scrollLayout=layout)
def addItem(self, *, routeKey, icon, text, onClick, selectable, position):
_ = routeKey, icon, text, onClick, selectable, position
return FakeNavItem(text)
def insertItem(self, index, *, routeKey, icon, text, onClick, selectable, position):
_ = index, routeKey, icon, text, onClick, selectable, position
return FakeNavItem(text)
settings_header = FakeNavItem("settings-header")
z2_user = FakeNavItem("z2-user")
z2_setup = FakeNavItem("z2-setup")
dpi = FakeNavItem("dpi")
z1_user = FakeNavItem("z1-user", visible=False)
z1_setup = FakeNavItem("z1-setup", visible=False)
layout = FakeScrollLayout(
[
settings_header,
z2_user,
z2_setup,
dpi,
z1_user,
z1_setup,
]
)
session = SimpleNamespace(
nav_items={
PageName.ZAPRET2_USER_PRESETS: z2_user,
PageName.ZAPRET2_PRESET_SETUP: z2_setup,
PageName.DPI_SETTINGS: dpi,
PageName.ZAPRET1_USER_PRESETS: z1_user,
PageName.ZAPRET1_PRESET_SETUP: z1_setup,
},
nav_icons={},
nav_labels={},
nav_headers=[
(
settings_header,
next(
group_plan.page_names
for group_plan in build_sidebar_group_plans(ZAPRET2_MODE)
if group_plan.group_name == "settings"
),
"nav.header.settings",
)
],
nav_header_by_group={"settings": settings_header},
nav_search_query="",
nav_mode_visibility={},
nav_scroll_position=None,
default_nav_icon=None,
ui_language="ru",
sidebar_search_model=None,
sidebar_search_completer=None,
page_host=SimpleNamespace(ensure_page=lambda page_name: None),
)
window = SimpleNamespace(
ui_session=session,
navigationInterface=FakeNavigationInterface(layout),
get_launch_method=lambda: ZAPRET2_MODE,
)
sidebar_builder.sync_nav_visibility(window, ZAPRET1_MODE)
self.assertEqual(
layout.widgets[:6],
[
settings_header,
z1_user,
z1_setup,
dpi,
z2_user,
z2_setup,
],
)
def test_add_nav_item_reuses_loaded_eager_page_without_second_ensure(self) -> None:
from app.page_names import PageName
from settings.mode import ZAPRET2_MODE
import ui.navigation.sidebar_builder as sidebar_builder
class FakePage:
def __init__(self) -> None:
self.name = ""
def objectName(self) -> str:
return self.name
def setObjectName(self, name: str) -> None:
self.name = name
class FakeNavigationInterface:
def __init__(self) -> None:
self.added_pages = []
def addItem(self, **_kwargs):
raise AssertionError("eager page should use addSubInterface")
loaded_page = FakePage()
session = SimpleNamespace(
nav_items={},
nav_icons={},
nav_labels={},
nav_scroll_position=None,
default_nav_icon=None,
ui_language="ru",
pages={PageName.ZAPRET2_MODE_CONTROL: loaded_page},
)
window = SimpleNamespace(
ui_session=session,
navigationInterface=FakeNavigationInterface(),
get_launch_method=lambda: ZAPRET2_MODE,
addSubInterface=Mock(return_value=object()),
)
with patch.object(sidebar_builder, "_ensure_page", side_effect=AssertionError("page is already loaded")):
sidebar_builder.add_nav_item(window, PageName.ZAPRET2_MODE_CONTROL, None)
window.addSubInterface.assert_called_once()
self.assertIs(window.addSubInterface.call_args.args[0], loaded_page)
self.assertIn(PageName.ZAPRET2_MODE_CONTROL, session.nav_items)
def test_sidebar_search_keeps_typing_global_even_when_page_has_local_handler(self) -> None:
import ui.navigation.search as sidebar_search
class CurrentPage:
def __init__(self) -> None:
self.queries = []
def apply_sidebar_search_query(self, query: str) -> bool:
self.queries.append(query)
return True
current_page = CurrentPage()
session = SimpleNamespace(
nav_search_query="",
page_host=SimpleNamespace(current_page=lambda: current_page),
)
window = SimpleNamespace(ui_session=session)
with (
patch.object(sidebar_search, "route_sidebar_search_by_text", return_value=False) as route_by_text,
patch.object(sidebar_search, "update_sidebar_search_suggestions") as update_suggestions,
patch("ui.navigation.sidebar_builder.apply_nav_visibility_filter") as apply_nav_visibility_filter,
):
sidebar_search.on_sidebar_search_changed(window, "Discord")
self.assertEqual(session.nav_search_query, "Discord")
self.assertEqual(current_page.queries, [])
route_by_text.assert_called_once_with(window, "Discord", prefer_first=False)
apply_nav_visibility_filter.assert_called_once_with(window)
update_suggestions.assert_called_once_with(window)
def test_sidebar_search_applies_result_query_after_route(self) -> None:
import ui.navigation.search as sidebar_search
from app.page_names import PageName
queries = []
session = SimpleNamespace(
page_host=SimpleNamespace(
current_page=lambda: SimpleNamespace(
apply_sidebar_search_query=lambda query: queries.append(query) or True
)
),
sidebar_search_nav_widget=SimpleNamespace(clear=Mock()),
)
window = SimpleNamespace(ui_session=session)
with patch.object(sidebar_search, "route_search_result", return_value=True):
routed = sidebar_search._route_search_result_and_clear(
window,
PageName.ZAPRET2_PRESET_SETUP,
query_text="Discord Voice",
)
self.assertTrue(routed)
self.assertEqual(queries, ["Discord Voice"])
def test_sidebar_search_finds_page_body_text(self) -> None:
from app.page_names import PageName
from app.search_index import find_search_entries
from settings.mode import ZAPRET2_MODE
from ui.navigation.schema import get_sidebar_search_pages_for_method
visible_pages = get_sidebar_search_pages_for_method(ZAPRET2_MODE, set(PageName))
matches = find_search_entries(
"премиум",
language="ru",
visible_pages=visible_pages,
max_results=10,
)
self.assertTrue(any(match.entry.page_name == PageName.PREMIUM for match in matches))
def test_sidebar_search_builds_profile_and_preset_runtime_entries(self) -> None:
from app.page_names import PageName
from settings.mode import ZAPRET2_MODE
import ui.navigation.search as sidebar_search
profiles = (
SimpleNamespace(
key="profile:0",
display_name="Discord Voice",
strategy_name="Fake",
group_name="Voice",
list_type="hostlist",
match_lines=("--hostlist=lists/private-discord-file.txt",),
),
)
manifests = (
SimpleNamespace(file_name="gaming.txt", name="Gaming Preset"),
)
session = SimpleNamespace(
sidebar_search_profile_loader=Mock(return_value=profiles),
sidebar_search_preset_loader=Mock(return_value=manifests),
)
window = SimpleNamespace(
ui_session=session,
get_launch_method=lambda: ZAPRET2_MODE,
)
entries = sidebar_search._build_runtime_search_entries(window)
self.assertTrue(
any(entry.kind == "profile" and entry.page_name == PageName.ZAPRET2_PRESET_SETUP for entry in entries)
)
self.assertTrue(
any(entry.kind == "preset" and entry.page_name == PageName.ZAPRET2_USER_PRESETS for entry in entries)
)
self.assertFalse(any("private-discord-file" in " ".join(entry.keywords) for entry in entries))
session.sidebar_search_profile_loader.assert_called_once_with(ZAPRET2_MODE)
session.sidebar_search_preset_loader.assert_called_once_with(ZAPRET2_MODE)
def test_sidebar_search_suggestions_expose_screen_reader_text(self) -> None:
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QStandardItemModel
from app.page_names import PageName
from app.search_index import SearchEntry, SearchMatch
import ui.navigation.search as sidebar_search
class _Completer:
def popup(self):
return SimpleNamespace(hide=Mock())
entry = SearchEntry(
"runtime.profile.discord",
PageName.ZAPRET2_PRESET_SETUP,
title="Discord Voice",
location="Готовые стратегии",
query_text="Discord Voice",
)
model = QStandardItemModel()
session = SimpleNamespace(
nav_search_query="Discord",
ui_language="ru",
sidebar_search_model=model,
sidebar_search_completer=_Completer(),
sidebar_search_nav_widget=SimpleNamespace(isVisible=lambda: False),
)
window = SimpleNamespace(ui_session=session)
with (
patch.object(sidebar_search, "get_sidebar_search_pages", return_value={PageName.ZAPRET2_PRESET_SETUP}),
patch.object(sidebar_search, "_build_runtime_search_entries", return_value=()),
patch.object(sidebar_search, "find_search_entries", return_value=(SearchMatch(entry, 10),)),
):
sidebar_search.update_sidebar_search_suggestions(window)
item = model.item(0)
self.assertEqual(item.text(), "Discord Voice - Готовые стратегии")
self.assertEqual(
item.data(Qt.ItemDataRole.AccessibleTextRole),
"Результат поиска: Discord Voice, место: Готовые стратегии. Нажмите Enter, чтобы открыть.",
)
def test_sidebar_search_popup_reports_current_result_to_screen_reader(self) -> None:
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtCore import QObject, Qt
from PyQt6.QtGui import QStandardItem
from PyQt6.QtWidgets import QApplication
import ui.navigation.search as sidebar_search
app = QApplication.instance() or QApplication([])
self.assertIsNotNone(app)
class _SearchWidget:
def __init__(self) -> None:
self.completer = None
def set_completer(self, completer) -> None:
self.completer = completer
search_widget = _SearchWidget()
session = SimpleNamespace(sidebar_search_nav_widget=search_widget)
window = QObject()
window.ui_session = session
sidebar_search.setup_sidebar_search_completer(window)
item = QStandardItem("Discord Voice - Готовые стратегии")
item.setData(
"Результат поиска: Discord Voice, место: Готовые стратегии. Нажмите Enter, чтобы открыть.",
int(Qt.ItemDataRole.AccessibleTextRole),
)
session.sidebar_search_model.appendRow(item)
popup = session.sidebar_search_completer.popup()
popup.setCurrentIndex(session.sidebar_search_model.index(0, 0))
self.assertEqual(
popup.property("screenReaderStateText"),
"Результат поиска: Discord Voice, место: Готовые стратегии. Нажмите Enter, чтобы открыть.",
)
def test_sidebar_search_keyboard_navigation_reads_selected_result(self) -> None:
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QStandardItem, QStandardItemModel
import ui.navigation.search as sidebar_search
class _SearchWidget:
def __init__(self) -> None:
self.state_texts: list[str] = []
def set_keyboard_result_text(self, text: str) -> None:
self.state_texts.append(text)
first = QStandardItem("DNS - Настройка DNS")
first.setData(
"Результат поиска: DNS, место: Настройка DNS. Нажмите Enter, чтобы открыть.",
int(Qt.ItemDataRole.AccessibleTextRole),
)
second = QStandardItem("Логи - Логи")
second.setData(
"Результат поиска: Логи, место: Логи. Нажмите Enter, чтобы открыть.",
int(Qt.ItemDataRole.AccessibleTextRole),
)
model = QStandardItemModel()
model.appendRow(first)
model.appendRow(second)
session = SimpleNamespace(
sidebar_search_model=model,
sidebar_search_nav_widget=_SearchWidget(),
sidebar_search_selected_row=-1,
)
window = SimpleNamespace(ui_session=session)
self.assertTrue(sidebar_search.select_sidebar_search_result_by_keyboard(window, 1))
self.assertEqual(session.sidebar_search_selected_row, 0)
self.assertEqual(
session.sidebar_search_nav_widget.state_texts[-1],
"Результат поиска: DNS, место: Настройка DNS. Нажмите Enter, чтобы открыть.",
)
self.assertTrue(sidebar_search.select_sidebar_search_result_by_keyboard(window, 1))
self.assertEqual(session.sidebar_search_selected_row, 1)
self.assertEqual(
session.sidebar_search_nav_widget.state_texts[-1],
"Результат поиска: Логи, место: Логи. Нажмите Enter, чтобы открыть.",
)
def test_sidebar_search_keyboard_activation_opens_selected_result(self) -> None:
from PyQt6.QtGui import QStandardItem, QStandardItemModel
from app.page_names import PageName
import ui.navigation.search as sidebar_search
item = QStandardItem("Логи - Логи")
item.setData(PageName.LOGS.name, sidebar_search._PAGE_ROLE)
item.setData("", sidebar_search._TAB_ROLE)
item.setData("", sidebar_search._QUERY_ROLE)
model = QStandardItemModel()
model.appendRow(item)
session = SimpleNamespace(
nav_search_query="лог",
sidebar_search_model=model,
sidebar_search_nav_widget=SimpleNamespace(clear=Mock()),
sidebar_search_selected_row=0,
)
window = SimpleNamespace(ui_session=session)
with patch.object(sidebar_search, "route_search_result", return_value=True) as route:
self.assertTrue(sidebar_search.activate_sidebar_search_result_from_keyboard(window))
route.assert_called_once_with(window, PageName.LOGS, "")
session.sidebar_search_nav_widget.clear.assert_called_once_with()
def test_sidebar_search_reuses_runtime_entries_cache_while_typing(self) -> None:
from settings.mode import ZAPRET2_MODE
import ui.navigation.search as sidebar_search
profiles = (
SimpleNamespace(key="profile:0", display_name="Discord Voice"),
)
manifests = (
SimpleNamespace(file_name="gaming.txt", name="Gaming Preset"),
)
session = SimpleNamespace(
sidebar_search_profile_loader=Mock(return_value=profiles),
sidebar_search_preset_loader=Mock(return_value=manifests),
sidebar_search_runtime_cache={},
)
window = SimpleNamespace(
ui_session=session,
get_launch_method=lambda: ZAPRET2_MODE,
)
first_entries = sidebar_search._build_runtime_search_entries(window)
second_entries = sidebar_search._build_runtime_search_entries(window)
self.assertIs(first_entries, second_entries)
session.sidebar_search_profile_loader.assert_called_once_with(ZAPRET2_MODE)
session.sidebar_search_preset_loader.assert_called_once_with(ZAPRET2_MODE)
def test_sidebar_search_loaders_use_peek_cache_without_sync_rebuild(self) -> None:
import ui.window_bootstrap_runtime as bootstrap_runtime
profile_source = inspect.getsource(bootstrap_runtime.load_sidebar_search_profile_items)
preset_source = inspect.getsource(bootstrap_runtime.load_sidebar_search_preset_manifests)
self.assertIn("peek_cached_profile_list", profile_source)
self.assertIn("peek_cached_preset_list_metadata", preset_source)
self.assertNotIn(".list_profiles(", profile_source)
self.assertNotIn(".list_preset_manifests(", preset_source)
def test_sidebar_search_loaders_return_empty_when_cache_missing(self) -> None:
import ui.window_bootstrap_runtime as bootstrap_runtime
profile_feature = SimpleNamespace(
peek_cached_profile_list=Mock(return_value=None),
list_profiles=Mock(side_effect=AssertionError("sync profile rebuild")),
)
presets_feature = SimpleNamespace(
peek_cached_preset_list_metadata=Mock(return_value=None),
list_preset_manifests=Mock(side_effect=AssertionError("sync preset rebuild")),
)
self.assertEqual(bootstrap_runtime.load_sidebar_search_profile_items(profile_feature, "preset"), ())
self.assertEqual(bootstrap_runtime.load_sidebar_search_preset_manifests(presets_feature, "preset"), ())
profile_feature.peek_cached_profile_list.assert_called_once_with("preset")
presets_feature.peek_cached_preset_list_metadata.assert_called_once_with("preset")
profile_feature.list_profiles.assert_not_called()
presets_feature.list_preset_manifests.assert_not_called()
def test_preset_setup_page_exposes_sidebar_search_handler(self) -> None:
from profile.ui.preset_setup_page import PresetSetupPageBase
handler_source = inspect.getsource(PresetSetupPageBase.apply_sidebar_search_query)
self.assertIn("_profile_search_input", handler_source)
self.assertIn("_on_profile_search_text_changed(query)", handler_source)
def test_preset_setup_sidebar_search_skips_duplicate_query(self) -> None:
from profile.ui.preset_setup_page import PresetSetupPageBase
search_input = SimpleNamespace(
text=lambda: "Discord",
setText=Mock(side_effect=AssertionError("same profile search query must not rewrite input")),
)
page = PresetSetupPageBase.__new__(PresetSetupPageBase)
page._profile_search_input = search_input
page._on_profile_search_text_changed = Mock(
side_effect=AssertionError("same profile search query must not refresh profile list")
)
self.assertTrue(PresetSetupPageBase.apply_sidebar_search_query(page, "Discord"))
search_input.setText.assert_not_called()
page._on_profile_search_text_changed.assert_not_called()
if __name__ == "__main__":
unittest.main()