1839 lines
72 KiB
Python
1839 lines
72 KiB
Python
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_settings_sections),
|
||
inspect.getsource(Zapret2ModeControlPage._build_ui),
|
||
inspect.getsource(Zapret2ModeControlPage._build_settings_sections),
|
||
)
|
||
)
|
||
|
||
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_skips_secondary_and_hidden_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,
|
||
"_schedule_sidebar_search_after_interactive",
|
||
side_effect=lambda current_window: None,
|
||
),
|
||
patch.object(
|
||
sidebar_builder,
|
||
"_schedule_secondary_sidebar_groups_after_interactive",
|
||
side_effect=lambda current_window: None,
|
||
),
|
||
patch.object(
|
||
sidebar_builder,
|
||
"_schedule_hidden_mode_nav_items_after_interactive",
|
||
side_effect=lambda current_window: None,
|
||
),
|
||
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)
|
||
|
||
self.assertIn(PageName.ZAPRET2_MODE_CONTROL, added_pages)
|
||
for page_name in (
|
||
PageName.ZAPRET2_USER_PRESETS,
|
||
PageName.ZAPRET2_PRESET_SETUP,
|
||
PageName.ZAPRET1_MODE_CONTROL,
|
||
PageName.ZAPRET1_USER_PRESETS,
|
||
PageName.ZAPRET1_PRESET_SETUP,
|
||
PageName.NETWORK,
|
||
):
|
||
self.assertNotIn(page_name, added_pages)
|
||
|
||
def test_initial_sidebar_build_defers_secondary_groups_until_after_interactive(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)
|
||
|
||
def emit(self) -> None:
|
||
for callback in list(self.connected):
|
||
callback("ui_ready")
|
||
|
||
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] = []
|
||
signal = FakeSignal()
|
||
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(),
|
||
startup_state=SimpleNamespace(interactive_logged=False),
|
||
startup_interactive_ready=signal,
|
||
get_launch_method=lambda: ZAPRET2_MODE,
|
||
log_startup_metric=Mock(),
|
||
)
|
||
scheduled: list[tuple[int, object]] = []
|
||
|
||
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, "_schedule_sidebar_search_after_interactive", side_effect=lambda current_window: None),
|
||
patch.object(sidebar_builder, "_schedule_hidden_mode_nav_items_after_interactive", side_effect=lambda current_window: None),
|
||
patch.object(sidebar_builder.QTimer, "singleShot", side_effect=lambda delay_ms, callback: scheduled.append((delay_ms, callback))),
|
||
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)
|
||
|
||
self.assertIn(PageName.ZAPRET2_MODE_CONTROL, added_pages)
|
||
self.assertNotIn(PageName.ZAPRET2_USER_PRESETS, added_pages)
|
||
self.assertNotIn(PageName.ZAPRET2_PRESET_SETUP, added_pages)
|
||
self.assertNotIn(PageName.NETWORK, added_pages)
|
||
# Сразу после init_navigation запланирована только страховочная
|
||
# перепроверка состояния сайдбара, но не вторичные группы.
|
||
self.assertEqual(
|
||
[delay for delay, _callback in scheduled],
|
||
[sidebar_builder.SIDEBAR_INTENT_RECHECK_AFTER_INIT_MS],
|
||
)
|
||
|
||
signal.emit()
|
||
|
||
self.assertEqual(len(scheduled), 2)
|
||
self.assertLessEqual(scheduled[1][0], 1_000)
|
||
|
||
next_callback_index = 0
|
||
while next_callback_index < len(scheduled):
|
||
scheduled[next_callback_index][1]()
|
||
next_callback_index += 1
|
||
|
||
self.assertIn(PageName.ZAPRET2_USER_PRESETS, added_pages)
|
||
self.assertIn(PageName.ZAPRET2_PRESET_SETUP, added_pages)
|
||
self.assertIn(PageName.NETWORK, added_pages)
|
||
self.assertIn(PageName.LOGS, added_pages)
|
||
|
||
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,
|
||
"_schedule_sidebar_search_after_interactive",
|
||
side_effect=lambda current_window: None,
|
||
),
|
||
patch.object(
|
||
sidebar_builder,
|
||
"_schedule_hidden_mode_nav_items_after_interactive",
|
||
side_effect=lambda current_window: None,
|
||
),
|
||
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,
|
||
"_schedule_sidebar_search_after_interactive",
|
||
side_effect=lambda current_window: None,
|
||
),
|
||
patch.object(
|
||
sidebar_builder,
|
||
"_schedule_hidden_mode_nav_items_after_interactive",
|
||
side_effect=lambda current_window: None,
|
||
),
|
||
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, "_schedule_sidebar_search_after_interactive", side_effect=lambda *_args: None),
|
||
patch.object(sidebar_builder, "_schedule_hidden_mode_nav_items_after_interactive", side_effect=lambda *_args: None),
|
||
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,
|
||
"_schedule_sidebar_search_after_interactive",
|
||
side_effect=lambda current_window: None,
|
||
),
|
||
patch.object(
|
||
sidebar_builder,
|
||
"_schedule_hidden_mode_nav_items_after_interactive",
|
||
side_effect=lambda current_window: None,
|
||
),
|
||
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,
|
||
"_schedule_sidebar_search_after_interactive",
|
||
side_effect=lambda current_window: None,
|
||
),
|
||
patch.object(
|
||
sidebar_builder,
|
||
"_schedule_hidden_mode_nav_items_after_interactive",
|
||
side_effect=lambda current_window: None,
|
||
),
|
||
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_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_is_delayed_until_interactive_ready(self) -> None:
|
||
import ui.navigation.sidebar_builder as sidebar_builder
|
||
|
||
class Signal:
|
||
def __init__(self) -> None:
|
||
self._callbacks = []
|
||
|
||
def connect(self, callback) -> None:
|
||
self._callbacks.append(callback)
|
||
|
||
def emit(self) -> None:
|
||
for callback in list(self._callbacks):
|
||
callback("ui_ready")
|
||
|
||
signal = Signal()
|
||
window = SimpleNamespace(
|
||
ui_session=SimpleNamespace(
|
||
sidebar_search_widget_cls=object,
|
||
),
|
||
startup_state=SimpleNamespace(interactive_logged=False),
|
||
startup_interactive_ready=signal,
|
||
)
|
||
scheduled = []
|
||
installed = []
|
||
|
||
with (
|
||
patch.object(
|
||
sidebar_builder.QTimer,
|
||
"singleShot",
|
||
side_effect=lambda delay_ms, callback: scheduled.append((int(delay_ms), callback)),
|
||
),
|
||
patch.object(
|
||
sidebar_builder,
|
||
"_install_sidebar_search",
|
||
side_effect=lambda current_window: installed.append(current_window),
|
||
),
|
||
):
|
||
sidebar_builder._schedule_sidebar_search_after_interactive(window)
|
||
self.assertEqual(scheduled, [])
|
||
self.assertEqual(installed, [])
|
||
|
||
signal.emit()
|
||
|
||
self.assertEqual(len(scheduled), 1)
|
||
self.assertEqual(scheduled[0][0], sidebar_builder.SIDEBAR_SEARCH_AFTER_INTERACTIVE_MS)
|
||
self.assertEqual(installed, [])
|
||
|
||
scheduled[0][1]()
|
||
|
||
self.assertEqual(installed, [window])
|
||
|
||
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()
|
||
|
||
def test_hidden_mode_sidebar_items_are_delayed_until_interactive_ready(self) -> None:
|
||
import ui.navigation.sidebar_builder as sidebar_builder
|
||
|
||
class Signal:
|
||
def __init__(self) -> None:
|
||
self._callbacks = []
|
||
|
||
def connect(self, callback) -> None:
|
||
self._callbacks.append(callback)
|
||
|
||
def emit(self) -> None:
|
||
for callback in list(self._callbacks):
|
||
callback("ui_ready")
|
||
|
||
signal = Signal()
|
||
window = SimpleNamespace(
|
||
ui_session=SimpleNamespace(),
|
||
startup_state=SimpleNamespace(interactive_logged=False),
|
||
startup_interactive_ready=signal,
|
||
log_startup_metric=Mock(),
|
||
)
|
||
scheduled = []
|
||
installed = []
|
||
|
||
with (
|
||
patch.object(
|
||
sidebar_builder.QTimer,
|
||
"singleShot",
|
||
side_effect=lambda delay_ms, callback: scheduled.append((int(delay_ms), callback)),
|
||
),
|
||
patch.object(
|
||
sidebar_builder,
|
||
"_install_hidden_mode_nav_items",
|
||
side_effect=lambda current_window: installed.append(current_window),
|
||
),
|
||
):
|
||
sidebar_builder._schedule_hidden_mode_nav_items_after_interactive(window)
|
||
self.assertEqual(scheduled, [])
|
||
self.assertEqual(installed, [])
|
||
|
||
signal.emit()
|
||
|
||
self.assertEqual(len(scheduled), 1)
|
||
self.assertEqual(scheduled[0][0], sidebar_builder.SIDEBAR_HIDDEN_MODE_ITEMS_AFTER_INTERACTIVE_MS)
|
||
self.assertEqual(installed, [])
|
||
|
||
scheduled[0][1]()
|
||
|
||
self.assertEqual(installed, [window])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|