zapretgui/tests/test_navigation_history.py
loop-uh e73a4f1605
All checks were successful
Zapret source guards / validate (push) Successful in 12s
Windows-сборка / Dev Dev 21.1.7.176 опубликован
Стрелка «назад» скрывается, когда шагать некуда, а не стоит серой; место под ней остаётся, и боковое меню не прыгает
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:32:04 +03:00

549 lines
22 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.

"""Журнал экранов окна: кнопки «назад» и «вперёд» возвращают на последний экран,
включая то, что было открыто внутри страницы."""
from __future__ import annotations
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
PROJECT_SRC = Path(__file__).resolve().parent.parent / "src"
if str(PROJECT_SRC) not in sys.path:
sys.path.insert(0, str(PROJECT_SRC))
from PyQt6.QtCore import QEvent, QObject, QPointF, Qt, pyqtSignal
from PyQt6.QtGui import QMouseEvent
from PyQt6.QtTest import QTest
from PyQt6.QtWidgets import QApplication, QWidget
from app.page_names import PageName
from ui.navigation.history import HistoryEntry, NavigationHistory, ScreenState
from ui.navigation.history_controller import WindowNavigationHistory
A, B, C = PageName.ZAPRET2_MODE_CONTROL, PageName.BLOCKCHECK, PageName.NETWORK
def _entry(page: PageName, key: str = "", payload: object = None) -> HistoryEntry:
return HistoryEntry(page, ScreenState(key=key, title=key, payload=payload))
class NavigationHistoryModelTests(unittest.TestCase):
def test_back_and_forward_walk_the_visited_screens(self) -> None:
history = NavigationHistory()
for entry in (_entry(A), _entry(B, "tab"), _entry(B, "card")):
history.visit(entry)
self.assertTrue(history.can_go_back())
self.assertFalse(history.can_go_forward())
self.assertEqual([e.screen.key for _i, e in history.back_entries()], ["tab", ""])
history.move_to(history.index - 1)
self.assertEqual(history.current(), _entry(B, "tab"))
self.assertEqual([e.screen.key for _i, e in history.forward_entries()], ["card"])
def test_new_screen_after_back_drops_the_forward_part(self) -> None:
history = NavigationHistory()
for entry in (_entry(A), _entry(B), _entry(C)):
history.visit(entry)
history.move_to(0)
history.visit(_entry(B, "card"))
self.assertEqual(history.entries(), (_entry(A), _entry(B, "card")))
self.assertFalse(history.can_go_forward())
def test_same_screen_is_updated_in_place_and_keeps_the_forward_part(self) -> None:
history = NavigationHistory()
for entry in (_entry(A), _entry(B, "card", payload="old"), _entry(C)):
history.visit(entry)
history.move_to(1)
history.visit(_entry(B, "card", payload="new"))
self.assertEqual(len(history.entries()), 3)
self.assertEqual(history.current().screen.payload, "new")
self.assertTrue(history.can_go_forward())
def test_limit_forgets_the_oldest_screens(self) -> None:
history = NavigationHistory(limit=3)
for number in range(5):
history.visit(_entry(B, str(number)))
self.assertEqual([e.screen.key for e in history.entries()], ["2", "3", "4"])
self.assertEqual(history.index, 2)
def test_dropped_screen_keeps_the_pointer_and_merges_equal_neighbours(self) -> None:
history = NavigationHistory()
for entry in (_entry(A), _entry(B, "gone"), _entry(A)):
history.visit(entry)
history.drop(1)
self.assertEqual(history.entries(), (_entry(A),))
self.assertEqual(history.index, 0)
self.assertFalse(history.can_go_back())
def test_renamed_screen_keeps_its_place_and_merges_with_its_new_name(self) -> None:
history = NavigationHistory()
for entry in (_entry(A), _entry(B, "old|raw"), _entry(B, "new|raw")):
history.visit(entry)
history.rewrite(lambda e: _entry(B, "new|raw") if e.screen.key == "old|raw" else e)
# Один экран под двумя именами — одна запись: «назад» ведёт на A, а не на «тот же» экран.
self.assertEqual(history.entries(), (_entry(A), _entry(B, "new|raw")))
self.assertEqual(history.index, 1)
self.assertFalse(history.can_go_forward())
def test_listeners_hear_every_change(self) -> None:
history = NavigationHistory()
heard = []
history.subscribe(lambda: heard.append(history.index))
history.visit(_entry(A))
history.visit(_entry(B))
history.move_to(0)
self.assertEqual(heard, [0, 1, 0])
class _FakePage:
"""Страница с вложенными экранами: открыть можно только те, что в ``known``."""
def __init__(self, *known: str) -> None:
self.known = {"", *known}
self.screen = ScreenState()
def open(self, key: str) -> None:
self.screen = ScreenState(key=key, title=key)
def navigation_screen(self) -> ScreenState:
return self.screen
def restore_navigation_screen(self, screen: ScreenState) -> bool:
if screen.key not in self.known:
return False
self.screen = screen
return True
def rename_navigation_screen(self, screen: ScreenState, old: str, new: str) -> ScreenState:
return ScreenState(key=new, title=new) if screen.key == old else screen
def rename(self, old: str, new: str) -> None:
"""Открытый предмет сменил ключ, оставшись тем же (как профиль после правки)."""
self.known = {new if key == old else key for key in self.known}
if self.screen.key == old:
self.screen = ScreenState(key=new, title=new)
class _SignalPage(QObject, _FakePage):
"""Страница, которая сама сообщает журналу о смене ключа своего предмета."""
navigation_screens_renamed = pyqtSignal(str, str)
def __init__(self, *known: str) -> None:
QObject.__init__(self)
_FakePage.__init__(self, *known)
def rename(self, old: str, new: str) -> None:
super().rename(old, new)
self.navigation_screens_renamed.emit(old, new)
class _FakeHost:
def __init__(self) -> None:
self.pages: dict[PageName, _FakePage] = {}
self.current: PageName | None = None
self.blocked: set[PageName] = set()
self.navigation_history = WindowNavigationHistory(object(), self)
def current_page(self):
return self.pages.get(self.current)
def page_name_of(self, page):
return next((name for name, loaded in self.pages.items() if loaded is page), None)
def get_loaded_page(self, page_name):
return self.pages.get(page_name)
def show_page(self, page_name, *, allow_internal: bool = False) -> bool:
if page_name in self.blocked:
return False
self.current = page_name
self.navigation_history.note_screen_changed()
return True
# Действия человека: каждое заканчивается записью в журнал, как в конце витка событий.
def user_opens(self, page_name: PageName) -> None:
self.show_page(page_name)
self.navigation_history.commit()
def user_opens_inside(self, key: str) -> None:
self.pages[self.current].open(key)
self.navigation_history.note_screen_changed()
self.navigation_history.commit()
def where(self) -> tuple[PageName | None, str]:
return self.current, self.current_page().screen.key
class WindowNavigationHistoryTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def _host(self) -> _FakeHost:
host = _FakeHost()
host.pages = {A: _FakePage(), B: _FakePage("tab", "card"), C: _FakePage()}
return host
def test_back_returns_to_the_screen_inside_the_page_not_to_the_previous_page(self) -> None:
host = self._host()
nav = host.navigation_history
host.user_opens(A)
host.user_opens(B)
host.user_opens_inside("tab")
host.user_opens_inside("card")
self.assertTrue(nav.go_back())
self.assertEqual(host.where(), (B, "tab"))
self.assertTrue(nav.go_back())
self.assertEqual(host.where(), (B, ""))
self.assertTrue(nav.go_back())
self.assertEqual(host.where(), (A, ""))
self.assertFalse(nav.can_go_back())
def test_returning_from_another_page_reopens_the_inner_screen(self) -> None:
host = self._host()
nav = host.navigation_history
host.user_opens(B)
host.user_opens_inside("card")
host.user_opens(C)
# Пока человек был на другой странице, эта вернулась в исходный вид.
host.pages[B].open("")
self.assertTrue(nav.go_back())
self.assertEqual(host.where(), (B, "card"))
def test_forward_undoes_back_until_a_new_screen_is_opened(self) -> None:
host = self._host()
nav = host.navigation_history
host.user_opens(A)
host.user_opens(B)
host.user_opens_inside("card")
nav.go_back()
nav.go_back()
self.assertTrue(nav.can_go_forward())
self.assertTrue(nav.go_forward())
self.assertEqual(host.where(), (B, ""))
self.assertTrue(nav.go_forward())
self.assertEqual(host.where(), (B, "card"))
self.assertFalse(nav.can_go_forward())
nav.go_back()
host.user_opens(C)
self.assertFalse(nav.can_go_forward())
def test_page_and_its_inner_screen_opened_together_are_one_step(self) -> None:
host = self._host()
nav = host.navigation_history
host.user_opens(A)
host.show_page(B)
host.pages[B].open("card")
nav.note_screen_changed()
QApplication.processEvents()
self.assertEqual([e.screen.key for e in nav.history.entries()], ["", "card"])
def test_screen_that_cannot_be_opened_anymore_is_skipped_and_forgotten(self) -> None:
host = self._host()
nav = host.navigation_history
host.user_opens(A)
host.user_opens(B)
host.user_opens_inside("card")
host.user_opens(C)
host.pages[B].known.discard("card")
self.assertTrue(nav.go_back())
self.assertEqual(host.where(), (B, ""))
self.assertEqual([e.screen.key for e in nav.history.entries()], ["", "", ""])
self.assertEqual(nav.history.index, 1)
def test_back_after_the_open_item_was_renamed_leaves_the_screen(self) -> None:
host = self._host()
host.pages[B] = _SignalPage("tab", "card")
nav = host.navigation_history
nav.attach_page(host.pages[B])
host.user_opens(A)
host.user_opens(B)
host.user_opens_inside("tab")
host.user_opens_inside("card")
# Правка выдала предмету новый ключ: страница сама сообщает журналу old → new.
host.pages[B].rename("card", "card-2")
nav.note_screen_changed()
nav.commit()
self.assertEqual([e.screen.key for e in nav.history.entries()], ["", "", "tab", "card-2"])
self.assertTrue(nav.go_back())
self.assertEqual(host.where(), (B, "tab"))
def test_page_unavailable_in_this_mode_is_skipped(self) -> None:
host = self._host()
nav = host.navigation_history
host.user_opens(A)
host.user_opens(B)
host.user_opens(C)
host.blocked.add(B)
self.assertTrue(nav.go_back())
self.assertEqual(host.where(), (A, ""))
self.assertEqual([e.page for e in nav.history.entries()], [A, C])
def test_picking_a_screen_from_the_list_jumps_straight_to_it(self) -> None:
host = self._host()
nav = host.navigation_history
host.user_opens(A)
host.user_opens(B)
host.user_opens_inside("card")
host.user_opens(C)
back = [index for index, _entry in nav.history.back_entries()]
self.assertTrue(nav.go_to_index(back[-1]))
self.assertEqual(host.where(), (A, ""))
self.assertEqual(len(nav.history.forward_entries()), 3)
def test_list_names_the_page_and_the_screen_inside_it(self) -> None:
host = self._host()
nav = host.navigation_history
with patch("ui.navigation.text_sync.get_nav_label", return_value="BlockCheck"):
self.assertEqual(nav.describe(_entry(B)), "BlockCheck")
self.assertEqual(
nav.describe(HistoryEntry(B, ScreenState(key="card:dns", title="DNS"))),
"BlockCheck — DNS",
)
class HistoryButtonsTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def _window(self):
from qfluentwidgets import FluentWindow
from qfluentwidgets.common.router import qrouter
from ui.navigation.history_buttons import HistoryButtons
window = FluentWindow()
self.addCleanup(window.deleteLater)
host = _FakeHost()
host.pages = {A: _FakePage(), B: _FakePage("card"), C: _FakePage()}
with patch("ui.navigation.history_buttons.get_window_ui_session", return_value=None):
buttons = HistoryButtons(window, host.navigation_history)
return window, host, buttons, qrouter
def test_back_button_follows_the_window_history_instead_of_the_library_one(self) -> None:
window, host, buttons, qrouter = self._window()
# Шагать некуда — стрелки нет совсем, а не серая; её место остаётся за ней.
self.assertTrue(buttons.back_button.isHidden())
self.assertTrue(buttons.back_button.sizePolicy().retainSizeWhenHidden())
host.user_opens(A)
host.user_opens(B)
host.user_opens_inside("card")
self.assertFalse(buttons.back_button.isHidden())
self.assertTrue(buttons.back_button.isEnabled())
with patch.object(type(qrouter), "pop") as library_pop:
buttons.back_button.click()
library_pop.assert_not_called()
self.assertEqual(host.where(), (B, ""))
# Библиотечный список страниц больше не решает, активна ли стрелка.
qrouter.emptyChanged.emit(True)
self.assertTrue(buttons.back_button.isEnabled())
self.assertFalse(buttons.back_button.isHidden())
# Вернулись к первому экрану — стрелка «назад» исчезает, «вперёд» появляется.
buttons.back_button.click()
buttons.back_button.click()
self.assertTrue(buttons.back_button.isHidden())
self.assertFalse(buttons.forward_button.isHidden())
def test_back_button_has_one_handler_even_if_the_library_one_is_not_recognized(self) -> None:
"""Сборка Nuitka: отписка по методу не находила подписку библиотеки, у
стрелки оставалось два обработчика, и «назад» ничего не делал (жалоба
15.10.2026: Alt+← работает, стрелка — нет)."""
from qfluentwidgets import FluentWindow
from qfluentwidgets.common.router import qrouter
from ui.navigation.history_buttons import HistoryButtons
window = FluentWindow()
self.addCleanup(window.deleteLater)
back = window.navigationInterface.panel.returnButton
# Так подписка выглядит в сборке: тот же шаг библиотеки, но не тот объект метода.
library_steps = []
back.clicked.connect(lambda *_args: library_steps.append(qrouter.pop()))
host = _FakeHost()
host.pages = {A: _FakePage(), B: _FakePage("card"), C: _FakePage()}
with patch("ui.navigation.history_buttons.get_window_ui_session", return_value=None):
buttons = HistoryButtons(window, host.navigation_history)
self.assertEqual(back.receivers(back.clicked), 1)
host.user_opens(A)
host.user_opens(B)
host.user_opens_inside("card")
buttons.back_button.click()
self.assertEqual(library_steps, [])
self.assertEqual(host.where(), (B, ""))
def test_library_page_list_is_retired(self) -> None:
"""Окно пишет страницы в библиотечный список при каждом переключении; он
выключен, поэтому уцелевшему шагу библиотеки нечего переключать."""
from PyQt6.QtWidgets import QStackedWidget
from qfluentwidgets.common.router import qrouter
window, host, buttons, _qrouter = self._window()
stacked = QStackedWidget()
self.addCleanup(stacked.deleteLater)
qrouter.push(stacked, "page-a")
qrouter.push(stacked, "page-b")
self.assertEqual(qrouter.history, [])
def test_forward_button_appears_only_after_back(self) -> None:
window, host, buttons, _qrouter = self._window()
host.user_opens(A)
host.user_opens(B)
self.assertTrue(buttons.forward_button.isHidden())
buttons.back_button.click()
self.assertFalse(buttons.forward_button.isHidden())
buttons.forward_button.click()
self.assertEqual(host.where(), (B, ""))
self.assertTrue(buttons.forward_button.isHidden())
def test_menu_lists_screens_behind_nearest_first(self) -> None:
window, host, buttons, _qrouter = self._window()
host.user_opens(A)
host.user_opens(B)
host.user_opens_inside("card")
host.user_opens(C)
behind = buttons.menu_entries(forward=False)
self.assertEqual([(e.page, e.screen.key) for _index, e in behind], [(B, "card"), (B, ""), (A, "")])
self.assertEqual(buttons.menu_entries(forward=True), ())
def _mouse(self, button, kind, mouse_button=Qt.MouseButton.LeftButton) -> None:
position = QPointF(button.rect().center())
event = QMouseEvent(kind, position, button.mapToGlobal(position), mouse_button, mouse_button, Qt.KeyboardModifier.NoModifier)
QApplication.sendEvent(button, event)
def test_long_press_opens_the_list_and_does_not_also_go_back(self) -> None:
from ui.navigation import history_buttons
window, host, buttons, _qrouter = self._window()
host.user_opens(A)
host.user_opens(B)
shown = []
with (
patch.object(history_buttons, "LONG_PRESS_MS", 1),
patch.object(history_buttons.HistoryButtons, "show_menu", lambda self, *, forward: shown.append(forward)),
):
gesture = history_buttons.HistoryButtonGesture(buttons.back_button, lambda: buttons.show_menu(forward=False))
self._mouse(buttons.back_button, QEvent.Type.MouseButtonPress)
QTest.qWait(40)
self._mouse(buttons.back_button, QEvent.Type.MouseButtonRelease)
gesture.deleteLater()
self.assertEqual(shown, [False])
self.assertEqual(host.where(), (B, ""))
def test_forward_button_keeps_its_mouse_events_from_the_title_bar(self) -> None:
# Шапка окна двигает окно и разворачивает его по событиям, которые никто не забрал.
window, host, buttons, _qrouter = self._window()
host.user_opens(A)
host.user_opens(B)
buttons.back_button.click()
button = buttons.forward_button
position = QPointF(button.rect().center())
for kind in (
QEvent.Type.MouseButtonPress,
QEvent.Type.MouseMove,
QEvent.Type.MouseButtonDblClick,
QEvent.Type.MouseButtonRelease,
):
event = QMouseEvent(
kind,
position,
button.mapToGlobal(position),
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier,
)
event.ignore()
QApplication.sendEvent(button, event)
self.assertTrue(event.isAccepted(), kind)
def test_short_click_still_goes_back(self) -> None:
window, host, buttons, _qrouter = self._window()
host.user_opens(A)
host.user_opens(B)
self._mouse(buttons.back_button, QEvent.Type.MouseButtonPress)
self._mouse(buttons.back_button, QEvent.Type.MouseButtonRelease)
self.assertEqual(host.where(), (A, ""))
class PageHostRecordsScreensTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def test_base_page_has_one_plain_screen(self) -> None:
from ui.pages.base_page import BasePage
page = BasePage("Страница")
self.addCleanup(page.deleteLater)
self.assertEqual(page.navigation_screen(), ScreenState())
self.assertTrue(page.restore_navigation_screen(ScreenState()))
self.assertFalse(page.restore_navigation_screen(ScreenState(key="card:dns")))
def test_created_page_reports_its_inner_screens_to_the_window_history(self) -> None:
from types import SimpleNamespace
from ui.page_host import WindowPageHost
from ui.pages.base_page import BasePage
page = BasePage("Страница")
self.addCleanup(page.deleteLater)
factory = SimpleNamespace(create_page=lambda _name: SimpleNamespace(page=page, elapsed_ms=0))
host = WindowPageHost(QWidget(), factory)
self.assertIs(host.ensure_page(B), page)
self.assertIs(host.page_name_of(page), B)
with patch.object(host.navigation_history, "commit") as commit:
page.navigation_screen_changed.emit()
QApplication.processEvents()
commit.assert_called_once()
if __name__ == "__main__":
unittest.main()