zapretgui/tests/test_qfluent_style_cache.py
loop-uh fa7fb76f3f
All checks were successful
Zapret source guards / validate (push) Successful in 9s
Windows-сборка / Dev Dev 21.1.7.92 опубликован
Сборка страниц ещё на треть быстрее: убраны перехватчики событий на каждом элементе, «Оформление» достраивает нижние разделы позже
Продолжение блочной сборки (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>
2026-10-08 16:31:34 +03:00

224 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Запас готовых текстов стилей qfluentwidgets отдаёт тот же текст, что библиотека."""
from __future__ import annotations
import os
import unittest
from unittest import mock
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import QApplication
import qfluentwidgets.common.style_sheet as fluent_style
from qfluentwidgets import BodyLabel, PushButton, Theme, setCustomStyleSheet
from qfluentwidgets.common.config import qconfig
import ui.qfluent_style_cache as style_cache
class QfluentStyleCacheTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls._app = QApplication.instance() or QApplication([])
# Библиотечные функции без запаса — с ними сверяем результат.
cls._plain_read = staticmethod(fluent_style.getStyleSheetFromFile)
cls._plain_render = staticmethod(fluent_style.renderQss)
cls._plain_register = fluent_style.StyleSheetManager.register
cls._plain_set_light = fluent_style.CustomStyleSheet.setLightStyleSheet
cls._plain_set_dark = fluent_style.CustomStyleSheet.setDarkStyleSheet
cls._plain_update = staticmethod(fluent_style.updateStyleSheet)
if getattr(fluent_style.renderQss, style_cache._PATCH_MARK, False):
raise unittest.SkipTest("запас стилей уже установлен другим тестом: сверять не с чем")
style_cache.install_qfluent_style_cache()
@classmethod
def tearDownClass(cls) -> None:
fluent_style.getStyleSheetFromFile = cls._plain_read
fluent_style.renderQss = cls._plain_render
fluent_style.StyleSheetManager.register = cls._plain_register
fluent_style.CustomStyleSheet.setLightStyleSheet = cls._plain_set_light
fluent_style.CustomStyleSheet.setDarkStyleSheet = cls._plain_set_dark
fluent_style.updateStyleSheet = cls._plain_update
style_cache.clear_qfluent_style_cache()
def setUp(self) -> None:
style_cache.clear_qfluent_style_cache()
def _set_theme(self, theme: Theme) -> None:
previous = qconfig.theme
qconfig.theme = theme
self.addCleanup(setattr, qconfig, "theme", previous)
def _set_theme_color(self, color: str) -> None:
item = qconfig._cfg.themeColor
previous = QColor(item.value)
item.value = QColor(color)
self.addCleanup(setattr, item, "value", previous)
def test_every_library_sheet_is_the_same_text_in_both_themes(self) -> None:
for theme in (Theme.LIGHT, Theme.DARK):
self._set_theme(theme)
for sheet in fluent_style.FluentStyleSheet:
with self.subTest(theme=theme.value, sheet=sheet.value):
expected = self._plain_render(self._plain_read(sheet.path(theme)))
self.assertEqual(fluent_style.getStyleSheet(sheet, theme), expected)
# Второй раз — уже из запаса.
self.assertEqual(fluent_style.getStyleSheet(sheet, theme), expected)
def test_file_is_read_once_and_rendered_once(self) -> None:
self._set_theme(Theme.DARK)
class _CountingFile(fluent_style.QFile):
opened = 0
def __init__(self, *args) -> None:
type(self).opened += 1
super().__init__(*args)
with mock.patch.object(fluent_style, "QFile", _CountingFile):
for _ in range(20):
label = BodyLabel("текст")
self.addCleanup(label.deleteLater)
self.assertEqual(_CountingFile.opened, 1, "файл стиля надписи читается один раз")
# Готовых текстов столько, сколько разных видов надписи, а не надписей.
rendered = len(style_cache._rendered)
for _ in range(20):
label = BodyLabel("ещё текст")
self.addCleanup(label.deleteLater)
self.assertEqual(len(style_cache._rendered), rendered)
self.assertEqual(_CountingFile.opened, 1)
def test_changed_theme_color_gives_a_new_text(self) -> None:
self._set_theme(Theme.DARK)
sheet = fluent_style.FluentStyleSheet.BUTTON
self._set_theme_color("#009faa")
first = fluent_style.getStyleSheet(sheet)
self._set_theme_color("#ff5500")
second = fluent_style.getStyleSheet(sheet)
self.assertNotEqual(first, second, "после смены цвета темы нельзя отдавать старый текст")
self.assertEqual(second, self._plain_render(self._plain_read(sheet.path())))
def test_changed_theme_gives_the_other_file(self) -> None:
sheet = fluent_style.FluentStyleSheet.BUTTON
self._set_theme(Theme.LIGHT)
light = fluent_style.getStyleSheet(sheet)
self._set_theme(Theme.DARK)
dark = fluent_style.getStyleSheet(sheet)
self.assertNotEqual(light, dark)
def test_widget_custom_sheet_still_reaches_the_widget(self) -> None:
self._set_theme(Theme.DARK)
button = PushButton("кнопка")
self.addCleanup(button.deleteLater)
setCustomStyleSheet(button, "PushButton { color: #111111; }", "PushButton { color: #eeeeee; }")
self.assertIn("#eeeeee", button.styleSheet())
self.assertNotIn("--ThemeColor", button.styleSheet())
def test_files_from_disk_are_not_remembered(self) -> None:
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "own.qss"
path.write_text("QWidget { color: red; }", encoding="utf-8")
self.assertIn("red", fluent_style.getStyleSheetFromFile(str(path)))
path.write_text("QWidget { color: blue; }", encoding="utf-8")
self.assertIn("blue", fluent_style.getStyleSheetFromFile(str(path)))
self.assertFalse(style_cache._file_texts)
def test_rendered_texts_are_bounded(self) -> None:
with mock.patch.object(style_cache, "_MAX_RENDERED", 8):
for index in range(40):
fluent_style.renderQss(f"QWidget {{ margin: {index}px; }}")
self.assertLessEqual(len(style_cache._rendered), 8)
def test_styled_widget_is_registered_without_a_destroyed_subscription(self) -> None:
# Библиотека подключала сигнал destroyed у каждого виджета со стилем.
# Подключение отпускает общий замок Python: рядом с занятым фоновым
# потоком это было самым дорогим местом сборки страницы.
from PyQt6.QtWidgets import QWidget
widget = QWidget()
self.addCleanup(widget.deleteLater)
before = widget.receivers(widget.destroyed)
fluent_style.setStyleSheet(widget, fluent_style.FluentStyleSheet.BUTTON)
self.assertEqual(widget.receivers(widget.destroyed), before)
self.assertIn(widget, fluent_style.styleSheetManager.widgets)
self.assertTrue(widget.styleSheet())
def test_theme_update_still_restyles_live_widgets_and_drops_dead_ones(self) -> None:
from PyQt6 import sip
self._set_theme(Theme.DARK)
alive = PushButton("живая")
self.addCleanup(alive.deleteLater)
dead = PushButton("удалённая")
sip.delete(dead)
self.assertIn(dead, fluent_style.styleSheetManager.widgets)
qconfig.theme = Theme.LIGHT
fluent_style.updateStyleSheet()
expected = fluent_style.getStyleSheet(fluent_style.FluentStyleSheet.BUTTON, Theme.LIGHT)
self.assertEqual(alive.styleSheet().strip(), expected.strip())
self.assertNotIn(dead, fluent_style.styleSheetManager.widgets, "мёртвую запись библиотека выбрасывает сама")
def test_styled_widget_carries_no_event_filters_of_the_library(self) -> None:
# Два перехватчика на каждом виджете со стилем — это два вызова
# функции на Python на каждое событие виджета (перерисовка, мышь).
button = PushButton("кнопка")
self.addCleanup(button.deleteLater)
label = BodyLabel("надпись")
self.addCleanup(label.deleteLater)
for widget in (button, label):
watchers = [
child
for child in widget.children()
if isinstance(child, (fluent_style.CustomStyleSheetWatcher, fluent_style.DirtyStyleSheetWatcher))
]
self.assertEqual(watchers, [], type(widget).__name__)
def test_custom_sheet_is_applied_where_it_is_set_and_follows_the_theme(self) -> None:
self._set_theme(Theme.DARK)
button = PushButton("кнопка")
self.addCleanup(button.deleteLater)
fluent_style.CustomStyleSheet(button).setDarkStyleSheet("PushButton { color: #abcdef; }")
self.assertIn("#abcdef", button.styleSheet())
fluent_style.CustomStyleSheet(button).setLightStyleSheet("PushButton { color: #123456; }")
self.assertNotIn("#123456", button.styleSheet(), "в тёмной теме действует тёмный вариант")
qconfig.theme = Theme.LIGHT
fluent_style.updateStyleSheet()
self.assertIn("#123456", button.styleSheet())
self.assertNotIn("#abcdef", button.styleSheet())
# Тот же текст повторно стиль не переустанавливает.
before = button.styleSheet()
with mock.patch.object(fluent_style, "addStyleSheet", wraps=fluent_style.addStyleSheet) as applied:
fluent_style.CustomStyleSheet(button).setLightStyleSheet("PushButton { color: #123456; }")
applied.assert_not_called()
self.assertEqual(button.styleSheet(), before)
def test_lazy_theme_update_restyles_hidden_widgets_at_once(self) -> None:
# Ленивое обновление библиотеки ждало перерисовки виджета через свой
# перехватчик. Его больше нет, поэтому стиль обновляется сразу.
self._set_theme(Theme.DARK)
hidden = PushButton("скрытая")
self.addCleanup(hidden.deleteLater)
dark = hidden.styleSheet()
qconfig.theme = Theme.LIGHT
fluent_style.updateStyleSheet(lazy=True)
self.assertNotEqual(hidden.styleSheet(), dark)
self.assertFalse(hidden.property("dirty-qss"))
def test_install_is_idempotent(self) -> None:
patched = fluent_style.renderQss
style_cache.install_qfluent_style_cache()
self.assertIs(fluent_style.renderQss, patched)
if __name__ == "__main__":
unittest.main()