Some checks failed
Zapret source guards / validate (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1388 lines
58 KiB
Python
1388 lines
58 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
import unittest
|
||
from pathlib import Path
|
||
from tempfile import TemporaryDirectory
|
||
from types import SimpleNamespace
|
||
from unittest.mock import Mock, patch
|
||
|
||
|
||
PROJECT_SRC = Path(__file__).resolve().parents[1] / "src"
|
||
if str(PROJECT_SRC) not in sys.path:
|
||
sys.path.insert(0, str(PROJECT_SRC))
|
||
|
||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||
|
||
from PyQt6.QtCore import QPoint, QRect # noqa: E402
|
||
from PyQt6.QtWidgets import QApplication, QPushButton, QScrollArea, QVBoxLayout, QWidget # noqa: E402
|
||
|
||
|
||
def _app() -> QApplication:
|
||
return QApplication.instance() or QApplication([])
|
||
|
||
|
||
class OnboardingFlagSettingsTests(unittest.TestCase):
|
||
def test_flag_defaults_to_not_shown_and_survives_normalize(self) -> None:
|
||
from settings.normalize import normalize_settings
|
||
from settings.schema import build_default_settings
|
||
|
||
defaults = build_default_settings()
|
||
self.assertIs(defaults["warnings"]["onboarding_tour_done"], False)
|
||
|
||
normalized = normalize_settings({"warnings": {"onboarding_tour_done": True}})
|
||
self.assertIs(normalized["warnings"]["onboarding_tour_done"], True)
|
||
|
||
def test_flag_round_trips_through_sqlite_store(self) -> None:
|
||
from settings import store as settings_store
|
||
|
||
with TemporaryDirectory() as temp_dir:
|
||
with patch("settings.store.MAIN_DIRECTORY", str(temp_dir)):
|
||
try:
|
||
settings_store.prepare_settings_database()
|
||
self.assertFalse(settings_store.get_onboarding_tour_done())
|
||
settings_store.set_onboarding_tour_done(True)
|
||
self.assertTrue(settings_store.get_onboarding_tour_done())
|
||
self.assertTrue(settings_store.read_settings()["warnings"]["onboarding_tour_done"])
|
||
finally:
|
||
settings_store.close_settings_database()
|
||
|
||
|
||
class OnboardingPostStartupTests(unittest.TestCase):
|
||
def _install(self, *, done: bool, start_results: list[bool]):
|
||
from main import post_startup_onboarding
|
||
|
||
_app()
|
||
startup_host = SimpleNamespace(
|
||
startup_post_init_ready=object(),
|
||
startup_state=SimpleNamespace(post_init_ready=True),
|
||
is_alive=Mock(return_value=True),
|
||
start_onboarding_tour=Mock(side_effect=list(start_results)),
|
||
)
|
||
mark_done = Mock()
|
||
delays: list[int] = []
|
||
|
||
def _schedule(delay_ms, callback):
|
||
delays.append(int(delay_ms))
|
||
if len(delays) <= len(start_results):
|
||
callback()
|
||
|
||
with (
|
||
patch.object(post_startup_onboarding, "bind_startup_gate", side_effect=lambda _s, cb, **_k: cb()),
|
||
patch.object(post_startup_onboarding, "schedule_after", side_effect=_schedule),
|
||
patch.object(post_startup_onboarding, "enqueue_subsystem_task", side_effect=lambda _q, _n, target: target()),
|
||
patch.object(post_startup_onboarding, "_read_tour_done", return_value=done),
|
||
patch.object(post_startup_onboarding, "_mark_tour_done", mark_done),
|
||
patch.object(post_startup_onboarding, "log"),
|
||
):
|
||
post_startup_onboarding.install_onboarding_tour(startup_host)
|
||
return startup_host, mark_done, delays
|
||
|
||
def test_tour_is_not_shown_again_when_flag_is_set(self) -> None:
|
||
startup_host, mark_done, delays = self._install(done=True, start_results=[True])
|
||
|
||
startup_host.start_onboarding_tour.assert_not_called()
|
||
mark_done.assert_not_called()
|
||
self.assertEqual(delays, [])
|
||
|
||
def test_first_start_shows_tour_and_remembers_it(self) -> None:
|
||
startup_host, mark_done, _delays = self._install(done=False, start_results=[True])
|
||
|
||
startup_host.start_onboarding_tour.assert_called_once_with()
|
||
mark_done.assert_called_once_with()
|
||
|
||
def test_waits_while_window_is_not_ready_and_marks_only_after_show(self) -> None:
|
||
startup_host, mark_done, delays = self._install(done=False, start_results=[False, False, True])
|
||
|
||
self.assertEqual(startup_host.start_onboarding_tour.call_count, 3)
|
||
mark_done.assert_called_once_with()
|
||
self.assertEqual(len(delays), 3)
|
||
|
||
|
||
class _FakePage(QWidget):
|
||
def __init__(self, parent=None) -> None:
|
||
super().__init__(parent)
|
||
layout = QVBoxLayout(self)
|
||
self.start_btn = QPushButton("Запустить", self)
|
||
layout.addWidget(self.start_btn)
|
||
|
||
def onboarding_target(self, name):
|
||
return self.start_btn if name == "start" else None
|
||
|
||
|
||
def _build_window():
|
||
window = QWidget()
|
||
window.resize(900, 640)
|
||
layout = QVBoxLayout(window)
|
||
page = _FakePage(window)
|
||
layout.addWidget(page)
|
||
|
||
class Host:
|
||
def show_page(self, _name, allow_internal=False):
|
||
return True
|
||
|
||
def get_loaded_page(self, _name):
|
||
return page
|
||
|
||
def current_page(self):
|
||
return page
|
||
|
||
window.ui_session = SimpleNamespace(
|
||
nav_items={},
|
||
nav_header_by_group={},
|
||
nav_headers=[],
|
||
page_host=Host(),
|
||
)
|
||
return window, page
|
||
|
||
|
||
class OnboardingOverlayTests(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
_app()
|
||
|
||
def _overlay(self, steps):
|
||
from app.page_names import PageName
|
||
from ui.onboarding.overlay import OnboardingOverlay
|
||
from ui.onboarding.steps import TourContext
|
||
|
||
window, page = _build_window()
|
||
window.show()
|
||
context = TourContext(
|
||
window=window,
|
||
control_page_name=PageName.ZAPRET2_MODE_CONTROL,
|
||
pages={"control": PageName.ZAPRET2_MODE_CONTROL},
|
||
current_page=page,
|
||
)
|
||
overlay = OnboardingOverlay(window, context, steps)
|
||
return window, overlay
|
||
|
||
def test_steps_without_targets_are_skipped(self) -> None:
|
||
from ui.onboarding.steps import TourStep, _page_target
|
||
|
||
steps = (
|
||
TourStep("welcome", hero=True),
|
||
TourStep("start", _page_target("start"), page="control"),
|
||
TourStep("missing", _page_target("nothing"), page="control"),
|
||
TourStep("user_presets", _page_target("presets_list"), page="user_presets"),
|
||
TourStep("finish", _page_target("tour_card"), page="control", target_optional=True),
|
||
)
|
||
window, overlay = self._overlay(steps)
|
||
try:
|
||
self.assertTrue(overlay.start())
|
||
# Страницы user_presets в этом режиме нет — шаг убран сразу.
|
||
self.assertEqual(overlay.step_keys(), ["welcome", "start", "missing", "finish"])
|
||
self.assertEqual(overlay.current_step_key(), "welcome")
|
||
overlay.go_next()
|
||
self.assertEqual(overlay.current_step_key(), "start")
|
||
overlay.go_next()
|
||
# Цели «missing» на странице нет — шаг пропущен.
|
||
self.assertEqual(overlay.current_step_key(), "finish")
|
||
overlay.go_back()
|
||
self.assertEqual(overlay.current_step_key(), "start")
|
||
finally:
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
def test_wiki_button_opens_step_article_and_hides_without_one(self) -> None:
|
||
from ui.onboarding.steps import TourStep, _page_target
|
||
|
||
url = "https://wiki2.zapret.moe/Zapret2/preset"
|
||
steps = (
|
||
TourStep("welcome", hero=True),
|
||
TourStep("start", _page_target("start"), page="control", wiki_url=url),
|
||
)
|
||
window, overlay = self._overlay(steps)
|
||
try:
|
||
overlay.start()
|
||
button = overlay._card.wiki_button
|
||
self.assertTrue(button.isHidden())
|
||
overlay.go_next()
|
||
self.assertFalse(button.isHidden())
|
||
self.assertEqual(button.getUrl().toString(), url)
|
||
self.assertEqual(button.text(), "Подробнее в вики")
|
||
overlay.go_back()
|
||
self.assertTrue(button.isHidden())
|
||
finally:
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
def test_overlay_is_child_of_window_not_separate_window(self) -> None:
|
||
from ui.onboarding.steps import TourStep
|
||
|
||
window, overlay = self._overlay((TourStep("welcome", hero=True),))
|
||
try:
|
||
overlay.start()
|
||
self.assertFalse(overlay.isWindow())
|
||
self.assertIs(overlay.parentWidget(), window)
|
||
self.assertFalse(any(child.isWindow() for child in overlay.findChildren(QWidget)))
|
||
finally:
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
def test_skip_finishes_tour(self) -> None:
|
||
from ui.onboarding.steps import TourStep
|
||
|
||
window, overlay = self._overlay((TourStep("welcome", hero=True), TourStep("how", hero=True)))
|
||
finished = Mock()
|
||
overlay.finished.connect(finished)
|
||
try:
|
||
overlay.start()
|
||
overlay.finish("skipped", immediate=True)
|
||
finished.assert_called_once_with("skipped")
|
||
finally:
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
|
||
class OnboardingKeyboardTests(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
_app()
|
||
|
||
def _running_overlay(self):
|
||
from PyQt6.QtTest import QTest
|
||
|
||
from app.page_names import PageName
|
||
from ui.onboarding.overlay import OnboardingOverlay
|
||
from ui.onboarding.steps import TourContext, TourStep, _page_target
|
||
|
||
window, page = _build_window()
|
||
window.show()
|
||
window.activateWindow()
|
||
QTest.qWaitForWindowActive(window, 1000)
|
||
context = TourContext(
|
||
window=window,
|
||
control_page_name=PageName.ZAPRET2_MODE_CONTROL,
|
||
pages={"control": PageName.ZAPRET2_MODE_CONTROL},
|
||
current_page=page,
|
||
)
|
||
steps = (
|
||
TourStep("welcome", hero=True),
|
||
TourStep("start", _page_target("start"), page="control"),
|
||
TourStep("finish", hero=True),
|
||
)
|
||
overlay = OnboardingOverlay(window, context, steps)
|
||
overlay.start()
|
||
QApplication.processEvents()
|
||
return window, page, overlay
|
||
|
||
def test_arrow_keys_page_through_steps_while_card_button_has_focus(self) -> None:
|
||
from PyQt6.QtCore import Qt
|
||
from PyQt6.QtTest import QTest
|
||
|
||
window, _page, overlay = self._running_overlay()
|
||
try:
|
||
focus = QApplication.focusWidget()
|
||
self.assertTrue(focus is not None and overlay.isAncestorOf(focus))
|
||
QTest.keyClick(focus, Qt.Key.Key_Right)
|
||
self.assertEqual(overlay.current_step_key(), "start")
|
||
QTest.keyClick(QApplication.focusWidget(), Qt.Key.Key_Left)
|
||
self.assertEqual(overlay.current_step_key(), "welcome")
|
||
finally:
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
def test_tab_does_not_leave_the_tour_card(self) -> None:
|
||
from PyQt6.QtCore import Qt
|
||
from PyQt6.QtTest import QTest
|
||
|
||
window, page, overlay = self._running_overlay()
|
||
try:
|
||
overlay.go_next()
|
||
for _ in range(6):
|
||
QTest.keyClick(QApplication.focusWidget(), Qt.Key.Key_Tab)
|
||
focus = QApplication.focusWidget()
|
||
self.assertTrue(focus is not None and overlay.isAncestorOf(focus))
|
||
self.assertFalse(page.isAncestorOf(focus))
|
||
finally:
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
def test_backdrop_grab_does_not_break_a_click_in_progress(self) -> None:
|
||
from PyQt6.QtCore import Qt
|
||
from PyQt6.QtTest import QTest
|
||
|
||
window, _page, overlay = self._running_overlay()
|
||
try:
|
||
button = overlay._card.next_button
|
||
QTest.mousePress(button, Qt.MouseButton.LeftButton)
|
||
overlay._refresh_blur()
|
||
QTest.mouseRelease(button, Qt.MouseButton.LeftButton)
|
||
self.assertEqual(overlay.current_step_key(), "start")
|
||
self.assertTrue(overlay.isVisible())
|
||
finally:
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
|
||
class _ParentPage(QWidget):
|
||
"""Страница-родитель: открывает вложенную страницу и показывает «меню»."""
|
||
|
||
def __init__(self, host, parent=None) -> None:
|
||
super().__init__(parent)
|
||
self._host = host
|
||
layout = QVBoxLayout(self)
|
||
self.row = QPushButton("Строка", self)
|
||
self.second = QPushButton("Вторая", self)
|
||
self.menu = QPushButton("Меню", self)
|
||
layout.addWidget(self.row)
|
||
layout.addWidget(self.second)
|
||
layout.addWidget(self.menu)
|
||
self.menu.hide()
|
||
self.allow_subpage = True
|
||
self.open_calls: list[str] = []
|
||
self.states: list[str | None] = []
|
||
|
||
def onboarding_target(self, name):
|
||
if name == "menu":
|
||
return [self.row, self.menu] if self.menu.isVisible() else None
|
||
if name == "pair":
|
||
return [self.row, None, self.second]
|
||
return None
|
||
|
||
def onboarding_set_state(self, state) -> None:
|
||
self.states.append(state)
|
||
self.menu.setVisible(state == "menu")
|
||
|
||
def onboarding_open_subpage(self, key) -> bool:
|
||
self.open_calls.append(key)
|
||
if not self.allow_subpage:
|
||
return False
|
||
return self._host.show_page(self._host.child_name)
|
||
|
||
|
||
class _ChildPage(QWidget):
|
||
def __init__(self, parent=None) -> None:
|
||
super().__init__(parent)
|
||
layout = QVBoxLayout(self)
|
||
self.editor = QPushButton("Текст пресета", self)
|
||
layout.addWidget(self.editor)
|
||
|
||
def onboarding_target(self, name):
|
||
return self.editor if name == "editor" else None
|
||
|
||
|
||
def _build_multi_page_window():
|
||
from app.page_names import PageName
|
||
|
||
window = QWidget()
|
||
window.resize(900, 640)
|
||
layout = QVBoxLayout(window)
|
||
|
||
class Host:
|
||
child_name = PageName.ZAPRET2_PRESET_RAW_EDITOR
|
||
|
||
def __init__(self) -> None:
|
||
self.pages = {}
|
||
self.current = None
|
||
self.shown: list = []
|
||
|
||
def show_page(self, name, allow_internal=False):
|
||
_ = allow_internal
|
||
self.shown.append(name)
|
||
for page_name, page in self.pages.items():
|
||
page.setVisible(page_name == name)
|
||
self.current = self.pages.get(name)
|
||
return self.current is not None
|
||
|
||
def get_loaded_page(self, name):
|
||
return self.pages.get(name)
|
||
|
||
def current_page(self):
|
||
return self.current
|
||
|
||
host = Host()
|
||
control = _FakePage(window)
|
||
parent = _ParentPage(host, window)
|
||
child = _ChildPage(window)
|
||
host.pages = {
|
||
PageName.ZAPRET2_MODE_CONTROL: control,
|
||
PageName.ZAPRET2_USER_PRESETS: parent,
|
||
PageName.ZAPRET2_PRESET_RAW_EDITOR: child,
|
||
}
|
||
for page in host.pages.values():
|
||
layout.addWidget(page)
|
||
host.show_page(PageName.ZAPRET2_MODE_CONTROL)
|
||
window.ui_session = SimpleNamespace(
|
||
nav_items={},
|
||
nav_header_by_group={},
|
||
nav_headers=[],
|
||
page_host=host,
|
||
)
|
||
return window, host, control, parent, child
|
||
|
||
|
||
class OnboardingSubpageAndStateTests(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
_app()
|
||
|
||
def _overlay(self, steps):
|
||
from app.page_names import PageName
|
||
from ui.onboarding.overlay import OnboardingOverlay
|
||
from ui.onboarding.steps import TourContext
|
||
|
||
window, host, control, parent, child = _build_multi_page_window()
|
||
window.show()
|
||
context = TourContext(
|
||
window=window,
|
||
control_page_name=PageName.ZAPRET2_MODE_CONTROL,
|
||
pages={
|
||
"control": PageName.ZAPRET2_MODE_CONTROL,
|
||
"user_presets": PageName.ZAPRET2_USER_PRESETS,
|
||
"preset_editor": PageName.ZAPRET2_PRESET_RAW_EDITOR,
|
||
},
|
||
current_page=control,
|
||
)
|
||
overlay = OnboardingOverlay(window, context, steps)
|
||
return window, host, parent, child, overlay
|
||
|
||
def test_subpage_is_opened_by_its_parent_page_and_not_reopened(self) -> None:
|
||
from ui.onboarding.steps import TourStep, _page_target
|
||
|
||
steps = (
|
||
TourStep("welcome", hero=True),
|
||
TourStep("file", _page_target("editor"), page="preset_editor"),
|
||
TourStep("file_again", _page_target("editor"), page="preset_editor"),
|
||
TourStep("start", _page_target("start"), page="control"),
|
||
)
|
||
window, host, parent, child, overlay = self._overlay(steps)
|
||
try:
|
||
overlay.start()
|
||
overlay.go_next()
|
||
self.assertEqual(overlay.current_step_key(), "file")
|
||
self.assertEqual(parent.open_calls, ["preset_editor"])
|
||
self.assertIs(host.current, child)
|
||
overlay.go_next()
|
||
# Страница уже открыта — родителя второй раз не дёргаем.
|
||
self.assertEqual(overlay.current_step_key(), "file_again")
|
||
self.assertEqual(parent.open_calls, ["preset_editor"])
|
||
overlay.go_next()
|
||
overlay.go_back()
|
||
# После ухода на другую страницу «Назад» снова открывает через родителя.
|
||
self.assertEqual(overlay.current_step_key(), "file_again")
|
||
self.assertEqual(parent.open_calls, ["preset_editor", "preset_editor"])
|
||
self.assertIs(host.current, child)
|
||
finally:
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
def test_subpage_step_is_skipped_when_parent_cannot_open_it(self) -> None:
|
||
from ui.onboarding.steps import TourStep, _page_target
|
||
|
||
steps = (
|
||
TourStep("welcome", hero=True),
|
||
TourStep("file", _page_target("editor"), page="preset_editor", target_optional=True),
|
||
TourStep("start", _page_target("start"), page="control"),
|
||
)
|
||
window, _host, parent, _child, overlay = self._overlay(steps)
|
||
parent.allow_subpage = False
|
||
try:
|
||
overlay.start()
|
||
overlay.go_next()
|
||
self.assertEqual(parent.open_calls, ["preset_editor"])
|
||
self.assertEqual(overlay.current_step_key(), "start")
|
||
finally:
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
def test_page_state_is_shown_on_its_step_and_reset_when_leaving(self) -> None:
|
||
from ui.onboarding.steps import TourStep, _page_target
|
||
|
||
steps = (
|
||
TourStep("welcome", hero=True),
|
||
TourStep("menu", _page_target("menu"), page="user_presets", page_state="menu"),
|
||
TourStep("start", _page_target("start"), page="control"),
|
||
)
|
||
window, _host, parent, _child, overlay = self._overlay(steps)
|
||
try:
|
||
overlay.start()
|
||
overlay.go_next()
|
||
self.assertEqual(overlay.current_step_key(), "menu")
|
||
self.assertEqual(parent.states, ["menu"])
|
||
self.assertEqual(overlay._targets, [parent.row, parent.menu])
|
||
overlay.go_next()
|
||
self.assertEqual(parent.states, ["menu", None])
|
||
overlay.go_back()
|
||
self.assertEqual(parent.states, ["menu", None, "menu"])
|
||
overlay.finish("skipped", immediate=True)
|
||
self.assertEqual(parent.states, ["menu", None, "menu", None])
|
||
self.assertFalse(parent.menu.isVisible())
|
||
finally:
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
def test_list_target_highlights_every_shown_widget(self) -> None:
|
||
from ui.onboarding.steps import TourContext, _page_target
|
||
|
||
window, _host, parent, _child, _overlay = self._overlay(())
|
||
try:
|
||
parent.show()
|
||
context = TourContext(window=window, current_page=parent)
|
||
self.assertEqual(_page_target("pair")(context), [parent.row, parent.second])
|
||
finally:
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
|
||
class MenuPreviewTests(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
_app()
|
||
|
||
def test_real_menu_is_shown_as_picture_inside_page_not_as_window(self) -> None:
|
||
from PyQt6.QtCore import QRect
|
||
from qfluentwidgets import Action, RoundMenu
|
||
|
||
from ui.onboarding.menu_preview import create_menu_preview, place_menu_preview, remove_menu_preview
|
||
|
||
host = QWidget()
|
||
host.resize(600, 400)
|
||
row = QPushButton("Строка", host)
|
||
row.setGeometry(10, 10, 580, 40)
|
||
host.show()
|
||
try:
|
||
menu = RoundMenu(parent=host)
|
||
menu.addAction(Action("Открыть", menu))
|
||
menu.addAction(Action("Дублировать", menu))
|
||
preview = create_menu_preview(host, menu)
|
||
self.assertIsNotNone(preview)
|
||
self.assertFalse(preview.isWindow())
|
||
self.assertFalse(menu.isVisible())
|
||
place_menu_preview(preview, row, QRect(0, 0, row.width(), row.height()))
|
||
self.assertTrue(preview.isVisible())
|
||
self.assertTrue(host.rect().contains(preview.geometry()))
|
||
self.assertFalse(any(w.isWindow() and w.isVisible() for w in QApplication.topLevelWidgets() if w is not host))
|
||
remove_menu_preview(preview)
|
||
self.assertFalse(preview.isVisible())
|
||
finally:
|
||
host.close()
|
||
host.deleteLater()
|
||
|
||
|
||
class TourStepCatalogTests(unittest.TestCase):
|
||
def test_wiki_links_belong_to_real_steps(self) -> None:
|
||
from config.urls import ONBOARDING_WIKI_URLS
|
||
from ui.onboarding.steps import TOUR_STEPS
|
||
|
||
step_keys = {step.key for step in TOUR_STEPS}
|
||
self.assertEqual(set(ONBOARDING_WIKI_URLS) - step_keys, set())
|
||
for step in TOUR_STEPS:
|
||
self.assertEqual(step.wiki_url, ONBOARDING_WIKI_URLS.get(step.key, ""))
|
||
if step.wiki_url:
|
||
self.assertTrue(step.wiki_url.startswith("https://wiki2.zapret.moe/"), step.key)
|
||
|
||
def test_tile_counter_step_stands_between_profiles_overview_and_profile_row(self) -> None:
|
||
from app.ui_texts import TEXTS as UI_TEXTS
|
||
from ui.onboarding.steps import TOUR_STEPS
|
||
|
||
keys = [step.key for step in TOUR_STEPS]
|
||
position = keys.index("profile_group")
|
||
self.assertEqual(keys[position - 1 : position + 2], ["profiles_list", "profile_group", "profile_row"])
|
||
self.assertEqual(TOUR_STEPS[position].page, "preset_setup")
|
||
|
||
# Главная мысль для новичка: включать все профили не нужно.
|
||
overview = UI_TEXTS["onboarding.step.profiles_list.body"]["ru"]
|
||
counter = UI_TEXTS["onboarding.step.profile_group.body"]["ru"]
|
||
row = UI_TEXTS["onboarding.step.profile_row.body"]["ru"]
|
||
self.assertIn("Включать все профили подряд не надо", overview)
|
||
self.assertIn("«3 из 5»", counter)
|
||
self.assertIn("включать их все обычно бесполезно", counter)
|
||
self.assertIn("Наведите мышь", counter)
|
||
self.assertIn("закрашенная — профиль включён", row)
|
||
self.assertNotIn("Hostlist или IPset", row)
|
||
|
||
def test_step_keys_are_unique_and_pages_exist_in_zapret2_mode(self) -> None:
|
||
from app.page_names import PageName
|
||
from ui.onboarding.steps import COMMON_TOUR_PAGES, MODE_TOUR_PAGES, TOUR_STEPS, TOUR_SUBPAGE_PARENTS
|
||
|
||
keys = [step.key for step in TOUR_STEPS]
|
||
self.assertEqual(len(keys), len(set(keys)))
|
||
pages = {**COMMON_TOUR_PAGES, **MODE_TOUR_PAGES[PageName.ZAPRET2_MODE_CONTROL]}
|
||
self.assertEqual(sorted({step.page for step in TOUR_STEPS if step.page} - set(pages)), [])
|
||
for child, parent in TOUR_SUBPAGE_PARENTS.items():
|
||
self.assertIn(child, pages)
|
||
self.assertIn(parent, pages)
|
||
# Общие страницы есть в каждом режиме, а фейки и разбор лога — только у winws2.
|
||
for mode_pages in MODE_TOUR_PAGES.values():
|
||
self.assertEqual(set(mode_pages) & set(COMMON_TOUR_PAGES), set())
|
||
self.assertNotIn("fakes_page", MODE_TOUR_PAGES[PageName.ZAPRET1_MODE_CONTROL])
|
||
self.assertNotIn("log_analyzer", MODE_TOUR_PAGES[PageName.ZAPRET1_MODE_CONTROL])
|
||
|
||
def test_every_step_belongs_to_a_named_chapter_and_chapters_do_not_mix(self) -> None:
|
||
from app.ui_texts import TEXTS
|
||
from ui.onboarding.steps import TOUR_STEPS
|
||
|
||
order: list[str] = []
|
||
for step in TOUR_STEPS:
|
||
self.assertTrue(step.chapter, step.key)
|
||
if not order or order[-1] != step.chapter:
|
||
order.append(step.chapter)
|
||
# Глава идёт одним куском: вернуться к уже пройденной нельзя.
|
||
self.assertEqual(len(order), len(set(order)))
|
||
self.assertEqual(order[0], "intro")
|
||
self.assertEqual(order[-1], "finish")
|
||
for chapter in order:
|
||
for language in ("ru", "en"):
|
||
self.assertTrue(str((TEXTS.get(f"onboarding.chapter.{chapter}") or {}).get(language) or "").strip(), chapter)
|
||
|
||
def test_tour_visits_the_pages_added_after_presets_and_profiles(self) -> None:
|
||
from ui.onboarding.steps import TOUR_STEPS
|
||
|
||
pages = {step.page for step in TOUR_STEPS}
|
||
for page in (
|
||
"fakes_page",
|
||
"dpi_settings",
|
||
"dns",
|
||
"custom_dns",
|
||
"hosts",
|
||
"hosts_file",
|
||
"telegram",
|
||
"telegram_advanced",
|
||
"blockcheck",
|
||
"log_analyzer",
|
||
"appearance",
|
||
"logs",
|
||
"about",
|
||
"updates",
|
||
):
|
||
self.assertIn(page, pages)
|
||
|
||
def test_every_step_has_title_and_body_in_both_languages(self) -> None:
|
||
from app.ui_texts import TEXTS
|
||
from ui.onboarding.steps import TOUR_STEPS
|
||
|
||
# Прямо по словарю: tr() подставил бы русский текст вместо пропавшего английского.
|
||
missing = []
|
||
for step in TOUR_STEPS:
|
||
for part in ("title", "body"):
|
||
key = f"onboarding.step.{step.key}.{part}"
|
||
for language in ("ru", "en"):
|
||
if not str((TEXTS.get(key) or {}).get(language) or "").strip():
|
||
missing.append(f"{key} [{language}]")
|
||
self.assertEqual(missing, [])
|
||
|
||
|
||
class TechniqueIllustrationTests(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
_app()
|
||
|
||
def _illustration(self, host):
|
||
from ui.onboarding.illustrations import TechniqueIllustration
|
||
|
||
illustration = TechniqueIllustration(host, tr_fn=lambda _key, default: default)
|
||
illustration.resize(532, illustration.height())
|
||
return illustration
|
||
|
||
def test_every_scene_draws_something_at_every_moment(self) -> None:
|
||
from ui.onboarding.illustrations import SCENES
|
||
|
||
host = QWidget()
|
||
try:
|
||
illustration = self._illustration(host)
|
||
for key in SCENES:
|
||
illustration.set_scene(key)
|
||
for phase in (0.05, 0.3, 0.6, 0.86):
|
||
illustration.set_phase(phase)
|
||
image = illustration.grab().toImage()
|
||
colors = {image.pixel(x, y) for x in range(0, image.width(), 7) for y in range(0, image.height(), 7)}
|
||
self.assertGreater(len(colors), 3, f"{key} @ {phase}")
|
||
finally:
|
||
host.deleteLater()
|
||
|
||
def test_still_frame_shows_settled_scene(self) -> None:
|
||
# Без анимаций виден один кадр STATIC_PHASE: к нему все переходы
|
||
# (реплика, вспышка у сайта, падение подделок и мусора) уже закончились.
|
||
from ui.onboarding.illustrations import FADE_FROM, SCENES, STATIC_PHASE
|
||
|
||
host = QWidget()
|
||
try:
|
||
illustration = self._illustration(host)
|
||
for width in (460, 532, 580):
|
||
illustration.resize(width, illustration.height())
|
||
for key in SCENES:
|
||
illustration.set_scene(key)
|
||
self.assertLessEqual(illustration.scene_times().settled, STATIC_PHASE, f"{key} @ {width}")
|
||
illustration.set_phase(STATIC_PHASE)
|
||
still = illustration.grab().toImage()
|
||
illustration.set_phase(FADE_FROM - 0.001)
|
||
self.assertEqual(still, illustration.grab().toImage(), f"{key} @ {width}")
|
||
finally:
|
||
host.deleteLater()
|
||
|
||
def test_tcpseg_junk_leads_one_packet_and_site_drops_it(self) -> None:
|
||
# seqovl: мусор приклеен в начало того же пакета. На схеме правее —
|
||
# значит раньше, поэтому мусор едет справа от данных, вплотную к ним,
|
||
# первым входит в проверку, а отбрасывает его уже сайт.
|
||
from ui.onboarding.illustrations import DISCARD, TRAVEL
|
||
|
||
host = QWidget()
|
||
try:
|
||
illustration = self._illustration(host)
|
||
illustration.set_scene("tcpseg")
|
||
times = illustration.scene_times()
|
||
junk_arrival = times.starts[0] + TRAVEL
|
||
data_arrival = times.starts[1] + TRAVEL
|
||
self.assertLess(junk_arrival, data_arrival)
|
||
|
||
# У проверки: реплика звучит, когда в середине проверки мусор, а данные ещё не дошли.
|
||
frames = {frame.index: frame for frame in illustration.chip_frames(times.verdict)}
|
||
junk, data = frames[0], frames[1]
|
||
self.assertAlmostEqual(junk.x, illustration.width() / 2, delta=1.0)
|
||
self.assertLess(data.x, junk.x)
|
||
# Один пакет: половины слиты без просвета, не как отдельные пакеты.
|
||
self.assertTrue(junk.glued)
|
||
self.assertEqual((junk.flat, data.flat), ("left", "right"))
|
||
self.assertAlmostEqual(junk.x - data.x, (junk.width + data.width) / 2)
|
||
|
||
# После проверки мусор не гаснет по дороге, как подделка.
|
||
frames = {frame.index: frame for frame in illustration.chip_frames(junk_arrival - TRAVEL * 0.1)}
|
||
self.assertAlmostEqual(frames[0].alpha, 1.0)
|
||
self.assertAlmostEqual(frames[0].dy, 0.0)
|
||
|
||
# У сайта: мусор падает с дорожки, данные входят следом.
|
||
frames = {frame.index: frame for frame in illustration.chip_frames(junk_arrival + DISCARD * 0.5)}
|
||
self.assertGreater(frames[0].dy, 0.0)
|
||
self.assertFalse(frames[0].glued)
|
||
self.assertEqual(frames[1].flat, "")
|
||
self.assertEqual(illustration.chip_frames(max(junk_arrival + DISCARD, data_arrival) + 0.01), [])
|
||
finally:
|
||
host.deleteLater()
|
||
|
||
def test_separate_packets_keep_a_gap(self) -> None:
|
||
# Просвет на схеме = отдельные пакеты: подделка в fake не слита с настоящим.
|
||
from ui.onboarding.illustrations import CHIP_GAP
|
||
|
||
host = QWidget()
|
||
try:
|
||
illustration = self._illustration(host)
|
||
illustration.set_scene("fake")
|
||
times = illustration.scene_times()
|
||
frames = {frame.index: frame for frame in illustration.chip_frames(times.starts[1] + 0.1)}
|
||
fake, real = frames[0], frames[1]
|
||
self.assertGreaterEqual(fake.x - real.x - (fake.width + real.width) / 2, CHIP_GAP)
|
||
self.assertEqual((fake.flat, real.flat, fake.glued), ("", "", False))
|
||
finally:
|
||
host.deleteLater()
|
||
|
||
def test_every_illustrated_step_uses_a_known_scene(self) -> None:
|
||
from ui.onboarding.illustrations import SCENES
|
||
from ui.onboarding.steps import TOUR_STEPS
|
||
|
||
illustrated = [step for step in TOUR_STEPS if step.illustration]
|
||
self.assertGreaterEqual(len(illustrated), 9)
|
||
for step in illustrated:
|
||
self.assertIn(step.illustration, SCENES, step.key)
|
||
|
||
def test_animation_runs_only_while_visible(self) -> None:
|
||
host = QWidget()
|
||
host.resize(600, 300)
|
||
host.show()
|
||
try:
|
||
illustration = self._illustration(host)
|
||
illustration.set_scene("fake")
|
||
illustration.show()
|
||
with patch("ui.onboarding.illustrations.are_live_animations_enabled", return_value=True):
|
||
illustration.set_scene("multisplit")
|
||
self.assertTrue(illustration.is_animating())
|
||
illustration.hide()
|
||
self.assertFalse(illustration.is_animating())
|
||
finally:
|
||
host.close()
|
||
host.deleteLater()
|
||
|
||
def test_pause_button_freezes_frame_and_resumes_from_it(self) -> None:
|
||
from PyQt6.QtCore import Qt
|
||
from PyQt6.QtTest import QTest
|
||
|
||
from ui.onboarding import illustrations
|
||
|
||
host = QWidget()
|
||
host.resize(600, 300)
|
||
host.show()
|
||
try:
|
||
illustration = self._illustration(host)
|
||
illustration.show()
|
||
with patch("ui.onboarding.illustrations.are_live_animations_enabled", return_value=True):
|
||
illustration.set_scene("tcpseg")
|
||
button = illustration.pause_button
|
||
self.assertTrue(button.isVisible())
|
||
self.assertEqual(button.geometry().topRight(), illustration.rect().topRight())
|
||
|
||
illustration.set_phase(0.3)
|
||
QTest.mouseClick(button, Qt.MouseButton.LeftButton)
|
||
self.assertTrue(illustration.is_paused())
|
||
self.assertFalse(illustration.is_animating())
|
||
self.assertEqual(illustration.phase(), 0.3)
|
||
self.assertEqual(button.toolTip(), "Продолжить анимацию")
|
||
|
||
# Продолжение идёт с того же кадра, а не с начала круга.
|
||
QTest.mouseClick(button, Qt.MouseButton.LeftButton)
|
||
self.assertFalse(illustration.is_paused())
|
||
self.assertTrue(illustration.is_animating())
|
||
self.assertEqual(button.toolTip(), "Остановить анимацию")
|
||
with patch.object(illustration._clock, "elapsed", return_value=int(0.1 * illustrations.PERIOD_MS)):
|
||
illustration._on_tick()
|
||
self.assertAlmostEqual(illustration.phase(), 0.4, places=3)
|
||
|
||
# Следующий шаг: новая схема идёт сама, с начала.
|
||
illustration.set_paused(True)
|
||
illustration.set_scene("fake")
|
||
self.assertFalse(illustration.is_paused())
|
||
self.assertTrue(illustration.is_animating())
|
||
self.assertEqual(illustration.phase(), 0.0)
|
||
finally:
|
||
host.close()
|
||
host.deleteLater()
|
||
|
||
def test_pause_button_hidden_when_animations_are_off(self) -> None:
|
||
host = QWidget()
|
||
host.resize(600, 300)
|
||
host.show()
|
||
try:
|
||
illustration = self._illustration(host)
|
||
illustration.show()
|
||
with patch("ui.onboarding.illustrations.are_live_animations_enabled", return_value=False):
|
||
illustration.set_scene("fake")
|
||
self.assertFalse(illustration.pause_button.isVisible())
|
||
illustration.set_paused(True)
|
||
self.assertFalse(illustration.is_paused())
|
||
finally:
|
||
host.close()
|
||
host.deleteLater()
|
||
|
||
def test_without_animations_shows_still_frame_with_result(self) -> None:
|
||
from ui.onboarding.illustrations import STATIC_PHASE
|
||
|
||
host = QWidget()
|
||
host.resize(600, 300)
|
||
host.show()
|
||
try:
|
||
illustration = self._illustration(host)
|
||
illustration.show()
|
||
with patch("ui.onboarding.illustrations.are_live_animations_enabled", return_value=False):
|
||
illustration.set_scene("fake")
|
||
self.assertFalse(illustration.is_animating())
|
||
self.assertEqual(illustration.phase(), STATIC_PHASE)
|
||
finally:
|
||
host.close()
|
||
host.deleteLater()
|
||
|
||
def test_card_shows_illustration_only_on_its_steps(self) -> None:
|
||
from app.page_names import PageName
|
||
from ui.onboarding.overlay import CARD_WIDTH, HERO_CARD_WIDTH, OnboardingOverlay
|
||
from ui.onboarding.steps import TourContext, TourStep, _page_target
|
||
|
||
window, page = _build_window()
|
||
window.resize(900, 760)
|
||
window.show()
|
||
context = TourContext(
|
||
window=window,
|
||
control_page_name=PageName.ZAPRET2_MODE_CONTROL,
|
||
pages={"control": PageName.ZAPRET2_MODE_CONTROL},
|
||
current_page=page,
|
||
)
|
||
steps = (
|
||
TourStep("start", _page_target("start"), page="control"),
|
||
TourStep("technique_fake", illustration="fake"),
|
||
)
|
||
overlay = OnboardingOverlay(window, context, steps)
|
||
try:
|
||
overlay.start()
|
||
illustration = overlay._card.illustration
|
||
self.assertTrue(illustration.isHidden())
|
||
self.assertEqual(overlay.card_target_size().width(), CARD_WIDTH)
|
||
overlay.go_next()
|
||
self.assertFalse(illustration.isHidden())
|
||
self.assertEqual(illustration.scene_key(), "fake")
|
||
self.assertEqual(overlay.card_target_size().width(), HERO_CARD_WIDTH)
|
||
overlay.go_back()
|
||
self.assertTrue(illustration.isHidden())
|
||
finally:
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
|
||
class _FocusGrabbingPage(QWidget):
|
||
"""Как редактор пресета: после открытия сам забирает фокус таймером."""
|
||
|
||
def __init__(self, parent=None) -> None:
|
||
from PyQt6.QtWidgets import QPlainTextEdit
|
||
|
||
super().__init__(parent)
|
||
layout = QVBoxLayout(self)
|
||
self.editor = QPlainTextEdit(self)
|
||
self.editor.setPlainText("--lua-desync=fake")
|
||
layout.addWidget(self.editor)
|
||
self.values = {"count": "7"}
|
||
|
||
def onboarding_target(self, name):
|
||
return self.editor if name == "editor" else None
|
||
|
||
def onboarding_text_values(self, _name):
|
||
return dict(self.values)
|
||
|
||
def grab_focus_later(self) -> None:
|
||
from PyQt6.QtCore import QTimer
|
||
|
||
QTimer.singleShot(0, self.editor.setFocus)
|
||
|
||
|
||
class OnboardingCardBehaviourTests(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
_app()
|
||
|
||
def _overlay(self, steps, page_cls=_FocusGrabbingPage):
|
||
from PyQt6.QtTest import QTest
|
||
|
||
from app.page_names import PageName
|
||
from ui.onboarding.overlay import OnboardingOverlay
|
||
from ui.onboarding.steps import TourContext
|
||
|
||
window = QWidget()
|
||
window.resize(900, 700)
|
||
layout = QVBoxLayout(window)
|
||
page = page_cls(window)
|
||
layout.addWidget(page)
|
||
|
||
class Host:
|
||
def show_page(self, _name, allow_internal=False):
|
||
return True
|
||
|
||
def get_loaded_page(self, _name):
|
||
return page
|
||
|
||
def current_page(self):
|
||
return page
|
||
|
||
window.ui_session = SimpleNamespace(nav_items={}, nav_header_by_group={}, nav_headers=[], page_host=Host())
|
||
window.show()
|
||
window.activateWindow()
|
||
QTest.qWaitForWindowActive(window, 1000)
|
||
context = TourContext(
|
||
window=window,
|
||
control_page_name=PageName.ZAPRET2_MODE_CONTROL,
|
||
pages={"control": PageName.ZAPRET2_MODE_CONTROL},
|
||
current_page=page,
|
||
)
|
||
overlay = OnboardingOverlay(window, context, steps)
|
||
return window, page, overlay
|
||
|
||
def test_arrows_keep_working_when_page_takes_focus(self) -> None:
|
||
from PyQt6.QtCore import Qt
|
||
from PyQt6.QtTest import QTest
|
||
|
||
from ui.onboarding.steps import TourStep, _page_target
|
||
|
||
steps = (
|
||
TourStep("welcome", hero=True),
|
||
TourStep("editor", _page_target("editor"), page="control"),
|
||
TourStep("finish", hero=True),
|
||
)
|
||
window, page, overlay = self._overlay(steps)
|
||
try:
|
||
overlay.start()
|
||
overlay.go_next()
|
||
page.grab_focus_later()
|
||
for _ in range(5):
|
||
QApplication.processEvents()
|
||
focus = QApplication.focusWidget()
|
||
self.assertTrue(focus is not None and overlay.isAncestorOf(focus))
|
||
QTest.keyClick(focus, Qt.Key.Key_A)
|
||
self.assertEqual(page.editor.toPlainText(), "--lua-desync=fake")
|
||
QTest.keyClick(QApplication.focusWidget(), Qt.Key.Key_Right)
|
||
self.assertEqual(overlay.current_step_key(), "finish")
|
||
finally:
|
||
overlay.finish("skipped", immediate=True)
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
def test_click_on_a_chapter_segment_opens_its_first_step(self) -> None:
|
||
from PyQt6.QtCore import QPoint, Qt
|
||
from PyQt6.QtTest import QTest
|
||
|
||
from ui.onboarding.steps import TourStep
|
||
|
||
# Шесть глав по десять шагов: отрезков шесть, а не шестьдесят точек.
|
||
steps = tuple(TourStep(f"step_{index}", hero=True, chapter=f"chapter_{index // 10}") for index in range(60))
|
||
window, _page, overlay = self._overlay(steps)
|
||
try:
|
||
overlay.start()
|
||
for _ in range(30):
|
||
QApplication.processEvents()
|
||
bar = overlay._card.chapter_bar
|
||
rects = bar.segment_rects()
|
||
self.assertEqual(len(rects), 6)
|
||
self.assertLessEqual(rects[-1].right(), bar.width() + 0.5)
|
||
# Нажимается вся высота строки, а не тонкая полоска.
|
||
self.assertGreaterEqual(min(rect.width() for rect in rects), 40)
|
||
self.assertEqual(rects[0].height(), bar.height())
|
||
QTest.mouseClick(bar, Qt.MouseButton.LeftButton, pos=QPoint(int(rects[4].center().x()), 2))
|
||
self.assertEqual(overlay.current_step_key(), "step_40")
|
||
# Щелчок в промежуток между отрезками тоже куда-то ведёт: мимо не нажать.
|
||
between = int((rects[1].right() + rects[2].left()) / 2)
|
||
QTest.mouseClick(bar, Qt.MouseButton.LeftButton, pos=QPoint(between, bar.height() - 2))
|
||
self.assertIn(overlay.current_step_key(), ("step_10", "step_20"))
|
||
finally:
|
||
overlay.finish("skipped", immediate=True)
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
def test_opacity_effect_is_off_once_text_is_fully_shown(self) -> None:
|
||
import time
|
||
|
||
from ui.onboarding.steps import TourStep
|
||
|
||
window, _page, overlay = self._overlay((TourStep("welcome", hero=True), TourStep("next", hero=True)))
|
||
try:
|
||
overlay.start()
|
||
deadline = time.monotonic() + 2.0
|
||
while time.monotonic() < deadline and overlay._card.content_effect.isEnabled():
|
||
QApplication.processEvents()
|
||
time.sleep(0.01)
|
||
self.assertFalse(overlay._card.content_effect.isEnabled())
|
||
finally:
|
||
overlay.finish("skipped", immediate=True)
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
def test_live_values_fill_the_text_and_missing_ones_become_a_dash(self) -> None:
|
||
from ui.onboarding.overlay import _fill_placeholders, _unbreakable_options
|
||
from ui.onboarding.steps import TourStep, _page_target
|
||
|
||
self.assertEqual(_fill_placeholders("{count} и {example}", {"count": "3"}), "3 и —")
|
||
self.assertEqual(_unbreakable_options("Строка --lua-desync тут"), "Строка \u2011\u2011lua\u2011desync тут")
|
||
|
||
steps = (TourStep("preset_blobs", _page_target("editor"), page="control", text_key="section:blobs"),)
|
||
window, page, overlay = self._overlay(steps)
|
||
page.values = {"count": "42", "example": "tls_vk"}
|
||
try:
|
||
overlay.start()
|
||
body = overlay._card.body_label.text()
|
||
self.assertIn("42", body)
|
||
self.assertIn("tls_vk", body)
|
||
self.assertNotIn("{", body)
|
||
finally:
|
||
overlay.finish("skipped", immediate=True)
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
|
||
class _TallGridPage(QScrollArea):
|
||
"""Прокручиваемая страница с одной высокой «сеткой»: цель — прямоугольник внутри неё."""
|
||
|
||
GRID_HEIGHT = 3000
|
||
|
||
def __init__(self, parent=None) -> None:
|
||
super().__init__(parent)
|
||
self.setWidgetResizable(True)
|
||
content = QWidget(self)
|
||
layout = QVBoxLayout(content)
|
||
self.panel = QPushButton("Панель над сеткой", content)
|
||
self.panel.setFixedHeight(60)
|
||
layout.addWidget(self.panel)
|
||
self.grid = QWidget(content)
|
||
self.grid.setFixedHeight(self.GRID_HEIGHT)
|
||
layout.addWidget(self.grid)
|
||
self.late_button = QPushButton("Появится позже", content)
|
||
self.late_button.hide()
|
||
layout.addWidget(self.late_button)
|
||
self.setWidget(content)
|
||
self.tile_rect = QRect(20, 2400, 300, 90)
|
||
|
||
def onboarding_target(self, name):
|
||
if name == "tile":
|
||
return (self.grid, self.tile_rect)
|
||
if name == "late":
|
||
return self.late_button
|
||
if name == "panel":
|
||
return self.panel
|
||
if name == "grid":
|
||
return self.grid
|
||
return None
|
||
|
||
|
||
class OnboardingChapterAndScrollTests(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
_app()
|
||
|
||
def _overlay(self, steps, page_cls=_FakePage):
|
||
from app.page_names import PageName
|
||
from ui.onboarding import overlay as overlay_module
|
||
from ui.onboarding.steps import TourContext
|
||
|
||
window = QWidget()
|
||
self.addCleanup(window.deleteLater)
|
||
window.resize(900, 640)
|
||
layout = QVBoxLayout(window)
|
||
page = page_cls(window)
|
||
layout.addWidget(page)
|
||
|
||
class Host:
|
||
def show_page(self, _name, allow_internal=False):
|
||
return True
|
||
|
||
def get_loaded_page(self, _name):
|
||
return page
|
||
|
||
def current_page(self):
|
||
return page
|
||
|
||
window.ui_session = SimpleNamespace(nav_items={}, nav_header_by_group={}, nav_headers=[], page_host=Host())
|
||
window.show()
|
||
QApplication.processEvents()
|
||
context = TourContext(
|
||
window=window,
|
||
control_page_name=PageName.ZAPRET2_MODE_CONTROL,
|
||
pages={"control": PageName.ZAPRET2_MODE_CONTROL},
|
||
current_page=page,
|
||
)
|
||
with patch.object(overlay_module, "are_live_animations_enabled", return_value=False):
|
||
overlay = overlay_module.OnboardingOverlay(window, context, steps)
|
||
self.addCleanup(lambda: overlay.finish("skipped", immediate=True))
|
||
return window, page, overlay
|
||
|
||
def test_counter_and_segment_hint_name_the_chapter(self) -> None:
|
||
from ui.onboarding.steps import TourStep, _page_target
|
||
|
||
steps = (
|
||
TourStep("start", _page_target("start"), page="control", chapter="control"),
|
||
TourStep("status", _page_target("start"), page="control", chapter="control"),
|
||
TourStep("preset", _page_target("start"), page="control"),
|
||
)
|
||
_window, _page, overlay = self._overlay(steps)
|
||
overlay.start()
|
||
|
||
self.assertEqual(overlay._card.counter_label.text(), "Главная страница · Шаг 1 из 3")
|
||
first, second = overlay._card.chapter_bar.chapters()
|
||
self.assertEqual((first.first, first.count, first.hint), (0, 2, "Главная страница · шагов: 2"))
|
||
# Шаги без главы — отдельный отрезок без подсказки, счётчик у них как раньше.
|
||
self.assertEqual((second.first, second.count, second.hint), (2, 1, ""))
|
||
overlay.go_to(2)
|
||
self.assertEqual(overlay._card.counter_label.text(), "Шаг 3 из 3")
|
||
|
||
def test_chapters_of_the_whole_tour_stay_easy_to_click_in_a_narrow_card(self) -> None:
|
||
from ui.onboarding.overlay import TourChapter, _ChapterBar
|
||
from ui.onboarding.steps import TOUR_STEPS
|
||
|
||
chapters: list[TourChapter] = []
|
||
for index, step in enumerate(TOUR_STEPS):
|
||
if chapters and chapters[-1].title == step.chapter:
|
||
chapters[-1] = TourChapter(step.chapter, chapters[-1].first, chapters[-1].count + 1)
|
||
else:
|
||
chapters.append(TourChapter(step.chapter, index, 1))
|
||
self.assertGreater(len(TOUR_STEPS), 90)
|
||
self.assertLessEqual(len(chapters), 12)
|
||
bar = _ChapterBar()
|
||
self.addCleanup(bar.deleteLater)
|
||
bar.set_chapters(chapters)
|
||
|
||
# 212 точек — строка в самой узкой карточке (окно шириной под 300).
|
||
for width in (212, 452):
|
||
bar.resize(width, bar.HEIGHT)
|
||
rects = bar.segment_rects()
|
||
self.assertLessEqual(rects[-1].right(), width + 0.5)
|
||
self.assertGreaterEqual(min(rect.width() for rect in rects), 12, width)
|
||
# Глава из одного шага («Финиш») нажимается так же, как длинная.
|
||
self.assertAlmostEqual(rects[-1].width(), rects[0].width(), places=3)
|
||
self.assertEqual(bar.chapter_at(rects[-1].center().x()), len(chapters) - 1)
|
||
|
||
# Пройденные главы закрашены целиком, текущая — по доле шагов, дальние пустые.
|
||
diagnostics = next(index for index, chapter in enumerate(chapters) if chapter.title == "diagnostics")
|
||
bar.set_step(chapters[diagnostics].first)
|
||
bar.advance(16.0, animated=False)
|
||
self.assertEqual(bar.current_chapter(), diagnostics)
|
||
self.assertEqual(bar.fill_share(diagnostics - 1), 1.0)
|
||
self.assertAlmostEqual(bar.fill_share(diagnostics), 1 / chapters[diagnostics].count, places=6)
|
||
self.assertEqual(bar.fill_share(diagnostics + 1), 0.0)
|
||
|
||
def test_tile_deep_in_a_tall_grid_is_scrolled_into_view(self) -> None:
|
||
from ui.onboarding.steps import TourStep, _page_target
|
||
|
||
steps = (TourStep("tile", _page_target("tile"), page="control"),)
|
||
window, page, overlay = self._overlay(steps, _TallGridPage)
|
||
overlay.start()
|
||
overlay._on_frame()
|
||
|
||
tile_top = page.grid.mapTo(window, page.tile_rect.topLeft()).y()
|
||
self.assertGreater(tile_top, 0)
|
||
self.assertLess(tile_top + page.tile_rect.height(), window.height())
|
||
self.assertIsNotNone(overlay._hole)
|
||
|
||
def test_target_that_slid_under_the_edge_is_brought_back_whole(self) -> None:
|
||
"""Страница сама прокрутилась после входа в шаг — от панели осталась полоска."""
|
||
from ui.onboarding.overlay import HOLE_PADDING
|
||
from ui.onboarding.steps import TourStep, _page_target
|
||
|
||
steps = (TourStep("panel", _page_target("panel"), page="control"),)
|
||
_window, page, overlay = self._overlay(steps, _TallGridPage)
|
||
overlay.start()
|
||
overlay._on_frame()
|
||
whole = page.panel.height() + 2 * HOLE_PADDING
|
||
self.assertAlmostEqual(overlay._hole.height(), whole, delta=1)
|
||
|
||
page.verticalScrollBar().setValue(page.panel.y() + page.panel.height() - 6)
|
||
self.assertLess(page.panel.visibleRegion().boundingRect().height(), 10)
|
||
overlay._on_frame()
|
||
|
||
self.assertEqual(page.panel.visibleRegion().boundingRect().height(), page.panel.height())
|
||
self.assertAlmostEqual(overlay._hole.height(), whole, delta=1)
|
||
|
||
def test_target_taller_than_the_page_is_shown_from_its_top_and_lit_only_where_seen(self) -> None:
|
||
from ui.onboarding.overlay import HOLE_PADDING
|
||
from ui.onboarding.steps import TourStep, _page_target
|
||
|
||
steps = (TourStep("grid", _page_target("grid"), page="control"),)
|
||
window, page, overlay = self._overlay(steps, _TallGridPage)
|
||
# Под страницей — полоса окна, которая к ней не относится.
|
||
footer = QPushButton("Низ окна", window)
|
||
footer.setFixedHeight(200)
|
||
window.layout().addWidget(footer)
|
||
QApplication.processEvents()
|
||
overlay.start()
|
||
overlay._on_frame()
|
||
|
||
# Qt сам поставил бы в центр середину сетки, и её начало уехало бы за край.
|
||
top = page.grid.mapTo(page.viewport(), QPoint(0, 0)).y()
|
||
self.assertGreaterEqual(top, 0)
|
||
self.assertLessEqual(top, 16)
|
||
# Окошко — только видимая часть сетки: на полосу под страницей оно не заходит.
|
||
page_bottom = page.viewport().mapTo(window, QPoint(0, page.viewport().height())).y() - overlay.y()
|
||
self.assertLessEqual(overlay._hole.bottom(), page_bottom + HOLE_PADDING + 1)
|
||
self.assertLess(overlay._hole.bottom(), footer.y() - overlay.y() + footer.height() / 2)
|
||
|
||
def test_target_that_appears_later_below_the_window_is_scrolled_to(self) -> None:
|
||
from ui.onboarding.steps import TourStep, _page_target
|
||
|
||
steps = (TourStep("late", _page_target("late"), page="control", target_optional=True),)
|
||
_window, page, overlay = self._overlay(steps, _TallGridPage)
|
||
overlay.start()
|
||
overlay._on_frame()
|
||
self.assertIsNone(overlay._hole)
|
||
|
||
page.late_button.show()
|
||
QApplication.processEvents()
|
||
overlay._on_frame()
|
||
|
||
self.assertIsNotNone(overlay._hole)
|
||
self.assertGreater(page.verticalScrollBar().value(), 0)
|
||
|
||
|
||
class OnboardingDecorTests(unittest.TestCase):
|
||
"""Полоска прогресса сверху и перетекающие точки."""
|
||
|
||
def setUp(self) -> None:
|
||
_app()
|
||
|
||
def _overlay(self, count: int, *, animated: bool):
|
||
from app.page_names import PageName
|
||
from ui.onboarding import overlay as overlay_module
|
||
from ui.onboarding.steps import TourContext, TourStep
|
||
|
||
window, page = _build_window()
|
||
window.show()
|
||
context = TourContext(
|
||
window=window,
|
||
control_page_name=PageName.ZAPRET2_MODE_CONTROL,
|
||
pages={"control": PageName.ZAPRET2_MODE_CONTROL},
|
||
current_page=page,
|
||
)
|
||
steps = tuple(TourStep(f"step_{index}", hero=True) for index in range(count))
|
||
with patch.object(overlay_module, "are_live_animations_enabled", return_value=animated):
|
||
overlay = overlay_module.OnboardingOverlay(window, context, steps)
|
||
overlay.start()
|
||
return window, overlay
|
||
|
||
@staticmethod
|
||
def _run(overlay, seconds: float) -> None:
|
||
import time
|
||
|
||
deadline = time.monotonic() + seconds
|
||
while time.monotonic() < deadline:
|
||
QApplication.processEvents()
|
||
time.sleep(0.005)
|
||
|
||
def test_progress_glides_to_the_new_share_and_back(self) -> None:
|
||
window, overlay = self._overlay(4, animated=True)
|
||
card = overlay._card
|
||
try:
|
||
self._run(overlay, 1.6)
|
||
self.assertAlmostEqual(card.progress(), 0.25, places=3)
|
||
overlay.go_next()
|
||
overlay.go_next()
|
||
self._run(overlay, 0.05)
|
||
self.assertGreater(card.progress(), 0.25)
|
||
self.assertLess(card.progress(), 0.75)
|
||
self._run(overlay, 1.6)
|
||
self.assertAlmostEqual(card.progress(), 0.75, places=3)
|
||
overlay.go_back()
|
||
self._run(overlay, 1.6)
|
||
self.assertAlmostEqual(card.progress(), 0.5, places=3)
|
||
finally:
|
||
overlay.finish("skipped", immediate=True)
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
def test_without_animations_everything_is_in_place_at_once(self) -> None:
|
||
window, overlay = self._overlay(5, animated=False)
|
||
try:
|
||
overlay.go_to(3)
|
||
self.assertAlmostEqual(overlay._card.progress(), 0.8, places=6)
|
||
overlay._on_frame()
|
||
self.assertEqual(overlay._card.chapter_bar.progress(), 4.0)
|
||
finally:
|
||
overlay.finish("skipped", immediate=True)
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
def test_chapter_fill_flows_to_a_far_step(self) -> None:
|
||
window, overlay = self._overlay(12, animated=True)
|
||
bar = overlay._card.chapter_bar
|
||
try:
|
||
self._run(overlay, 0.5)
|
||
self.assertEqual(bar.progress(), 1.0)
|
||
overlay.go_to(9)
|
||
self._run(overlay, 0.04)
|
||
# Заливка дотекает до нового шага, а не прыгает.
|
||
self.assertGreater(bar.progress(), 1.0)
|
||
self.assertLess(bar.progress(), 10.0)
|
||
self._run(overlay, 1.5)
|
||
self.assertEqual(bar.progress(), 10.0)
|
||
# Тур без глав — один отрезок на всю ширину, как обычная полоса хода.
|
||
self.assertEqual(len(bar.segment_rects()), 1)
|
||
self.assertAlmostEqual(bar.fill_share(0), 10 / 12, places=6)
|
||
finally:
|
||
overlay.finish("skipped", immediate=True)
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
|
||
class StartOnboardingTourTests(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
_app()
|
||
|
||
def test_automatic_start_waits_for_visible_window(self) -> None:
|
||
from ui.onboarding import start_onboarding_tour
|
||
|
||
window, _page = _build_window()
|
||
try:
|
||
self.assertFalse(start_onboarding_tour(window, automatic=True))
|
||
finally:
|
||
window.deleteLater()
|
||
|
||
def test_manual_replay_starts_and_second_call_reuses_running_tour(self) -> None:
|
||
from ui.onboarding import find_onboarding_overlay, start_onboarding_tour
|
||
|
||
window, _page = _build_window()
|
||
window.show()
|
||
try:
|
||
self.assertTrue(start_onboarding_tour(window))
|
||
overlay = find_onboarding_overlay(window)
|
||
self.assertIsNotNone(overlay)
|
||
self.assertTrue(start_onboarding_tour(window))
|
||
self.assertIs(find_onboarding_overlay(window), overlay)
|
||
finally:
|
||
window.close()
|
||
window.deleteLater()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|