zapretgui/tests/test_update_dialog.py

402 lines
15 KiB
Python

from __future__ import annotations
"""Большое окно обновления, «Что нового» и их живые элементы."""
import os
import unittest
from unittest.mock import Mock
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication, QWidget
from updater.ui import plans
from updater.ui.fun_texts import phrases
from updater.ui.update_dialog import UpdateDialog, WhatsNewDialog
from updater.ui.update_flow import (
PHASE_DOWNLOADING,
PHASE_FAILED,
PHASE_IDLE,
PHASE_INSTALLING,
PHASE_OFFER,
UpdateFlow,
UpdateOffer,
)
from ui.widgets.fun.runway import STATE_DONE, STATE_FAILED, UpdateRunway
_HISTORY = (
{"version": "2.1", "notes": "- новая кнопка\n- <b>не тег</b>", "published_at": "2026-09-30T10:00:00Z", "url": "https://u/2.1"},
{"version": "2.0", "notes": "починили", "published_at": "", "url": ""},
)
def _wait(predicate, timeout: float = 3.0) -> bool:
import time
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
QApplication.processEvents()
if predicate():
return True
time.sleep(0.005)
return bool(predicate())
def _offer() -> UpdateOffer:
return UpdateOffer(version="2.1", current_version="1.9", source="Forgejo", url="https://u/2.1", history=_HISTORY)
class UpdateFlowTests(unittest.TestCase):
def test_phases_follow_install_service(self) -> None:
flow = UpdateFlow()
flow.set_offer(_offer())
self.assertEqual(flow.phase, PHASE_OFFER)
flow.start_download()
self.assertTrue(flow.is_busy)
flow.on_progress(0, 0, 0)
self.assertFalse(flow.progress.known_size)
flow.on_progress(40, 40 * 1024 * 1024, 100 * 1024 * 1024)
self.assertEqual(flow.progress.percent, 40)
self.assertTrue(flow.progress.known_size)
self.assertIn("40.0 / 100.0", flow.progress.size_text)
flow.on_downloaded()
self.assertEqual(flow.phase, PHASE_INSTALLING)
self.assertEqual(flow.progress.percent, 100)
def test_new_offer_cannot_interrupt_download(self) -> None:
flow = UpdateFlow()
flow.set_offer(_offer())
flow.start_download()
self.assertFalse(flow.set_offer(UpdateOffer(version="9.9", current_version="1.9")))
flow.clear_offer()
self.assertEqual(flow.offer.version, "2.1")
self.assertEqual(flow.phase, PHASE_DOWNLOADING)
def test_failure_keeps_error_and_allows_new_offer(self) -> None:
flow = UpdateFlow()
flow.set_offer(_offer())
flow.start_download()
flow.on_failed("обрыв")
self.assertEqual(flow.phase, PHASE_FAILED)
self.assertEqual(flow.progress.error_text, "обрыв")
flow.clear_offer()
self.assertEqual(flow.phase, PHASE_IDLE)
class ReleaseHistoryHtmlTests(unittest.TestCase):
def test_every_version_is_listed_with_date_lists_and_links(self) -> None:
html = plans.release_history_html(
({"version": "2.1", "notes": "- пункт https://git.zapret.moe/x.\n# Раздел\nтекст", "published_at": "2026-09-30T10:00:00Z"},),
accent_hex="#123456",
muted_hex="#999",
language="ru",
empty_text="пусто",
)
self.assertIn("v2.1", html)
self.assertIn("30 сентября 2026", html)
self.assertIn("<li", html)
self.assertIn("<b>Раздел</b>", html)
# Точка в конце предложения не попадает в ссылку.
self.assertIn('href="https://git.zapret.moe/x"', html)
def test_release_text_cannot_inject_markup(self) -> None:
html = plans.release_history_html(
({"version": "2.1<script>", "notes": "<img src=x onerror=1>"},),
accent_hex="#000",
muted_hex="#999",
language="ru",
empty_text="пусто",
)
self.assertNotIn("<img", html)
self.assertNotIn("<script>", html)
self.assertIn("&lt;img", html)
def test_new_versions_on_top_and_earlier_section_below(self) -> None:
html = plans.release_history_html(
(
{"version": "2.1", "notes": "a", "is_new": True},
{"version": "2.0", "notes": "b", "is_new": False},
),
accent_hex="#123456",
muted_hex="#999999",
language="ru",
empty_text="пусто",
)
self.assertLess(html.index("v2.1"), html.index("новое"))
self.assertLess(html.index("новое"), html.index("Ранее"))
self.assertLess(html.index("Ранее"), html.index("v2.0"))
self.assertEqual(plans.count_new_versions(({"is_new": True}, {"is_new": False}, {})), 2)
def test_all_new_has_no_badges_or_earlier_section(self) -> None:
html = plans.release_history_html(
({"version": "2.1", "notes": "a"}, {"version": "2.0", "notes": "b"}),
accent_hex="#123456",
muted_hex="#999999",
language="ru",
empty_text="пусто",
)
self.assertNotIn("новое", html)
self.assertNotIn("Ранее", html)
def test_empty_notes_say_so(self) -> None:
html = plans.release_history_html(
({"version": "2.1", "notes": ""},), accent_hex="#000", muted_hex="#999", language="en", empty_text="No notes"
)
self.assertIn("No notes", html)
class VersionsWordTests(unittest.TestCase):
def test_russian_plural(self) -> None:
cases = {2: "версии", 4: "версии", 5: "версий", 11: "версий", 12: "версий", 21: "версию", 22: "версии", 25: "версий"}
for count, word in cases.items():
self.assertEqual(plans.versions_word(count, "ru"), word, count)
self.assertEqual(plans.versions_word(3, "en"), "versions")
class _DialogCase(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls._app = QApplication.instance() or QApplication([])
def setUp(self) -> None:
self.host = QWidget()
self.host.resize(1200, 800)
self.host.show()
self.addCleanup(self.host.deleteLater)
@staticmethod
def _visible(*buttons) -> list[bool]:
return [not button.isHidden() for button in buttons]
class UpdateDialogTests(_DialogCase):
def _dialog(self, flow: UpdateFlow) -> UpdateDialog:
dialog = UpdateDialog(self.host, flow=flow)
dialog.open()
QApplication.processEvents()
self.addCleanup(dialog.deleteLater)
return dialog
def test_offer_shows_all_versions_and_offer_buttons(self) -> None:
flow = UpdateFlow()
flow.set_offer(_offer())
dialog = self._dialog(flow)
text = dialog.browser.toPlainText()
self.assertIn("v2.1", text)
self.assertIn("v2.0", text)
self.assertIn("<b>не тег</b>", text)
self.assertIn("v1.9", dialog.subtitle_label.text())
self.assertIn("2", dialog.subtitle_label.text())
self.assertEqual(
self._visible(dialog.install_btn, dialog.later_btn, dialog.skip_btn, dialog.browser_btn, dialog.telegram_btn),
[True, True, True, True, True],
)
self.assertEqual(self._visible(dialog.hide_btn, dialog.close_btn), [False, False])
self.assertTrue(dialog.download_panel.isHidden())
def test_download_switches_to_runway_and_hide_keeps_download(self) -> None:
flow = UpdateFlow()
flow.set_offer(_offer())
dialog = self._dialog(flow)
hidden = Mock()
dialog.hide_clicked.connect(hidden)
flow.start_download()
flow.on_progress(55, 55, 100)
self.assertFalse(dialog.download_panel.isHidden())
self.assertTrue(dialog.install_btn.isHidden())
self.assertFalse(dialog.hide_btn.isHidden())
self.assertEqual(dialog.runway.value(), 55.0)
self.assertTrue(dialog.ticker.is_running())
self.assertIn(dialog.ticker.text(), phrases("preparing") + phrases("downloading"))
dialog.hide_btn.click()
hidden.assert_called_once_with()
# Окно закрылось, загрузка — нет.
self.assertEqual(flow.phase, PHASE_DOWNLOADING)
def test_reopened_window_shows_current_progress(self) -> None:
flow = UpdateFlow()
flow.set_offer(_offer())
flow.start_download()
flow.on_progress(70, 70, 100)
dialog = self._dialog(flow)
self.assertEqual(dialog.runway.value(), 70.0)
self.assertEqual(dialog.percent_label.text(), "70%")
def test_failure_offers_retry_and_telegram(self) -> None:
flow = UpdateFlow()
flow.set_offer(_offer())
dialog = self._dialog(flow)
flow.start_download()
flow.on_failed("Соединение сброшено")
self.assertEqual(dialog.runway.state(), STATE_FAILED)
self.assertIn("Соединение сброшено", dialog.stage_label.text())
self.assertEqual(self._visible(dialog.install_btn, dialog.telegram_btn, dialog.close_btn), [True, True, True])
self.assertEqual(dialog.install_btn.text(), "Повторить")
def test_buttons_report_clicks(self) -> None:
flow = UpdateFlow()
flow.set_offer(_offer())
dialog = self._dialog(flow)
clicks: list[str] = []
dialog.install_clicked.connect(lambda: clicks.append("install"))
dialog.link_clicked.connect(lambda url: clicks.append(url))
dialog.telegram_clicked.connect(lambda: clicks.append("telegram"))
dialog.skip_clicked.connect(lambda: clicks.append("skip"))
dialog.install_btn.click()
dialog.browser_btn.click()
dialog.telegram_btn.click()
dialog.skip_btn.click()
self.assertEqual(clicks, ["install", "https://u/2.1", "telegram", "skip"])
def test_closed_window_stops_listening_to_flow(self) -> None:
"""Состояние живёт дольше окна: закрытое окно от него отписывается."""
flow = UpdateFlow()
flow.set_offer(_offer())
before = flow.receivers(flow.changed)
dialog = self._dialog(flow)
self.assertEqual(flow.receivers(flow.changed), before + 1)
dialog.later_btn.click()
self.assertTrue(_wait(lambda: flow.receivers(flow.changed) == before))
class DialogHeaderTests(_DialogCase):
def test_title_block_is_centered_on_logo_not_hanging_below(self) -> None:
"""Раньше значок был выше текста и свисал ниже подписи — «съехал вниз»."""
from PyQt6.QtCore import QPoint
dialog = WhatsNewDialog(self.host, version="2.1", history=_HISTORY)
dialog.open()
self.addCleanup(dialog.deleteLater)
_wait(lambda: dialog.titles_box.height() > 0)
mascot = dialog.mascot
logo_top = mascot.mapTo(dialog.widget, QPoint(0, 0)).y() + mascot.logo_top()
logo_side = mascot.height() - 2 - mascot.logo_top()
box = dialog.titles_box
box_top = box.mapTo(dialog.widget, QPoint(0, 0)).y()
self.assertLessEqual(abs((box_top + box.height() / 2) - (logo_top + logo_side / 2)), 2)
# Значок не выше блока текста больше чем на пару пикселей.
self.assertLessEqual(logo_side - box.height(), 2)
class WhatsNewDialogTests(_DialogCase):
def test_loading_then_history(self) -> None:
dialog = WhatsNewDialog(self.host, version="2.1", loading=True)
dialog.open()
self.addCleanup(dialog.deleteLater)
QApplication.processEvents()
self.assertFalse(dialog.loading_panel.isHidden())
self.assertTrue(dialog.ticker.is_running())
dialog.set_history(_HISTORY)
self.assertTrue(dialog.loading_panel.isHidden())
self.assertFalse(dialog.ticker.is_running())
self.assertIn("v2.0", dialog.browser.toPlainText())
self.assertIn("2.1", dialog.title_label.text())
self.assertEqual(dialog.subtitle_label.text(), "Изменения за 2 версии")
self.assertFalse(dialog.browser_btn.isHidden())
def test_new_badge_is_a_rounded_picture_not_a_flat_block(self) -> None:
history = (
{"version": "2.1", "notes": "a", "is_new": True},
{"version": "2.0", "notes": "b", "is_new": False},
)
dialog = WhatsNewDialog(self.host, version="2.1", history=history)
self.addCleanup(dialog.deleteLater)
html = dialog.browser.toHtml()
self.assertIn("zapret-badge://new/", html)
self.assertNotIn("background-color", html.split("Ранее")[0])
def test_error_still_links_to_release_page(self) -> None:
dialog = WhatsNewDialog(self.host, version="2.1", loading=True)
self.addCleanup(dialog.deleteLater)
links: list[str] = []
dialog.link_clicked.connect(links.append)
dialog.show_error("нет сети")
dialog.browser_btn.click()
self.assertIn("нет сети", dialog.subtitle_label.text())
self.assertTrue(links[0].endswith("/releases/tag/2.1"))
class UpdateRunwayTests(_DialogCase):
def test_states(self) -> None:
runway = UpdateRunway(self.host)
runway.show()
self.assertTrue(runway.is_indeterminate())
runway.set_value(150)
self.assertEqual(runway.value(), 100.0)
self.assertFalse(runway.is_indeterminate())
runway.finish()
self.assertEqual(runway.state(), STATE_DONE)
self.assertFalse(runway.is_animating())
runway.reset()
runway.fail()
self.assertEqual(runway.state(), STATE_FAILED)
self.assertIn("ошибка", runway.property("screenReaderStateText"))
def test_runner_follows_progress(self) -> None:
runway = UpdateRunway(self.host)
runway.resize(400, runway.height())
runway.set_value(0)
start = runway.runner_x()
runway.set_value(100)
runway.hide() # без анимации процент встаёт сразу
self.assertGreater(runway.runner_x(), start + 200)
class FunTextsTests(unittest.TestCase):
def test_every_stage_has_jokes_in_both_languages(self) -> None:
for kind in ("preparing", "downloading", "installing", "failed", "whats_new"):
self.assertTrue(phrases(kind, "ru"), kind)
self.assertTrue(phrases(kind, "en"), kind)
self.assertNotEqual(phrases("downloading", "ru"), phrases("downloading", "en"))
def test_joke_pools_are_big_unique_and_fit_one_line(self) -> None:
# Чтобы от обновления к обновлению текст был разный, наборы большие;
# окно-продолжение не переносит строки, поэтому фразы короткие.
minimum = {"preparing": 10, "downloading": 20, "installing": 10, "failed": 6, "restarting": 15, "whats_new": 6}
for language in ("ru", "en"):
for kind, count in minimum.items():
pool = phrases(kind, language)
self.assertGreaterEqual(len(pool), count, (language, kind))
self.assertEqual(len(pool), len(set(pool)), (language, kind))
for phrase in pool:
self.assertLessEqual(len(phrase), 70, phrase)
if __name__ == "__main__":
unittest.main()