zapret-kvn/tests/test_app_tour.py
loop-uh e927d092bb
Some checks failed
Windows project source guards / test (push) Failing after 27s
Панель: кнопка «Добавить в Telegram» рядом с адресом прокси
Пользователь в режиме «Прокси» с выключенным системным прокси не смог
вписать адрес в Telegram и решил, что VPN не работает (16.10.2026). Кнопка
открывает tg://socks?server=127.0.0.1&port=<действующий SOCKS-порт>, и
Telegram сам предлагает подключить прокси. Нет Telegram — плашка с ручной
настройкой. Видна вместе со строкой адреса (подключение в режиме «Прокси»).
Шаг обучения «Адрес прокси и Telegram» подсвечивает строку с кнопкой и
советует ZaStoGram (решение владельца: для Telegram — ZaStoGram, VPN запасной).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 02:06:42 +03:00

623 lines
27 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.

"""Обучающая экскурсия: шаги, размещение карточки и поведение оверлея.
Keep the ``test_app_*`` prefix (QApplication before tests/test_engine_process_stop.py).
"""
from __future__ import annotations
import os
import re
import unittest
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6 import sip
from PyQt6.QtCore import QEvent, QEventLoop, QPoint, QRect, QSize, Qt, QTimer
from PyQt6.QtGui import QKeyEvent, QRegion
from PyQt6.QtWidgets import QApplication, QPushButton, QScrollArea, QVBoxLayout, QWidget
_existing = QApplication.instance()
if _existing is not None and not isinstance(_existing, QApplication):
raise RuntimeError("widget tests need a QApplication")
app = _existing or QApplication([])
from dataclasses import fields
from xray_fluent.profiles.models import AppSettings
from xray_fluent.ui.tour import TOUR_STEPS, TourOverlay, TourStep, active_tour
from xray_fluent.ui.tour.overlay import (
CARD_GAP,
CARD_MARGIN,
PULSE_CYCLES,
PULSE_PERIOD_MS,
place_card,
)
from xray_fluent.ui.tour.steps import on_page
UI_DIR = Path(__file__).resolve().parents[1] / "xray_fluent" / "ui"
#: Где объявлен первый атрибут пути ``on_page`` для каждой страницы.
PAGE_SOURCES = {
"dashboard_page": "dashboard_page.py",
"nodes_page": "nodes_page.py",
"subscriptions_page": "subscriptions_page.py",
"configs_page": "configs_page.py",
"zapret_page": "zapret_page.py",
"logs_page": "logs_page.py",
"settings_page": "settings_page.py",
"updates_page": "updates_page.py",
"about_page": "about_page.py",
}
def _spin(ms: int) -> None:
loop = QEventLoop()
QTimer.singleShot(ms, loop.quit)
loop.exec()
def _key(overlay: TourOverlay, key: Qt.Key) -> None:
overlay.keyPressEvent(QKeyEvent(QEvent.Type.KeyPress, key, Qt.KeyboardModifier.NoModifier))
class StepsTests(unittest.TestCase):
def test_keys_are_unique_and_texts_filled(self) -> None:
keys = [step.key for step in TOUR_STEPS]
self.assertEqual(len(keys), len(set(keys)))
for step in TOUR_STEPS:
self.assertTrue(step.title.strip(), step.key)
self.assertGreater(len(step.body), 60, step.key)
def test_tour_starts_with_intro_and_ends_on_restart_item(self) -> None:
self.assertTrue(TOUR_STEPS[0].hero)
last = TOUR_STEPS[-1]
self.assertIsNotNone(last.target)
self.assertEqual(last.target.__closure__[0].cell_contents, ("tour",))
def test_restart_item_is_registered_in_navigation(self) -> None:
"""Повторный запуск — пункт «Обучение» в меню, а не спрятанная кнопка."""
source = (UI_DIR / "main_window.py").read_text(encoding="utf-8")
block = source[source.index('routeKey="tour"'):]
block = block[: block.index(")\n")]
self.assertIn("selectable=False", block)
self.assertIn("NavigationItemPosition.BOTTOM", block)
# clicked(bool) не должен попасть в automatic: лямбда отбрасывает аргумент.
self.assertIn("onClick=lambda *_args: self._on_tour_nav_clicked()", block)
def test_menu_item_collapses_overlay_menu_before_tour(self) -> None:
from types import SimpleNamespace
from unittest.mock import Mock
from qfluentwidgets import NavigationDisplayMode
MainWindow = _main_window_class()
for mode, collapsed in ((NavigationDisplayMode.MENU, 1), (NavigationDisplayMode.EXPAND, 0)):
panel = SimpleNamespace(displayMode=mode, collapse=Mock())
window = SimpleNamespace(navigationInterface=SimpleNamespace(panel=panel), _start_tour=Mock())
MainWindow._on_tour_nav_clicked(window)
self.assertEqual(panel.collapse.call_count, collapsed, mode)
window._start_tour.assert_called_once_with()
def test_pages_are_main_window_pages(self) -> None:
source = (UI_DIR / "main_window.py").read_text(encoding="utf-8")
for step in TOUR_STEPS:
if step.page is None:
continue
self.assertIn(step.page, PAGE_SOURCES, step.key)
self.assertRegex(source, rf"self\.{step.page} = ", step.key)
def test_routing_sections_exist(self) -> None:
from xray_fluent.ui.configs_page import SECTIONS
known = {key for key, _title, _icon in SECTIONS}
for step in TOUR_STEPS:
if step.section is not None:
self.assertEqual(step.page, "configs_page", step.key)
self.assertIn(step.section, known, step.key)
def test_page_anchors_exist_in_page_source(self) -> None:
"""Переименовали кнопку на странице — шаг не должен молча потерять подсветку."""
sources = {name: (UI_DIR / file).read_text(encoding="utf-8") for name, file in PAGE_SOURCES.items()}
sections = (UI_DIR / "singbox" / "sections.py").read_text(encoding="utf-8")
lists = (UI_DIR / "singbox" / "lists.py").read_text(encoding="utf-8")
checked = 0
for step in TOUR_STEPS:
if step.target is None or step.target.__closure__ is None:
continue
cells = [cell.cell_contents for cell in step.target.__closure__]
page = next((c for c in cells if isinstance(c, str)), None)
paths = next((c for c in cells if isinstance(c, tuple)), ())
if page not in sources:
continue # nav_item: проверяется маршрутами ниже
for path in paths:
first = path.split(".")[0]
self.assertRegex(sources[page], rf"self\.{re.escape(first)}\b[^=\n]*=", f"{step.key}: {path}")
checked += 1
self.assertGreater(checked, 15)
# Вложенные звенья маршрутизации живут в других файлах.
self.assertRegex(sections, r"self\.check_row = ")
self.assertRegex(sections, r"self\.rules = ")
self.assertRegex(lists, r"self\.add_button = ")
def test_nav_routes_match_page_object_names(self) -> None:
source = (UI_DIR / "main_window.py").read_text(encoding="utf-8")
for route in ("subscriptions", "configs", "zapret", "updates"):
self.assertRegex(source, rf'DeferredPage\(\w+, "{route}", self\)')
def test_on_page_walks_attributes_and_dict_keys(self) -> None:
window = QWidget()
page = QWidget(window)
page.button = QPushButton(page)
page.rows = {"tcp": QPushButton(page)}
window.some_page = page
found = on_page("some_page", "button", "rows.tcp", "rows.missing", "nope.deeper")(window)
self.assertEqual(found, [page.button, page.rows["tcp"]])
def test_on_page_first_visible_falls_back_while_main_target_is_hidden(self) -> None:
window = QWidget()
page = QWidget(window)
page.label = QPushButton(page)
page.row = QPushButton(page)
window.some_page = page
target = on_page("some_page", "label", "row", first_visible=True)
window.show()
page.label.hide()
self.assertEqual(target(window), [page.row])
page.label.show()
self.assertEqual(target(window), [page.label])
window.hide()
def test_proxy_address_step_follows_proxy_mode_and_names_telegram(self) -> None:
keys = [step.key for step in TOUR_STEPS]
self.assertEqual(keys.index("proxy_address"), keys.index("mode_proxy") + 1)
step = TOUR_STEPS[keys.index("proxy_address")]
self.assertIn("127.0.0.1:1390", step.body)
self.assertIn("Telegram", step.body)
paths = next(c.cell_contents for c in step.target.__closure__ if isinstance(c.cell_contents, tuple))
self.assertEqual(paths[0], "proxy_address_row")
class SettingsTests(unittest.TestCase):
def test_tour_flag_round_trips_and_defaults_to_false(self) -> None:
self.assertFalse(AppSettings.from_dict({}).tour_done)
# Старые флаги ставились без решения человека: tour_seen (v0.8.10) —
# при автопоказе, tour_banner_closed (v0.8.12) — при начале экскурсии.
self.assertFalse(AppSettings.from_dict({"tour_seen": True}).tour_done)
self.assertFalse(AppSettings.from_dict({"tour_banner_closed": True}).tour_done)
# А tour_banner_dismissed (v0.8.13) — то же решение, что и tour_done.
self.assertTrue(AppSettings.from_dict({"tour_banner_dismissed": True}).tour_done)
self.assertFalse(AppSettings.from_dict({"tour_done": False, "tour_banner_dismissed": True}).tour_done)
settings = AppSettings()
settings.tour_done = True
self.assertTrue(AppSettings.from_dict(settings.to_dict()).tour_done)
def test_settings_page_cannot_roll_the_flag_back(self) -> None:
source = (UI_DIR / "main_window.py").read_text(encoding="utf-8")
block = source[source.index("_WINDOW_OWNED_SETTINGS = tuple("):]
self.assertIn('"tour_done"', block[: block.index(")\n\n")])
self.assertIn("tour_done", {item.name for item in fields(AppSettings)})
class PlaceCardTests(unittest.TestCase):
BOUNDS = QRect(16, 16, 968, 640)
SIZE = QSize(440, 300)
def test_centres_without_target(self) -> None:
rect = place_card(self.BOUNDS, None, self.SIZE)
self.assertEqual(rect.center(), self.BOUNDS.center())
def test_prefers_right_of_target(self) -> None:
hole = QRect(40, 100, 200, 60)
rect = place_card(self.BOUNDS, hole, self.SIZE)
self.assertEqual(rect.left(), hole.right() + 1 + CARD_GAP)
self.assertFalse(rect.intersects(hole))
def test_goes_below_when_right_does_not_fit(self) -> None:
hole = QRect(700, 40, 260, 60)
rect = place_card(self.BOUNDS, hole, self.SIZE)
self.assertGreater(rect.top(), hole.bottom())
self.assertTrue(self.BOUNDS.contains(rect))
def test_goes_left_of_target_in_bottom_right_corner(self) -> None:
hole = QRect(700, 420, 260, 200)
rect = place_card(self.BOUNDS, hole, self.SIZE)
self.assertFalse(rect.intersects(hole))
self.assertTrue(self.BOUNDS.contains(rect))
def test_huge_target_keeps_card_inside_window(self) -> None:
hole = QRect(20, 20, 960, 630)
rect = place_card(self.BOUNDS, hole, self.SIZE)
self.assertTrue(self.BOUNDS.contains(rect))
def test_card_larger_than_window_is_shrunk(self) -> None:
bounds = QRect(16, 16, 300, 200)
rect = place_card(bounds, None, self.SIZE)
self.assertTrue(bounds.contains(rect))
class _FakeWindow(QWidget):
def __init__(self) -> None:
super().__init__()
self.resize(900, 600)
self.opened: list[str] = []
layout = QVBoxLayout(self)
self.first = QPushButton("first", self)
self.area = QScrollArea(self)
self.area.setWidgetResizable(True)
body = QWidget()
body_layout = QVBoxLayout(body)
body_layout.addSpacing(2000)
self.deep = QPushButton("deep", body)
body_layout.addWidget(self.deep)
self.area.setWidget(body)
self.hidden = QPushButton("hidden", self)
self.hidden.hide()
layout.addWidget(self.first)
layout.addWidget(self.area)
def _steps() -> tuple[TourStep, ...]:
return (
TourStep("intro", "Вступление", "Текст вступления", hero=True),
TourStep("first", "Первая", "Текст первой", page="a", target=lambda w: [w.first]),
TourStep("deep", "Глубокая", "Текст глубокой", page="b", target=lambda w: [w.deep]),
TourStep("hidden", "Скрытая", "Текст скрытой", target=lambda w: [w.hidden]),
TourStep("broken", "Сломанная", "Текст сломанной", target=lambda w: [w.no_such_widget]),
)
class OverlayTests(unittest.TestCase):
def setUp(self) -> None:
self.window = _FakeWindow()
self.window.show()
app.processEvents()
self.reasons: list[str] = []
self.overlay = TourOverlay(self.window, _steps())
self.overlay.step_opening.connect(lambda step: self.window.opened.append(step.key))
self.overlay.finished.connect(self.reasons.append)
self.overlay.start()
_spin(30)
def tearDown(self) -> None:
if not sip.isdeleted(self.overlay):
self.overlay.finish("interrupted")
_spin(10)
self.window.hide()
self.window.deleteLater()
app.processEvents()
def _settle(self) -> None:
_spin(700)
def test_covers_window_and_shows_intro_without_spotlight(self) -> None:
self.assertEqual(self.overlay.geometry(), self.window.rect())
self.assertIs(active_tour(self.window), self.overlay)
self.assertIsNone(self.overlay._hole)
self.assertEqual(self.overlay.card.title_label.text(), "Вступление")
self.assertEqual(self.overlay.card.counter_label.text(), "Шаг 1 из 5")
self.assertEqual(self.overlay.card.next_btn.text(), "Начнём")
self.assertFalse(self.overlay.card.back_btn.isVisibleTo(self.overlay.card))
self._settle()
self.assertEqual(self.overlay.card.geometry().center(), self.overlay.rect().center())
def test_step_opens_its_page_and_spotlights_target(self) -> None:
self.overlay.go_next()
self._settle()
self.assertEqual(self.window.opened, ["intro", "first"])
hole = self.overlay._hole.toRect()
target = QRect(self.overlay.mapFromGlobal(self.window.first.mapToGlobal(self.window.first.rect().topLeft())), self.window.first.size())
self.assertTrue(hole.contains(target))
self.assertLessEqual(hole.width(), target.width() + 20)
card = self.overlay.card.geometry()
self.assertFalse(card.intersects(hole))
inner = self.overlay.rect().adjusted(CARD_MARGIN, CARD_MARGIN, -CARD_MARGIN, -CARD_MARGIN)
self.assertTrue(inner.contains(card))
def test_scrolls_to_target_below_the_fold(self) -> None:
self.overlay.go_next()
self.overlay.go_next()
self._settle()
self.assertGreater(self.window.area.verticalScrollBar().value(), 0)
self.assertIsNotNone(self.overlay._hole)
def test_spotlight_follows_window_resize(self) -> None:
self.overlay.go_next()
self._settle()
before = self.overlay._hole.toRect()
self.window.resize(1200, 700)
self._settle()
self.assertEqual(self.overlay.geometry(), self.window.rect())
self.assertGreater(self.overlay._hole.toRect().width(), before.width())
def test_hidden_or_broken_target_falls_back_to_centred_card(self) -> None:
for _ in range(3):
self.overlay.go_next()
self._settle()
self.assertEqual(self.overlay.step.key, "hidden")
self.assertIsNone(self.overlay._hole)
with self.assertLogs("xray_fluent.ui.tour.overlay", level="ERROR") as logs:
self.overlay.go_next()
self._settle()
self.assertEqual(len(logs.records), 1) # не на каждом кадре
self.assertIsNone(self.overlay._hole)
self.assertEqual(self.overlay.card.counter_label.text(), "Шаг 5 из 5")
def test_keyboard_navigation_and_finish(self) -> None:
_key(self.overlay, Qt.Key.Key_Right)
self.assertEqual(self.overlay.index, 1)
_key(self.overlay, Qt.Key.Key_Left)
self.assertEqual(self.overlay.index, 0)
_key(self.overlay, Qt.Key.Key_Left)
self.assertEqual(self.overlay.index, 0)
with self.assertLogs("xray_fluent.ui.tour.overlay", level="ERROR"):
for _ in range(4):
_key(self.overlay, Qt.Key.Key_Return)
self.assertEqual(self.overlay.card.next_btn.text(), "Готово")
self.assertFalse(self.overlay.card.skip_btn.isVisibleTo(self.overlay.card))
_key(self.overlay, Qt.Key.Key_Return)
self.assertEqual(self.reasons, ["done"])
_spin(20)
self.assertIsNone(active_tour(self.window))
def test_escape_skips_once(self) -> None:
_key(self.overlay, Qt.Key.Key_Escape)
self.overlay.go_next()
self.overlay.finish("done")
self.assertEqual(self.reasons, ["skipped"])
def test_hiding_window_ends_tour(self) -> None:
self.window.hide()
_spin(200)
self.assertEqual(self.reasons, ["hidden"])
def test_idle_tour_does_not_repaint(self) -> None:
self.overlay.go_next()
_spin(int(PULSE_CYCLES * PULSE_PERIOD_MS) + 900) # кольцо отыграло и замерло
paints = 0
original = self.overlay.paintEvent
def counting(event) -> None:
nonlocal paints
paints += 1
original(event)
self.overlay.paintEvent = counting
_spin(400)
self.assertEqual(paints, 0)
def test_card_takes_final_size_at_once_so_text_is_not_rewrapped(self) -> None:
"""Карточка едет, но не растягивается: иначе текст «плывёт» на каждом кадре."""
self._settle()
self.overlay.go_next()
sizes = set()
for _ in range(12):
sizes.add((self.overlay.card.width(), self.overlay.card.height()))
_spin(20)
self.assertEqual(len(sizes), 1, sizes)
self._settle()
self.assertIn((self.overlay.card.width(), self.overlay.card.height()), sizes)
def test_step_change_repaints_only_changed_areas(self) -> None:
"""Полная перерисовка оверлея перерисовывает всю страницу под ним — это и есть лаги."""
self._settle() # затемнение проявилось — дальше полных перерисовок быть не должно
# Точка в стороне и от подсветки (вверху окна), и от карточки (по центру и слева).
untouched = QPoint(self.overlay.width() - 6, self.overlay.height() // 2)
requested: list[bool] = []
painted: list[bool] = []
original_update = self.overlay.update
original_paint = self.overlay.paintEvent
def recording_update(*args) -> None:
if not args:
region = QRegion(self.overlay.rect())
elif len(args) == 4:
region = QRegion(*args)
else:
region = QRegion(args[0])
requested.append(region.contains(untouched))
original_update(*args)
def recording_paint(event) -> None:
painted.append(event.region().contains(untouched))
original_paint(event)
self.overlay.update = recording_update
self.overlay.paintEvent = recording_paint
self.overlay.go_next()
self._settle()
# Проверяем запросы самого оверлея: на Windows без рабочего стола (гейт
# выпуска по SSH) окно не экспонировано и paintEvent не приходит вовсе.
self.assertGreater(len(requested), 3)
self.assertFalse(any(requested))
self.assertFalse(any(painted))
def test_content_fades_in_and_effect_is_off_at_rest(self) -> None:
self._settle()
self.overlay.go_next()
_spin(40)
self.assertTrue(self.overlay.card.content_effect.isEnabled())
self._settle()
self.assertFalse(self.overlay.card.content_effect.isEnabled())
def test_announces_next_step_for_prewarming(self) -> None:
upcoming: list[str] = []
self.overlay.step_upcoming.connect(lambda step: upcoming.append(step.key))
self._settle()
self.assertEqual(upcoming, ["first"])
def test_dim_fades_out_after_finish(self) -> None:
self._settle()
self.overlay.skip()
self.assertEqual(self.reasons, ["skipped"])
self.assertIsNone(active_tour(self.window))
self.assertFalse(self.overlay.card.isVisible())
self.assertTrue(self.overlay.isVisible())
_spin(400)
self.assertTrue(sip.isdeleted(self.overlay) or not self.overlay.isVisible())
def test_overlay_holds_no_strong_reference_to_window(self) -> None:
for value in vars(self.overlay).values():
self.assertIsNot(value, self.window)
def _main_window_class():
import ctypes
import sys
from types import SimpleNamespace
if sys.platform == "win32":
from xray_fluent.ui.main_window import MainWindow
return MainWindow
class _FakeWindll:
def __getattr__(self, name):
library = SimpleNamespace()
setattr(self, name, library)
return library
original = getattr(ctypes, "windll", None)
ctypes.windll = _FakeWindll()
try:
from xray_fluent.ui.main_window import MainWindow
finally:
if original is None:
del ctypes.windll
else:
ctypes.windll = original
return MainWindow
class TourAutostartTests(unittest.TestCase):
"""Экскурсия при первом запуске: сама, но только когда её видно."""
def setUp(self) -> None:
from types import SimpleNamespace
from unittest.mock import Mock
MainWindow = _main_window_class()
names = (
"_start_tour", "_open_tour_step", "_prewarm_tour_step", "_on_tour_finished", "_mark_tour_done",
"_tour_autostart_wanted", "_schedule_tour_autostart", "_try_autostart_tour",
)
attrs = {name: getattr(MainWindow, name) for name in names}
attrs["isActiveWindow"] = lambda self: self.active
host_cls = type("Host", (QWidget,), attrs)
self.window = host_cls()
self.window.resize(900, 600)
self.window.active = True
self.window._quitting = False
self.window._geometry_persistence_ready = True
self.window._tour_started_this_session = False
self.window._tour_autostart_tries = 0
self.window._tour_autostart_timer = QTimer(self.window)
self.window._tour_autostart_timer.setSingleShot(True)
self.window._tour_autostart_timer.timeout.connect(self.window._try_autostart_tour)
self.window.controller = SimpleNamespace(
state=SimpleNamespace(settings=AppSettings()), locked=False, schedule_save=Mock()
)
self.window.switchTo = Mock()
self.window.stackedWidget = Mock()
self.window.stackedWidget.isAnimationEnabled.return_value = True
self.window.dashboard_page = QWidget(self.window)
self.window.show()
def tearDown(self) -> None:
tour = active_tour(self.window)
if tour is not None:
tour.finish("interrupted")
self.window.hide()
self.window.deleteLater()
_spin(10)
@property
def settings(self) -> AppSettings:
return self.window.controller.state.settings
def _autostart(self):
self.window._try_autostart_tour()
return active_tour(self.window)
def _next_launch_starts_tour(self) -> bool:
self.window._tour_started_this_session = False
return self._autostart() is not None
def test_starts_by_itself_without_marking_done(self) -> None:
self.assertIsNotNone(self._autostart())
self.assertFalse(self.settings.tour_done)
def test_waits_while_window_is_behind_others(self) -> None:
self.window.active = False
self.assertIsNone(self._autostart())
self.assertTrue(self.window._tour_autostart_timer.isActive())
self.window.active = True
self.window._tour_autostart_timer.stop()
self.assertIsNotNone(self._autostart())
def test_busy_window_postpones_tour(self) -> None:
self.window.controller.locked = True
self.assertIsNone(self._autostart())
self.assertTrue(self.window._tour_autostart_timer.isActive())
def test_user_decision_marks_done(self) -> None:
for reason in ("done", "skipped"):
with self.subTest(reason=reason):
self.settings.tour_done = False
self.window._tour_started_this_session = False
self._autostart().finish(reason)
_spin(10)
self.assertTrue(self.settings.tour_done)
self.assertFalse(self._next_launch_starts_tour())
def test_app_interruption_repeats_next_launch_only(self) -> None:
for reason in ("hidden", "interrupted"):
with self.subTest(reason=reason):
self.window._tour_started_this_session = False
self._autostart().finish(reason)
_spin(10)
self.assertFalse(self.settings.tour_done)
# В этом же запуске (например, после возврата из трея) — не навязываем.
self.assertIsNone(self._autostart())
self.window._schedule_tour_autostart()
self.assertFalse(self.window._tour_autostart_timer.isActive())
self.assertTrue(self._next_launch_starts_tour())
active_tour(self.window).finish("interrupted")
_spin(10)
def test_schedule_is_not_duplicated(self) -> None:
self.window._schedule_tour_autostart()
self.assertTrue(self.window._tour_autostart_timer.isActive())
self.window._tour_autostart_tries = 5
self.window._schedule_tour_autostart()
self.assertEqual(self.window._tour_autostart_tries, 5)
def test_activation_reschedules_autostart(self) -> None:
source = (UI_DIR / "main_window.py").read_text(encoding="utf-8")
block = source[source.index(" def changeEvent(self, event):"):]
block = block[: block.index("\n def ")]
self.assertIn("QEvent.Type.ActivationChange", block)
self.assertIn("self._schedule_tour_autostart()", block)
class RealStepsFitTests(unittest.TestCase):
def test_every_card_fits_the_minimum_window(self) -> None:
"""Самый длинный текст должен помещаться в окно 860×560 под заголовком."""
window = QWidget()
window.resize(860, 560 - 48)
window.show()
overlay = TourOverlay(window, TOUR_STEPS)
try:
available = window.height() - 2 * CARD_MARGIN
for index, step in enumerate(TOUR_STEPS):
overlay.card.set_step(step, index, len(TOUR_STEPS))
width = 560 if step.hero else 440
self.assertLessEqual(overlay.card.height_for(width), available, step.key)
finally:
overlay.finish("interrupted")
_spin(10)
window.hide()
window.deleteLater()
if __name__ == "__main__":
unittest.main()