Владелец попросил (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>
223 lines
8.8 KiB
Python
223 lines
8.8 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
import time
|
||
import unittest
|
||
from unittest import mock
|
||
|
||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||
|
||
from PyQt6.QtWidgets import QApplication, QLabel, QScrollArea, QVBoxLayout, QWidget
|
||
|
||
import ui.widgets.stagger_float_in as float_module
|
||
import ui.widgets.turning_globe as globe_module
|
||
from ui.widgets.stagger_float_in import (
|
||
attach_stagger_float_in,
|
||
float_in_progress_of,
|
||
is_floating_in,
|
||
skip_float_in,
|
||
)
|
||
from ui.widgets.turning_globe import GLOBE_ICONS, TurningGlobe
|
||
|
||
|
||
def _wait(seconds: float) -> None:
|
||
deadline = time.monotonic() + seconds
|
||
while time.monotonic() < deadline:
|
||
QApplication.processEvents()
|
||
|
||
|
||
class StaggerFloatInTests(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls) -> None:
|
||
cls._app = QApplication.instance() or QApplication([])
|
||
|
||
def setUp(self) -> None:
|
||
patcher = mock.patch.object(float_module, "are_live_animations_enabled", return_value=True)
|
||
patcher.start()
|
||
self.addCleanup(patcher.stop)
|
||
self.container = QWidget()
|
||
self.addCleanup(self.container.deleteLater)
|
||
layout = QVBoxLayout(self.container)
|
||
self.cards = [QLabel(f"card {index}") for index in range(3)]
|
||
self.own_entrance = skip_float_in(QLabel("motto"))
|
||
layout.addWidget(self.own_entrance)
|
||
for card in self.cards:
|
||
layout.addWidget(card)
|
||
self.container.resize(300, 200)
|
||
self.controller = attach_stagger_float_in(self.container)
|
||
|
||
def test_cards_float_in_one_after_another_then_stand_in_place(self) -> None:
|
||
self.container.show()
|
||
_wait(0.12)
|
||
|
||
self.assertTrue(self.controller.is_running())
|
||
self.assertFalse(is_floating_in(self.own_entrance))
|
||
progress = [float_in_progress_of(card) for card in self.cards]
|
||
self.assertGreater(progress[0], progress[2])
|
||
# Появление рисует общий слой, а не графический эффект на каждой карточке.
|
||
self.assertTrue(all(card.graphicsEffect() is None for card in self.cards))
|
||
self.container.grab()
|
||
|
||
_wait((float_module.FLOAT_IN_DURATION_MS + 3 * float_module.FLOAT_IN_STEP_MS) / 1000 + 0.2)
|
||
self.assertFalse(self.controller.is_running())
|
||
self.assertFalse(any(is_floating_in(card) for card in self.cards))
|
||
# Настоящие карточки снова видны: маска полёта снята.
|
||
self.assertTrue(all(card.mask().isEmpty() for card in self.cards))
|
||
|
||
def test_nothing_floats_when_live_animations_are_off(self) -> None:
|
||
with mock.patch.object(float_module, "are_live_animations_enabled", return_value=False):
|
||
self.container.show()
|
||
_wait(0.05)
|
||
self.assertFalse(self.controller.is_running())
|
||
self.assertFalse(any(is_floating_in(card) for card in self.cards))
|
||
|
||
def test_cards_below_the_window_edge_stay_in_place(self) -> None:
|
||
scroll = QScrollArea()
|
||
self.addCleanup(scroll.deleteLater)
|
||
scroll.setWidgetResizable(True)
|
||
content = QWidget()
|
||
layout = QVBoxLayout(content)
|
||
top = QLabel("top")
|
||
top.setFixedHeight(40)
|
||
below = QLabel("below")
|
||
below.setFixedHeight(40)
|
||
layout.addWidget(top)
|
||
layout.addSpacing(600)
|
||
layout.addWidget(below)
|
||
scroll.setWidget(content)
|
||
controller = attach_stagger_float_in(content)
|
||
scroll.resize(300, 200)
|
||
scroll.show()
|
||
_wait(0.05)
|
||
|
||
self.assertTrue(controller.is_running())
|
||
self.assertTrue(is_floating_in(top))
|
||
self.assertFalse(is_floating_in(below))
|
||
|
||
def test_hiding_finishes_immediately(self) -> None:
|
||
self.container.show()
|
||
_wait(0.05)
|
||
self.container.hide()
|
||
QApplication.processEvents()
|
||
self.assertFalse(self.controller.is_running())
|
||
self.assertFalse(any(is_floating_in(card) for card in self.cards))
|
||
self.assertTrue(all(card.mask().isEmpty() for card in self.cards))
|
||
|
||
|
||
class TurningGlobeTests(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls) -> None:
|
||
cls._app = QApplication.instance() or QApplication([])
|
||
|
||
def test_globe_turns_to_next_continent_and_stops_when_hidden(self) -> None:
|
||
with mock.patch.object(globe_module, "are_live_animations_enabled", return_value=True), \
|
||
mock.patch.object(globe_module, "TURN_PERIOD_MS", 1000), \
|
||
mock.patch.object(globe_module, "TURN_DURATION_MS", 300):
|
||
globe = TurningGlobe(size=32)
|
||
self.addCleanup(globe.deleteLater)
|
||
globe.show()
|
||
self.assertTrue(globe.is_animating())
|
||
self.assertEqual(globe.current_icon(), GLOBE_ICONS[0])
|
||
_wait(1.2)
|
||
globe.grab()
|
||
self.assertEqual(globe.current_icon(), GLOBE_ICONS[1])
|
||
globe.hide()
|
||
self.assertFalse(globe.is_animating())
|
||
|
||
def test_globe_stands_still_when_live_animations_are_off(self) -> None:
|
||
with mock.patch.object(globe_module, "are_live_animations_enabled", return_value=False):
|
||
globe = TurningGlobe(size=32)
|
||
self.addCleanup(globe.deleteLater)
|
||
globe.show()
|
||
self.assertFalse(globe.is_animating())
|
||
|
||
|
||
class KvnLayoutTests(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls) -> None:
|
||
cls._app = QApplication.instance() or QApplication([])
|
||
|
||
def test_groups_do_not_stretch_in_a_tall_tab(self) -> None:
|
||
# Вкладки лежат в QStackedWidget и получают высоту самой длинной;
|
||
# группы KVN не должны делить лишнее место и оставлять дыру.
|
||
from ui.pages.about_page_kvn_build import build_about_page_kvn_content
|
||
from ui.theme import get_theme_tokens
|
||
|
||
tab = QWidget()
|
||
self.addCleanup(tab.deleteLater)
|
||
layout = QVBoxLayout(tab)
|
||
widgets = build_about_page_kvn_content(
|
||
layout,
|
||
tokens=get_theme_tokens(),
|
||
content_parent=tab,
|
||
on_open_kvn_channel=lambda: None,
|
||
on_open_kvn_bot=lambda: None,
|
||
on_open_kvn_github=lambda: None,
|
||
)
|
||
tab.resize(900, 2000)
|
||
tab.show()
|
||
_wait(0.05)
|
||
|
||
gap = widgets.links_group.y() - widgets.features_group.geometry().bottom()
|
||
self.assertLess(gap, 40)
|
||
self.assertLess(widgets.features_group.height(), 260)
|
||
|
||
|
||
class PageOpenFloatInTests(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls) -> None:
|
||
cls._app = QApplication.instance() or QApplication([])
|
||
|
||
def test_every_page_floats_its_cards_in_when_opened(self) -> None:
|
||
from ui.pages.base_page import BasePage
|
||
|
||
with mock.patch.object(float_module, "are_live_animations_enabled", return_value=True):
|
||
page = BasePage("Страница", "Описание")
|
||
self.addCleanup(page.deleteLater)
|
||
card = QLabel("card")
|
||
page.add_widget(card)
|
||
page.resize(500, 400)
|
||
page.show()
|
||
_wait(0.05)
|
||
|
||
self.assertTrue(float_module.stagger_float_in(page.content).is_running())
|
||
self.assertTrue(float_module.is_floating_in(card))
|
||
|
||
page.hide()
|
||
QApplication.processEvents()
|
||
self.assertFalse(float_module.is_floating_in(card))
|
||
|
||
|
||
class AboutPageMotionWiringTests(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls) -> None:
|
||
cls._app = QApplication.instance() or QApplication([])
|
||
|
||
def test_every_about_tab_floats_in_and_premium_star_twinkles(self) -> None:
|
||
from app.state_store import MainWindowStateStore
|
||
from donater.premium_display import PREMIUM_TIERS, TIER_UNKNOWN, PremiumDisplay
|
||
from ui.pages.about_page import AboutPage
|
||
from ui.widgets.stagger_float_in import stagger_float_in
|
||
|
||
page = AboutPage(
|
||
open_premium=lambda: None,
|
||
open_updates=lambda: None,
|
||
create_open_action_worker=lambda *_args, **_kwargs: None,
|
||
ui_state_store=MainWindowStateStore(),
|
||
)
|
||
self.addCleanup(page.cleanup)
|
||
self.addCleanup(page.deleteLater)
|
||
|
||
for tab in (page._about_tab, page._help_tab, page._kvn_tab):
|
||
self.assertIsNotNone(stagger_float_in(tab))
|
||
# Стопку вкладок целиком не двигаем: её карточки выплывают сами.
|
||
self.assertTrue(page.stacked_widget.__dict__.get(float_module.NO_FLOAT_IN_ATTR))
|
||
|
||
page.update_subscription_status(PremiumDisplay(tier=next(iter(PREMIUM_TIERS)), days=37))
|
||
self.assertGreater(page.sub_status_icon._twinkle_interval_ms, 0)
|
||
page.update_subscription_status(PremiumDisplay(tier=TIER_UNKNOWN))
|
||
self.assertEqual(page.sub_status_icon._twinkle_interval_ms, 0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|