Продолжение блочной сборки (8bfc2dd3).
- Стили qfluentwidgets: у виджета со стилем больше нет двух перехватчиков
событий библиотеки (ждали смены «своего стиля» и «ленивой» смены темы).
Это было два вызова функции на Python на каждое событие каждого такого
виджета. «Свой стиль» применяется там, где его задают; смена темы
обновляет стили сразу (ленивой сменой программа не пользовалась).
- Закрытие выпадающих списков при потере активности слушает один сигнал
приложения вместо перехватчика, через который шло каждое событие каждого
объекта.
- Имя и состояние элемента для экранного диктора: служебные дочерние кнопки
убираются из Tab-порядка за один обход детей вместо четырёх (на главной
335 обходов дерева при сборке → 91).
- «Оформление»: новогоднее оформление, прозрачность, акцентный цвет и
производительность — блок ниже первого экрана (ui.block_build). При
создании страницы строится 52 элемента вместо 135. Блок, собранный позже,
перечитывает настройки: прозрачность меняют и из меню в трее.
- У заглушки блока и слоя появления убрана лишняя установка фокуса
(значение по умолчанию), из-за неё оба файла попадали под проверку
доступности.
Замер на хосте против 21.1.7.85, семь страниц: сборка 255 → 171 мс, показ
75 → 54 мс, открытие рядом с занятым фоновым потоком 5644 → 3478 мс
(«Оформление» 989 → 125 мс).
Тест test_appearance_page_builds_all_sections_in_initial_pass заменён:
решение владельца от 2026-10-08 — не строить сразу то, чего не видно.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
200 lines
8.8 KiB
Python
200 lines
8.8 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
import unittest
|
||
from dataclasses import replace
|
||
from unittest.mock import Mock, patch
|
||
|
||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||
|
||
from PyQt6.QtWidgets import QApplication
|
||
|
||
from app.state_store import AppUiState, MainWindowStateStore
|
||
from settings import appearance as appearance_settings
|
||
|
||
|
||
_CALLBACK_NAMES = (
|
||
"on_garland_changed",
|
||
"on_snowflakes_changed",
|
||
"on_background_refresh_needed",
|
||
"on_background_preset_changed",
|
||
"on_opacity_changed",
|
||
"on_animations_changed",
|
||
"on_smooth_scroll_changed",
|
||
"on_editor_smooth_scroll_changed",
|
||
"on_ui_language_changed",
|
||
"on_sidebar_icon_style_changed",
|
||
)
|
||
|
||
_PREMIUM_SAVE_ACTIONS = {"background_preset", "garland_enabled", "snowflakes_enabled"}
|
||
|
||
|
||
def _clear_appearance_caches() -> None:
|
||
for name in dir(appearance_settings):
|
||
if name.startswith("clear_warmed_") and name.endswith("_cache"):
|
||
getattr(appearance_settings, name)()
|
||
|
||
|
||
class AppearancePageConstructionTests(unittest.TestCase):
|
||
"""Страница строится настоящим __init__ — так ловятся ошибки построения.
|
||
|
||
Именно такого теста не было, когда страница падала при каждом открытии
|
||
из-за распаковки кортежа Premium-статуса.
|
||
"""
|
||
|
||
@classmethod
|
||
def setUpClass(cls) -> None:
|
||
cls._app = QApplication.instance() or QApplication([])
|
||
|
||
def _build_page(
|
||
self,
|
||
state: AppUiState,
|
||
*,
|
||
preset: str = "amoled",
|
||
effects: bool = True,
|
||
build_lower_sections: bool = True,
|
||
):
|
||
from ui.pages.appearance_page import AppearancePage
|
||
|
||
plan = replace(
|
||
appearance_settings.build_default_page_initial_state(),
|
||
background_preset=preset,
|
||
animations_enabled=True,
|
||
garland_enabled=effects,
|
||
snowflakes_enabled=effects,
|
||
)
|
||
_clear_appearance_caches()
|
||
self.addCleanup(_clear_appearance_caches)
|
||
appearance_settings.store_warmed_page_initial_state(plan)
|
||
|
||
store = MainWindowStateStore(state)
|
||
callbacks = {name: Mock() for name in _CALLBACK_NAMES}
|
||
save_patch = patch.object(AppearancePage, "_request_appearance_save")
|
||
save = save_patch.start()
|
||
self.addCleanup(save_patch.stop)
|
||
|
||
page = AppearancePage(
|
||
None,
|
||
**callbacks,
|
||
appearance_feature=Mock(),
|
||
ui_state_store=store,
|
||
)
|
||
self.addCleanup(page.deleteLater)
|
||
self.addCleanup(page.cleanup)
|
||
if build_lower_sections:
|
||
# Разделы ниже первого экрана собираются позже страницы (блок
|
||
# ui.block_build); этим тестам нужна страница целиком.
|
||
page.ensure_all_blocks()
|
||
return page, store, save, callbacks
|
||
|
||
def _premium_saves(self, save: Mock) -> list[str]:
|
||
return [call.args[0] for call in save.call_args_list if call.args and call.args[0] in _PREMIUM_SAVE_ACTIONS]
|
||
|
||
def test_unknown_status_shows_last_saved_premium_appearance(self) -> None:
|
||
page, _store, save, callbacks = self._build_page(AppUiState(subscription_known=False))
|
||
|
||
self.assertTrue(page._bg_radio_amoled.isEnabled())
|
||
self.assertTrue(page._bg_radio_amoled.isChecked())
|
||
self.assertTrue(page._garland_checkbox.isEnabled())
|
||
self.assertTrue(page._garland_checkbox.isChecked())
|
||
self.assertTrue(page._snowflakes_checkbox.isChecked())
|
||
self.assertEqual(self._premium_saves(save), [])
|
||
callbacks["on_background_preset_changed"].assert_not_called()
|
||
|
||
def test_known_free_status_locks_premium_controls_without_saving(self) -> None:
|
||
page, _store, save, callbacks = self._build_page(
|
||
AppUiState(subscription_known=True, subscription_is_premium=False)
|
||
)
|
||
|
||
self.assertFalse(page._bg_radio_amoled.isEnabled())
|
||
self.assertFalse(page._bg_radio_rkn_chan.isEnabled())
|
||
self.assertTrue(page._bg_radio_standard.isChecked())
|
||
self.assertFalse(page._garland_checkbox.isEnabled())
|
||
self.assertFalse(page._garland_checkbox.isChecked())
|
||
self.assertFalse(page._snowflakes_checkbox.isChecked())
|
||
self.assertFalse(page._rkn_background_combo.isEnabled())
|
||
# Сохраняет сброс и применяет фон окно (ui/window_premium_appearance.py).
|
||
self.assertEqual(self._premium_saves(save), [])
|
||
callbacks["on_background_preset_changed"].assert_not_called()
|
||
callbacks["on_garland_changed"].assert_not_called()
|
||
|
||
def test_known_premium_status_keeps_premium_background(self) -> None:
|
||
page, _store, _save, _callbacks = self._build_page(
|
||
AppUiState(subscription_known=True, subscription_is_premium=True),
|
||
preset="rkn_chan",
|
||
)
|
||
|
||
self.assertTrue(page._bg_radio_rkn_chan.isEnabled())
|
||
self.assertTrue(page._bg_radio_rkn_chan.isChecked())
|
||
self.assertTrue(page._garland_checkbox.isEnabled())
|
||
|
||
def test_subscription_answer_after_construction_updates_controls(self) -> None:
|
||
page, store, save, callbacks = self._build_page(AppUiState(subscription_known=False))
|
||
|
||
store.set_subscription(False)
|
||
|
||
self.assertFalse(page._bg_radio_amoled.isEnabled())
|
||
self.assertTrue(page._bg_radio_standard.isChecked())
|
||
self.assertFalse(page._garland_checkbox.isChecked())
|
||
self.assertEqual(self._premium_saves(save), [])
|
||
callbacks["on_background_preset_changed"].assert_not_called()
|
||
|
||
def test_lower_sections_are_a_block_built_later_than_the_page(self) -> None:
|
||
from ui.pages.appearance_page import LOWER_SECTIONS_BLOCK, LOWER_SECTIONS_HEIGHT
|
||
|
||
page, _store, _save, _callbacks = self._build_page(
|
||
AppUiState(subscription_known=False),
|
||
build_lower_sections=False,
|
||
)
|
||
# Первый экран готов сразу.
|
||
self.assertIsNotNone(page._display_mode_card)
|
||
self.assertIsNotNone(page._bg_radio_amoled)
|
||
# Нижние разделы ещё не собраны, но место под них занято.
|
||
for name in ("_garland_checkbox", "_snowflakes_checkbox", "_opacity_row", "_accent_group", "_performance_group"):
|
||
self.assertIsNone(getattr(page, name), name)
|
||
block = page.lazy_block(LOWER_SECTIONS_BLOCK)
|
||
self.assertFalse(block.is_built())
|
||
self.assertEqual(block.minimumHeight(), LOWER_SECTIONS_HEIGHT)
|
||
|
||
# Экскурсии нужен раздел из блока — она достраивает его сама.
|
||
self.assertIsNotNone(page.onboarding_target("theme"))
|
||
self.assertFalse(block.is_built(), "цель первого экрана блок не достраивает")
|
||
self.assertIsNotNone(page.onboarding_target("performance"))
|
||
self.assertTrue(block.is_built())
|
||
self.assertIsNotNone(page._garland_checkbox)
|
||
real = block.sizeHint().height()
|
||
self.assertLess(abs(real - LOWER_SECTIONS_HEIGHT), 120, f"настоящая высота блока {real}")
|
||
|
||
def test_block_built_after_the_subscription_answer_shows_the_current_state(self) -> None:
|
||
# Ответ о подписке пришёл, пока нижние разделы ещё не собраны: когда
|
||
# блок соберётся, он должен показать уже новое состояние.
|
||
page, store, save, callbacks = self._build_page(
|
||
AppUiState(subscription_known=False),
|
||
build_lower_sections=False,
|
||
)
|
||
store.set_subscription(False)
|
||
self.assertIsNone(page._garland_checkbox)
|
||
|
||
page.ensure_all_blocks()
|
||
self.assertFalse(page._garland_checkbox.isEnabled())
|
||
self.assertFalse(page._garland_checkbox.isChecked())
|
||
self.assertFalse(page._snowflakes_checkbox.isChecked())
|
||
self.assertEqual(self._premium_saves(save), [])
|
||
callbacks["on_garland_changed"].assert_not_called()
|
||
|
||
def test_block_built_later_reads_settings_changed_meanwhile(self) -> None:
|
||
# Прозрачность меняют и из меню в трее: блок, собранный позже, берёт
|
||
# текущие настройки, а не те, что были при создании страницы.
|
||
page, store, _save, _callbacks = self._build_page(
|
||
AppUiState(subscription_known=True, subscription_is_premium=True),
|
||
build_lower_sections=False,
|
||
)
|
||
fresh = replace(appearance_settings.load_page_initial_state(), animations_enabled=False)
|
||
appearance_settings.store_warmed_page_initial_state(fresh)
|
||
|
||
page.ensure_all_blocks()
|
||
self.assertFalse(page._animations_switch.isChecked())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|