All checks were successful
Zapret source guards / validate (push) Successful in 15s
Ядро блочной сборки (ui/block_build, BasePage): - время на открытие считается от щелчка: если сборка страницы уже заняла полкадра, блоки первого экрана собираются после первого кадра, по одному присесту между перерисовками, и выплывают в свой черёд; - блок можно положить внутрь вкладки или панели (layout=...); блок скрытой вкладки ждёт, пока её откроют; - блок объявляет атрибуты, которые создаёт (provides=...): обращение к виджету несобранного блока достраивает блок на месте. Страницы: - Telegram Proxy: карточка состояния с кнопкой запуска — сразу, настройки и hosts — блоками; загруженные настройки и запрет полей блок получает при сборке; - продвинутые настройки Telegram Proxy: три группы — три блока; - настройка профиля: экран «Подробности о стратегии» собирается при первом открытии. Появление карточек: - блок больше не пропадает на один кадр в конце полёта; - блок, собранный после показа страницы, не появляется раньше карточек над ним. Анимации: глобус на вкладке KVN и новогодние эффекты берут кадры у общего такта и стоят при заблокированном сеансе и выключенном экране. Замер на стенде, от щелчка до первого кадра: Telegram Proxy 52 → 23 мс, продвинутые настройки 43 → 22 мс, настройка профиля 45 → 35 мс. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
370 lines
16 KiB
Python
370 lines
16 KiB
Python
"""Блочное появление: один снимок блока, один слой, общий такт кадров.
|
||
|
||
Проверяется то, ради чего появление переписано (см. ui.widgets.stagger_float_in):
|
||
блок не перерисовывается в полёте, снимается только видимая его часть,
|
||
невидимое не выплывает вовсе, а спрятанный на время полёта блок всегда
|
||
возвращается на место — даже когда такт кадров стоит.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import time
|
||
import unittest
|
||
from unittest import mock
|
||
|
||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||
|
||
from PyQt6.QtCore import QTimer
|
||
from PyQt6.QtGui import QColor, QPainter
|
||
from PyQt6.QtWidgets import QApplication, QGraphicsOpacityEffect, QLabel, QScrollArea, QVBoxLayout, QWidget
|
||
|
||
import ui.widgets.stagger_float_in as float_module
|
||
from ui.frame_clock import frame_clock
|
||
from ui.widgets.stagger_float_in import (
|
||
attach_stagger_float_in,
|
||
float_in,
|
||
float_in_progress_of,
|
||
is_floating_in,
|
||
)
|
||
|
||
|
||
def _wait(seconds: float) -> None:
|
||
deadline = time.monotonic() + seconds
|
||
while time.monotonic() < deadline:
|
||
QApplication.processEvents()
|
||
|
||
|
||
_FLIGHT_S = float_module.FLOAT_IN_DURATION_MS / 1000.0
|
||
|
||
|
||
class _Card(QWidget):
|
||
"""Одноцветная карточка, которая считает свои перерисовки."""
|
||
|
||
def __init__(self, color: str, height: int = 60) -> None:
|
||
super().__init__()
|
||
self._color = QColor(color)
|
||
self.paints = 0
|
||
self.setFixedHeight(height)
|
||
|
||
def paintEvent(self, event) -> None: # noqa: N802
|
||
self.paints += 1
|
||
painter = QPainter(self)
|
||
painter.fillRect(self.rect(), self._color)
|
||
painter.end()
|
||
|
||
|
||
class BlockFloatInTests(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)
|
||
|
||
def _container(self, *cards: QWidget, size=(320, 400)) -> QWidget:
|
||
container = QWidget()
|
||
self.addCleanup(container.deleteLater)
|
||
layout = QVBoxLayout(container)
|
||
for card in cards:
|
||
layout.addWidget(card)
|
||
layout.addStretch(1)
|
||
container.resize(*size)
|
||
return container
|
||
|
||
def test_flight_draws_the_snapshot_and_hides_the_real_block(self) -> None:
|
||
card = _Card("#ff0000")
|
||
container = self._container(card)
|
||
attach_stagger_float_in(container)
|
||
container.show()
|
||
_wait(_FLIGHT_S * 0.5)
|
||
|
||
self.assertTrue(is_floating_in(card))
|
||
self.assertIsNone(card.graphicsEffect())
|
||
self.assertFalse(card.mask().isEmpty(), "настоящий блок на время полёта спрятан маской")
|
||
# В середине полёта на месте блока полупрозрачная картинка: красного
|
||
# уже много, но это ещё не сплошной красный.
|
||
pixel = container.grab().toImage().pixelColor(card.geometry().center())
|
||
self.assertGreater(pixel.red(), pixel.green())
|
||
self.assertGreater(pixel.green(), 0)
|
||
|
||
_wait(_FLIGHT_S * 0.5 + 0.2)
|
||
self.assertFalse(is_floating_in(card))
|
||
self.assertTrue(card.mask().isEmpty())
|
||
pixel = container.grab().toImage().pixelColor(card.geometry().center())
|
||
self.assertEqual((pixel.red(), pixel.green(), pixel.blue()), (255, 0, 0))
|
||
|
||
def test_layer_and_its_objects_are_gone_after_the_flight(self) -> None:
|
||
cards = [_Card("#336699") for _ in range(3)]
|
||
container = self._container(*cards)
|
||
controller = attach_stagger_float_in(container)
|
||
container.show()
|
||
_wait(0.05)
|
||
self.assertIsNotNone(container.findChild(QWidget, "floatInOverlay"))
|
||
|
||
_wait(_FLIGHT_S + 3 * float_module.FLOAT_IN_STEP_MS / 1000 + 0.2)
|
||
self.assertFalse(controller.is_running())
|
||
self.assertIsNone(container.findChild(QWidget, "floatInOverlay"))
|
||
self.assertFalse(float_module._ENGINES, "слой появления живёт только во время полёта")
|
||
# Весь полёт — один снимок и одна перерисовка на место; уход слоя
|
||
# не перерисовывает страницу заново.
|
||
self.assertEqual([card.paints for card in cards], [cards[0].paints] * 3)
|
||
|
||
def test_landing_does_not_repaint_blocks_that_already_landed(self) -> None:
|
||
cards = [_Card("#336699") for _ in range(3)]
|
||
container = self._container(*cards)
|
||
attach_stagger_float_in(container)
|
||
container.show()
|
||
QApplication.processEvents()
|
||
for card in cards:
|
||
card.paints = 0
|
||
_wait(_FLIGHT_S + 3 * float_module.FLOAT_IN_STEP_MS / 1000 + 0.2)
|
||
self.assertEqual([card.paints for card in cards], [2, 2, 2])
|
||
|
||
def test_only_the_visible_part_of_a_tall_block_is_snapshotted(self) -> None:
|
||
# Сетка серверов DNS: 1880 точек в высоту, видно четыре плитки. Раньше
|
||
# эффект снимал её целиком каждые 0,12 с.
|
||
scroll = QScrollArea()
|
||
self.addCleanup(scroll.deleteLater)
|
||
scroll.setWidgetResizable(True)
|
||
content = QWidget()
|
||
layout = QVBoxLayout(content)
|
||
tall = _Card("#00aa00", height=3000)
|
||
layout.addWidget(tall)
|
||
scroll.setWidget(content)
|
||
attach_stagger_float_in(content)
|
||
scroll.resize(300, 200)
|
||
scroll.show()
|
||
_wait(0.1)
|
||
|
||
engine = float_module._engine_of(content, create=False)
|
||
self.assertIsNotNone(engine)
|
||
block = engine.block_of(tall)
|
||
self.assertIsNotNone(block.pixmap)
|
||
self.assertLessEqual(block.pixmap.height(), 200)
|
||
self.assertGreater(block.pixmap.height(), 100)
|
||
|
||
def test_row_below_the_window_edge_lands_without_a_flight(self) -> None:
|
||
# Подбор стратегий: 200 строк приходят разом, видно десяток.
|
||
scroll = QScrollArea()
|
||
self.addCleanup(scroll.deleteLater)
|
||
scroll.setWidgetResizable(True)
|
||
content = QWidget()
|
||
layout = QVBoxLayout(content)
|
||
rows = [_Card("#888888", height=40) for _ in range(30)]
|
||
for row in rows:
|
||
layout.addWidget(row)
|
||
scroll.setWidget(content)
|
||
scroll.resize(300, 200)
|
||
scroll.show()
|
||
QApplication.processEvents()
|
||
for row in rows:
|
||
row.paints = 0
|
||
self.assertTrue(float_in(row))
|
||
_wait(0.08)
|
||
|
||
self.assertTrue(is_floating_in(rows[0]))
|
||
self.assertFalse(is_floating_in(rows[-1]))
|
||
self.assertTrue(rows[-1].mask().isEmpty())
|
||
self.assertEqual(rows[-1].paints, 0, "невидимую строку незачем снимать в картинку")
|
||
|
||
def test_resized_block_lands_at_once(self) -> None:
|
||
card = _Card("#ff0000")
|
||
container = self._container(card)
|
||
attach_stagger_float_in(container)
|
||
container.show()
|
||
_wait(0.08)
|
||
self.assertTrue(is_floating_in(card))
|
||
|
||
card.setFixedHeight(120)
|
||
QApplication.processEvents()
|
||
self.assertFalse(is_floating_in(card), "картинка устарела — блок встаёт на место")
|
||
self.assertTrue(card.mask().isEmpty())
|
||
|
||
def test_moved_block_keeps_flying(self) -> None:
|
||
# Выше появилось содержимое: блок сдвинулся, но картинка та же.
|
||
first = _Card("#ff0000")
|
||
second = _Card("#0000ff")
|
||
container = self._container(first, second)
|
||
attach_stagger_float_in(container)
|
||
container.show()
|
||
_wait(0.12)
|
||
self.assertTrue(is_floating_in(second))
|
||
old_top = second.y()
|
||
|
||
first.setFixedHeight(100)
|
||
QApplication.processEvents()
|
||
self.assertGreater(second.y(), old_top)
|
||
self.assertTrue(is_floating_in(second))
|
||
engine = float_module._engine_of(container, create=False)
|
||
self.assertEqual(engine.block_of(second).rect.top(), second.y())
|
||
|
||
def test_block_lands_even_when_the_frame_clock_is_paused(self) -> None:
|
||
# Сеанс заблокирован или дисплей выключен: такт стоит. Блок не должен
|
||
# остаться спрятанным.
|
||
card = _Card("#ff0000")
|
||
container = self._container(card)
|
||
attach_stagger_float_in(container)
|
||
container.show()
|
||
_wait(0.05)
|
||
self.assertTrue(is_floating_in(card))
|
||
|
||
clock = frame_clock()
|
||
clock.set_paused("test", True)
|
||
self.addCleanup(clock.set_paused, "test", False)
|
||
_wait(_FLIGHT_S + float_module._DEADLINE_MARGIN_MS / 1000 + 0.3)
|
||
self.assertFalse(is_floating_in(card))
|
||
self.assertTrue(card.mask().isEmpty())
|
||
|
||
def test_nothing_starts_while_the_screen_is_not_seen(self) -> None:
|
||
clock = frame_clock()
|
||
clock.set_paused("test", True)
|
||
self.addCleanup(clock.set_paused, "test", False)
|
||
card = _Card("#ff0000")
|
||
container = self._container(card)
|
||
controller = attach_stagger_float_in(container)
|
||
container.show()
|
||
_wait(0.05)
|
||
self.assertFalse(controller.is_running())
|
||
self.assertTrue(card.mask().isEmpty())
|
||
|
||
def test_single_widget_floats_in_with_delay(self) -> None:
|
||
card = _Card("#ff0000")
|
||
container = self._container(card)
|
||
container.show()
|
||
QApplication.processEvents()
|
||
|
||
self.assertTrue(float_in(card, delay_ms=150))
|
||
_wait(0.05)
|
||
self.assertEqual(float_in_progress_of(card), 0.0)
|
||
_wait(0.2)
|
||
self.assertGreater(float_in_progress_of(card), 0.0)
|
||
|
||
def test_asking_again_restarts_the_flight(self) -> None:
|
||
card = _Card("#ff0000")
|
||
container = self._container(card)
|
||
container.show()
|
||
QApplication.processEvents()
|
||
self.assertTrue(float_in(card))
|
||
_wait(_FLIGHT_S * 0.7)
|
||
first = float_in_progress_of(card)
|
||
self.assertTrue(float_in(card), "повторная просьба не должна отказывать из-за нашей же маски")
|
||
_wait(0.03)
|
||
self.assertLess(float_in_progress_of(card), first)
|
||
_wait(_FLIGHT_S + 0.2)
|
||
self.assertTrue(card.mask().isEmpty())
|
||
|
||
def test_widget_without_a_parent_or_with_its_own_effect_is_left_alone(self) -> None:
|
||
loose = QLabel("без родителя")
|
||
self.addCleanup(loose.deleteLater)
|
||
self.assertFalse(float_in(loose))
|
||
|
||
card = _Card("#ff0000")
|
||
container = self._container(card)
|
||
card.setGraphicsEffect(QGraphicsOpacityEffect(card))
|
||
container.show()
|
||
QApplication.processEvents()
|
||
self.assertFalse(float_in(card))
|
||
self.assertTrue(card.mask().isEmpty())
|
||
|
||
def test_content_that_arrived_before_the_blocks_turn_gets_into_the_snapshot(self) -> None:
|
||
# Снимок делается, когда до блока дошла очередь, а не при показе.
|
||
class _Late(_Card):
|
||
pass
|
||
|
||
card = _Late("#ff0000")
|
||
container = self._container(card)
|
||
container.show()
|
||
QApplication.processEvents()
|
||
self.assertTrue(float_in(card, delay_ms=120))
|
||
QTimer.singleShot(30, lambda: setattr(card, "_color", QColor("#00ff00")))
|
||
_wait(0.12 + _FLIGHT_S * 0.6)
|
||
|
||
self.assertTrue(is_floating_in(card))
|
||
pixel = container.grab().toImage().pixelColor(card.geometry().center())
|
||
self.assertGreater(pixel.green(), pixel.red())
|
||
|
||
def test_block_does_not_vanish_between_the_end_of_the_flight_and_landing(self) -> None:
|
||
# Полёт закончился, а такт кадров ещё не пришёл открыть настоящий
|
||
# блок: картинка должна стоять на месте, иначе блок на кадр пропадает.
|
||
card = _Card("#ff0000")
|
||
container = self._container(card)
|
||
container.show()
|
||
_wait(0.05)
|
||
float_in(card)
|
||
_wait(0.05)
|
||
self.assertTrue(is_floating_in(card))
|
||
|
||
engine = float_module._engine_of(container, create=False)
|
||
block = engine.block_of(card)
|
||
block.starts_at = time.monotonic() - _FLIGHT_S - 0.05
|
||
# Ни одного оборота цикла событий: кадр такта ещё не пришёл.
|
||
self.assertFalse(card.mask().isEmpty(), "настоящий блок пока спрятан")
|
||
image = container.grab().toImage()
|
||
center = card.geometry().center()
|
||
self.assertEqual(image.pixelColor(center).name(), "#ff0000")
|
||
|
||
def test_group_built_after_the_show_takes_its_place_in_the_queue(self) -> None:
|
||
# Блок первого экрана, собранный на кадр позже страницы, не должен
|
||
# появиться раньше карточек над ним.
|
||
cards = [_Card("#202020") for _ in range(4)]
|
||
group = QWidget()
|
||
float_module.float_in_by_parts(group)
|
||
group_layout = QVBoxLayout(group)
|
||
group_layout.setContentsMargins(0, 0, 0, 0)
|
||
container = self._container(*cards, group, size=(320, 600))
|
||
attach_stagger_float_in(container)
|
||
container.show()
|
||
_wait(0.03)
|
||
|
||
late = [_Card("#404040"), _Card("#404040")]
|
||
for card in late:
|
||
group_layout.addWidget(card)
|
||
float_module.float_in_group(group)
|
||
|
||
engine = float_module._engine_of(group, create=False)
|
||
starts = [engine.block_of(card).starts_at for card in late]
|
||
above = float_module._engine_of(container, create=False).block_of(cards[-1]).starts_at
|
||
step = float_module.FLOAT_IN_STEP_MS / 1000.0
|
||
self.assertAlmostEqual(starts[0] - above, step, delta=0.03, msg="первый виджет группы идёт следом за карточкой над ним")
|
||
self.assertAlmostEqual(starts[1] - starts[0], step, delta=0.01)
|
||
|
||
def test_group_built_long_after_the_show_starts_at_once(self) -> None:
|
||
cards = [_Card("#202020") for _ in range(4)]
|
||
group = QWidget()
|
||
float_module.float_in_by_parts(group)
|
||
group_layout = QVBoxLayout(group)
|
||
group_layout.setContentsMargins(0, 0, 0, 0)
|
||
container = self._container(*cards, group, size=(320, 600))
|
||
controller = attach_stagger_float_in(container)
|
||
container.show()
|
||
_wait(0.03)
|
||
# Очередь страницы давно отыграла: до блока долистали.
|
||
controller._played_at -= 5.0
|
||
|
||
late = _Card("#404040")
|
||
group_layout.addWidget(late)
|
||
before = time.monotonic()
|
||
float_module.float_in_group(group)
|
||
|
||
start = float_module._engine_of(group, create=False).block_of(late).starts_at
|
||
self.assertLess(start - before, 0.02)
|
||
|
||
def test_deleted_block_is_forgotten(self) -> None:
|
||
card = _Card("#ff0000")
|
||
keep = _Card("#0000ff")
|
||
container = self._container(card, keep)
|
||
controller = attach_stagger_float_in(container)
|
||
container.show()
|
||
_wait(0.05)
|
||
card.setParent(None)
|
||
card.deleteLater()
|
||
_wait(_FLIGHT_S + 0.4)
|
||
self.assertFalse(controller.is_running())
|
||
self.assertTrue(keep.mask().isEmpty())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|