All checks were successful
Zapret source guards / validate (push) Successful in 11s
Жалоба 15.10.2026: окно-продолжение на пол-экрана висело поверх всех окон, и его нельзя было ни закрыть, ни свернуть. Окно только показывает ход: обновление ведут установщик и новая версия, поэтому спрятать его можно в любой момент. - Большое окно: в правом верхнем углу кнопка «Свернуть», как у обычных окон Windows, и Esc — окно уходит на панель задач, вернуть его можно щелчком по значку. Свёрнутое окно обратно не вытаскивается и гаснет вместе с обновлением, как раньше. - Маленькая карточка (обновление из трея): крестик «Скрыть» — карточка уходит, обновление продолжается. - Подсказка у кнопки на языке программы. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
558 lines
31 KiB
Python
558 lines
31 KiB
Python
from __future__ import annotations
|
||
|
||
"""Окно-продолжение: обновление видно от загрузки до открытия новой версии.
|
||
|
||
Раньше между закрытием старой версии и открытием новой экран был пустым.
|
||
Теперь окно обновления сменяет отдельное окно PowerShell, которое переживает
|
||
замену ``Zapret.exe`` и гаснет, когда новая версия откроется.
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
from unittest.mock import Mock, patch
|
||
|
||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||
|
||
from updater.install import paths, splash
|
||
from updater.install.splash import RestartSplashSpec
|
||
from updater.install.splash_script import SPLASH_SCRIPT_TEMPLATE, render_splash_script
|
||
|
||
|
||
def _spec(**overrides) -> RestartSplashSpec:
|
||
values = dict(
|
||
x=10,
|
||
y=20,
|
||
width=900,
|
||
height=600,
|
||
title="Обновляем Zapret до v2.0",
|
||
subtitle="v1.9 → v2.0",
|
||
stages=("Закрываем", "Устанавливаем v2.0", "Запускаем"),
|
||
footer="Окно закроется само",
|
||
window_title="Zapret — обновление",
|
||
jokes=("шутка",),
|
||
colors={"background": "#2b2b2b", "accent": "#00c8ff"},
|
||
logo_png=b"\x89PNG-logo",
|
||
)
|
||
values.update(overrides)
|
||
return RestartSplashSpec(**values)
|
||
|
||
|
||
class _StateDirCase(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
self._tmp = tempfile.TemporaryDirectory()
|
||
self.addCleanup(self._tmp.cleanup)
|
||
self.state_dir = Path(self._tmp.name)
|
||
patcher = patch.object(paths, "update_state_dir", return_value=self.state_dir)
|
||
patcher.start()
|
||
self.addCleanup(patcher.stop)
|
||
|
||
|
||
class PrepareSplashTests(_StateDirCase):
|
||
def test_files_are_laid_out_next_to_update_state(self) -> None:
|
||
(self.state_dir / paths.RESTART_SPLASH_SHOWN_NAME).write_text("старая метка")
|
||
(self.state_dir / paths.UPDATE_APP_READY_NAME).write_text("старая метка")
|
||
|
||
command = splash.prepare_restart_splash(_spec())
|
||
|
||
payload = json.loads((self.state_dir / paths.RESTART_SPLASH_SPEC_NAME).read_text(encoding="utf-8"))
|
||
self.assertEqual((payload["x"], payload["y"], payload["width"], payload["height"]), (10, 20, 900, 600))
|
||
self.assertEqual(payload["texts"]["stages"][1], "Устанавливаем v2.0")
|
||
self.assertEqual(len(payload["texts"]["statuses"]), 3)
|
||
self.assertEqual(payload["app_process_name"], "Zapret")
|
||
self.assertEqual(payload["old_pid"], os.getpid())
|
||
self.assertEqual((self.state_dir / paths.RESTART_SPLASH_LOGO_NAME).read_bytes(), b"\x89PNG-logo")
|
||
script = (self.state_dir / paths.RESTART_SPLASH_SCRIPT_NAME).read_bytes()
|
||
# BOM: PowerShell 5.1 иначе портит русский текст.
|
||
self.assertTrue(script.startswith(b"\xef\xbb\xbf"))
|
||
# Старые метки не гасят новое окно и не обманывают ожидание «показано».
|
||
self.assertFalse((self.state_dir / paths.RESTART_SPLASH_SHOWN_NAME).exists())
|
||
self.assertFalse((self.state_dir / paths.UPDATE_APP_READY_NAME).exists())
|
||
self.assertIn("-STA", command)
|
||
self.assertEqual(command[command.index("-StatePath") + 1], str(self.state_dir / paths.HANDOFF_STATE_NAME))
|
||
|
||
def test_window_is_allowed_to_take_focus_before_start(self) -> None:
|
||
order: list[str] = []
|
||
|
||
with patch.object(splash, "_allow_splash_foreground", side_effect=lambda: order.append("allow")):
|
||
splash.show_restart_splash(_spec(), spawn=lambda _cmd: order.append("spawn") or True, wait=lambda _p: True)
|
||
|
||
self.assertEqual(order, ["allow", "spawn"])
|
||
|
||
def test_show_waits_for_window_and_never_raises(self) -> None:
|
||
spawned: list[tuple] = []
|
||
|
||
self.assertTrue(splash.show_restart_splash(_spec(), spawn=lambda cmd: spawned.append(cmd) or True, wait=lambda _p: True))
|
||
self.assertEqual(len(spawned), 1)
|
||
self.assertFalse(splash.show_restart_splash(_spec(), spawn=lambda _cmd: False, wait=lambda _p: True))
|
||
self.assertFalse(splash.show_restart_splash(_spec(), spawn=lambda _cmd: True, wait=lambda _p: False))
|
||
self.assertFalse(splash.show_restart_splash(None, spawn=Mock(), wait=Mock()))
|
||
|
||
def test_ready_mark_is_written_only_while_window_waits(self) -> None:
|
||
self.assertFalse(splash.mark_update_app_ready("2.0"))
|
||
self.assertFalse((self.state_dir / paths.UPDATE_APP_READY_NAME).exists())
|
||
|
||
splash.prepare_restart_splash(_spec())
|
||
|
||
self.assertTrue(splash.mark_update_app_ready("2.0"))
|
||
marker = json.loads((self.state_dir / paths.UPDATE_APP_READY_NAME).read_text(encoding="utf-8"))
|
||
self.assertEqual(marker["version"], "2.0")
|
||
|
||
def test_wait_for_shown_polls_until_marker(self) -> None:
|
||
marker = self.state_dir / "shown"
|
||
clock = iter(range(100))
|
||
|
||
def sleep(_seconds: float) -> None:
|
||
marker.write_text("ok")
|
||
|
||
self.assertTrue(
|
||
splash.wait_for_splash_shown(marker, timeout_seconds=5, sleep=sleep, monotonic=lambda: next(clock))
|
||
)
|
||
|
||
|
||
class InstallerProgressTests(_StateDirCase):
|
||
"""Полоса честная: ход копирования — по журналу установщика."""
|
||
|
||
def test_expected_files_come_from_last_successful_install_log(self) -> None:
|
||
log = self.state_dir / paths.SETUP_LOG_NAME
|
||
log.write_text("Starting the installation process.\n" + "-- File entry --\n" * 682 + "Installation process succeeded.\n", encoding="utf-8")
|
||
|
||
self.assertEqual(splash.estimate_installer_files(setup_log=log, install_root=None), 682)
|
||
|
||
def test_failed_log_falls_back_to_files_on_disk_without_user_data(self) -> None:
|
||
log = self.state_dir / paths.SETUP_LOG_NAME
|
||
log.write_text("-- File entry --\n" * 5, encoding="utf-8") # без «succeeded» — не в счёт
|
||
root = self.state_dir / "root"
|
||
(root / "_internal").mkdir(parents=True)
|
||
(root / "user").mkdir()
|
||
for name in ("a", "b", "c"):
|
||
(root / "_internal" / name).write_text("x")
|
||
(root / "user" / "settings.sqlite3").write_text("x")
|
||
|
||
self.assertEqual(splash.estimate_installer_files(setup_log=log, install_root=root), 3)
|
||
self.assertEqual(splash.estimate_installer_files(setup_log=self.state_dir / "нет.log", install_root=None), 0)
|
||
|
||
def test_spec_tells_window_where_installer_log_is(self) -> None:
|
||
with patch.object(splash, "_install_root", return_value=None):
|
||
splash.prepare_restart_splash(_spec())
|
||
|
||
payload = json.loads((self.state_dir / paths.RESTART_SPLASH_SPEC_NAME).read_text(encoding="utf-8"))
|
||
self.assertEqual(payload["setup_log_path"], str(self.state_dir / paths.SETUP_LOG_NAME))
|
||
self.assertIn("expected_files", payload)
|
||
self.assertIn("{done}", payload["texts"]["files_template"])
|
||
|
||
|
||
class InstallFlowSplashTests(unittest.TestCase):
|
||
def _run(self, *, started: bool, show_splash) -> None:
|
||
from updater.download import flow
|
||
|
||
dpi = Mock()
|
||
dpi.is_running.return_value = False
|
||
with patch.object(flow, "_resolve_and_download", return_value=Mock()):
|
||
flow.run_update_install(
|
||
"2.0",
|
||
token=Mock(),
|
||
dpi=dpi,
|
||
on_stage=lambda _text: None,
|
||
start_installation=lambda _handoff: started,
|
||
splash=_spec(),
|
||
show_splash=show_splash,
|
||
)
|
||
|
||
def test_window_appears_only_after_installer_was_handed_over(self) -> None:
|
||
shown = Mock(return_value=True)
|
||
|
||
self._run(started=True, show_splash=shown)
|
||
shown.assert_called_once()
|
||
|
||
not_shown = Mock()
|
||
from updater.download.downloader import UpdatePipelineError
|
||
|
||
with self.assertRaises(UpdatePipelineError):
|
||
self._run(started=False, show_splash=not_shown)
|
||
not_shown.assert_not_called()
|
||
|
||
def test_window_failure_does_not_break_update(self) -> None:
|
||
with patch("updater.download.flow.log"):
|
||
self._run(started=True, show_splash=Mock(side_effect=OSError("диск занят")))
|
||
|
||
|
||
class SplashSpecTests(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls) -> None:
|
||
from PyQt6.QtWidgets import QApplication
|
||
|
||
cls._app = QApplication.instance() or QApplication([])
|
||
|
||
def test_physical_rect_scales_offset_inside_screen(self) -> None:
|
||
from PyQt6.QtCore import QPoint, QRect
|
||
|
||
from updater.ui.restart_splash_spec import to_physical_rect
|
||
|
||
screen = Mock()
|
||
screen.devicePixelRatio.return_value = 1.5
|
||
screen.geometry.return_value.topLeft.return_value = QPoint(1920, 0)
|
||
|
||
self.assertEqual(to_physical_rect(QRect(2020, 100, 800, 600), screen=screen), (2070, 150, 1200, 900))
|
||
|
||
def test_theme_colors_become_solid_hex(self) -> None:
|
||
from types import SimpleNamespace
|
||
|
||
from updater.ui.restart_splash_spec import splash_colors
|
||
|
||
colors = splash_colors(
|
||
SimpleNamespace(
|
||
is_light=False,
|
||
fg="rgba(255, 255, 255, 0.5)",
|
||
fg_muted="rgba(255, 255, 255, 0.65)",
|
||
accent_hex="#00c8ff",
|
||
accent_fg="#000000",
|
||
)
|
||
)
|
||
|
||
for value in colors.values():
|
||
self.assertRegex(value, r"^#[0-9a-f]{6}$")
|
||
# Полупрозрачный белый над тёмным фоном страницы — серый, а не белый.
|
||
self.assertEqual(colors["foreground"], "#939393")
|
||
# Фон и карточки — цвета главного окна программы.
|
||
self.assertEqual((colors["background"], colors["card"]), ("#272727", "#323232"))
|
||
|
||
def test_spec_takes_place_of_update_window(self) -> None:
|
||
from PyQt6.QtWidgets import QWidget
|
||
|
||
from updater.ui.restart_splash_spec import build_restart_splash_spec
|
||
from updater.ui.fun_texts import phrases as fun_phrases
|
||
|
||
host = QWidget()
|
||
host.resize(1000, 800)
|
||
host.show()
|
||
self.addCleanup(host.deleteLater)
|
||
card = QWidget(host)
|
||
card.setGeometry(100, 50, 700, 500)
|
||
card.show()
|
||
|
||
spec = build_restart_splash_spec(
|
||
host, dialog_widget=card, current_version="1.9", target_version="2.0", language="ru"
|
||
)
|
||
|
||
self.assertEqual((spec.width, spec.height), (700, 500))
|
||
self.assertIn("v2.0", spec.title)
|
||
self.assertIn("v1.9", spec.subtitle)
|
||
self.assertEqual(len(spec.stages), 3)
|
||
self.assertEqual(spec.statuses, ("Готово", "Выполняется", "Ожидает"))
|
||
self.assertCountEqual(spec.jokes, fun_phrases("restarting", "ru"))
|
||
|
||
hidden = build_restart_splash_spec(
|
||
host, dialog_widget=None, current_version="1.9", target_version="2.0", language="en"
|
||
)
|
||
self.assertGreaterEqual(hidden.width, 720)
|
||
self.assertIn("Updating", hidden.title)
|
||
self.assertEqual(hidden.statuses, ("Done", "In progress", "Waiting"))
|
||
# Подсказка у кнопки в углу — на языке программы.
|
||
self.assertTrue(spec.hide_hint.startswith("Свернуть"))
|
||
self.assertTrue(hidden.hide_hint.startswith("Minimize"))
|
||
payload = spec.to_payload(logo_path="", shown_path="", ready_path="", log_path="")
|
||
self.assertEqual(payload["texts"]["hide"], spec.hide_hint)
|
||
|
||
|
||
def test_small_card_sits_in_the_bottom_right_corner_of_the_screen(self) -> None:
|
||
"""Маленькая карточка встаёт там, где Windows показывает уведомления."""
|
||
from PyQt6.QtWidgets import QWidget
|
||
|
||
from updater.ui.restart_splash_spec import (
|
||
COMPACT_HEIGHT,
|
||
COMPACT_MARGIN,
|
||
COMPACT_WIDTH,
|
||
build_restart_splash_spec,
|
||
)
|
||
|
||
host = QWidget()
|
||
host.setGeometry(200, 100, 1000, 800)
|
||
host.show()
|
||
self.addCleanup(host.deleteLater)
|
||
|
||
spec = build_restart_splash_spec(
|
||
host, dialog_widget=None, current_version="1.9", target_version="2.0", language="ru", compact=True
|
||
)
|
||
|
||
self.assertEqual(spec.layout, "compact")
|
||
self.assertEqual((spec.width, spec.height), (COMPACT_WIDTH, COMPACT_HEIGHT))
|
||
# Место не зависит от окна программы (оно может быть в трее): правый
|
||
# нижний угол рабочей области экрана, над панелью задач.
|
||
area = host.screen().availableGeometry()
|
||
ratio = host.screen().devicePixelRatio()
|
||
self.assertEqual(spec.x + spec.width, round((area.x() + area.width() - COMPACT_MARGIN) * ratio))
|
||
self.assertEqual(spec.y + spec.height, round((area.y() + area.height() - COMPACT_MARGIN) * ratio))
|
||
self.assertEqual((spec.title, spec.subtitle), ("Обновляем Zapret", "Ставим версию 2.0"))
|
||
# Этапы и фразы — те же, что у большого окна.
|
||
self.assertEqual(len(spec.stages), 3)
|
||
self.assertTrue(spec.jokes)
|
||
payload = spec.to_payload(logo_path="", shown_path="", ready_path="", log_path="")
|
||
self.assertEqual(payload["layout"], "compact")
|
||
# Свернуть карточку некуда — её кнопка «Скрыть».
|
||
self.assertTrue(spec.hide_hint.startswith("Скрыть"))
|
||
|
||
english = build_restart_splash_spec(
|
||
host, dialog_widget=None, current_version="1.9", target_version="2.0", language="en", compact=True
|
||
)
|
||
self.assertEqual((english.title, english.subtitle), ("Updating Zapret", "Installing version 2.0"))
|
||
|
||
# По кнопке «Обновить» окно прежнее.
|
||
full = build_restart_splash_spec(
|
||
host, dialog_widget=None, current_version="1.9", target_version="2.0", language="ru"
|
||
)
|
||
self.assertEqual(full.layout, "full")
|
||
self.assertGreaterEqual(full.width, 720)
|
||
self.assertEqual(_spec().to_payload(logo_path="", shown_path="", ready_path="", log_path="")["layout"], "full")
|
||
|
||
|
||
class SplashScriptContractTests(unittest.TestCase):
|
||
"""Правила скрипта, которые на Linux можно проверить только по тексту."""
|
||
|
||
def test_rules_that_keep_update_safe(self) -> None:
|
||
script = render_splash_script()
|
||
|
||
self.assertNotRegex(script, r"@[A-Z_]+@")
|
||
# Поверх всех окон — всё время обновления (решение владельца: окно не
|
||
# прятать). Снимается только при неудаче, чтобы сообщение установщика
|
||
# об ошибке было видно; тогда же окно уходит.
|
||
self.assertIn("$form.TopMost = (-not $S.Snapshot)", script)
|
||
self.assertIn("function Watch-OldApp", script)
|
||
watch = script[script.index("function Watch-OldApp"):script.index("function Read-SetupLog")]
|
||
self.assertNotIn("$form.TopMost = $false", watch)
|
||
self.assertIn("Watch-OldApp", script[script.index("function Poll-State"):])
|
||
failed = script[script.index("'failed' {"):script.index("'failed' {") + 260]
|
||
self.assertIn("$form.TopMost = $false", failed)
|
||
self.assertIn("Start-Closing $true 'установка не удалась'", failed)
|
||
self.assertIn("'обновление отменено'", script)
|
||
# Гаснет по метке новой версии, по её окну или по срокам.
|
||
self.assertIn("function Test-AppReady", script)
|
||
self.assertIn("function Test-NewAppWindow", script)
|
||
self.assertIn("$readyTimeoutSeconds = 90", script)
|
||
# Страховка вне общего try: окно не может висеть вечно.
|
||
self.assertIn("[System.Windows.Forms.Application]::Exit()", script)
|
||
# Прозрачность — только украшение: её сбой не мешает закрытию.
|
||
self.assertIn("function Set-FormOpacity", script)
|
||
# [Math]::Min(1, 0.16) в PowerShell — целочисленный вызов, равный 0:
|
||
# так окно навсегда оставалось прозрачным. Только дробные литералы.
|
||
self.assertNotRegex(script, r"\[Math\]::(?:Min|Max)\(\s*\d+\s*,")
|
||
self.assertIn("Set-FormOpacity ([Math]::Min(1.0, $S.Opacity + $dt / 0.2))", script)
|
||
self.assertIn("Появление не завершилось само", script)
|
||
# Копирование — по настоящему ходу: журнал установщика.
|
||
self.assertIn("function Read-SetupLog", script)
|
||
self.assertIn("-- File entry --", script)
|
||
self.assertIn("Starting the installation process.", script)
|
||
self.assertIn("Installation process succeeded.", script)
|
||
# Из того же журнала — имя файла, который копируется сейчас.
|
||
self.assertIn("Dest filename: ", script)
|
||
fill = script[script.index("function Target-Fill"):script.index("function Ease-Out")]
|
||
self.assertIn("0.10 + 0.80 * $share", fill)
|
||
self.assertIn("return 0.95 + 0.04", fill)
|
||
self.assertNotIn("/ 22.0", fill)
|
||
|
||
def test_progress_bar_only_moves_forward(self) -> None:
|
||
"""Полоса не бегает туда-обратно и не откатывается между этапами.
|
||
|
||
Раньше, пока установщик готовился, вместо заливки рисовался отрезок,
|
||
который ходил из края в край вместе с логотипом, а потом полоса
|
||
прыгала обратно на 5 %.
|
||
"""
|
||
script = render_splash_script()
|
||
fill = script[script.index("function Target-Fill"):script.index("function Ease-Out")]
|
||
|
||
# Нет режима «хода не видно»: у каждого этапа своя доля полосы.
|
||
self.assertNotIn("Indeterminate", script)
|
||
self.assertNotIn("return -1.0", fill)
|
||
self.assertNotIn("$bounce", script)
|
||
# Подготовка установщика тихо подползает от 5 к 10 %, копирование — с 10 %.
|
||
self.assertIn("return 0.05 + 0.05 * (1 - [Math]::Exp(-($now - $S.StageSince) / 4.0))", fill)
|
||
# Новое значение не меньше прежнего, а сглаживание идёт по времени —
|
||
# в оба звена: и цель с запасом, и сама полоса только растут.
|
||
self.assertIn(
|
||
"$S.Lead = [Math]::Max($S.Lead, $S.Lead + ($target - $S.Lead) * (1 - [Math]::Exp(-$dt / 0.14)))",
|
||
script,
|
||
)
|
||
self.assertIn(
|
||
"$S.Fill = [Math]::Max($S.Fill, $S.Fill + ($S.Lead - $S.Fill) * (1 - [Math]::Exp(-$dt / 0.16)))",
|
||
script,
|
||
)
|
||
|
||
def test_script_draws_small_card_for_compact_layout(self) -> None:
|
||
script = render_splash_script()
|
||
|
||
self.assertIn("$compact = ([string]$spec.layout -eq 'compact')", script)
|
||
self.assertIn("function Draw-FrameCompact", script)
|
||
# И окно, и проверочный кадр рисуются через общий выбор вида.
|
||
self.assertIn("if ($compact) { Draw-FrameCompact $g $width $height } else { Draw-Frame $g $width $height }", script)
|
||
self.assertEqual(script.count("Draw-Window $"), 2)
|
||
# Макет карточки рассчитан на те же размеры, что отдаёт программа.
|
||
from updater.ui.restart_splash_spec import COMPACT_HEIGHT, COMPACT_WIDTH
|
||
|
||
self.assertIn(f"$bounds.Width / ({COMPACT_WIDTH}.0 * $screenScale)", script)
|
||
self.assertIn(f"$bounds.Height / ({COMPACT_HEIGHT}.0 * $screenScale)", script)
|
||
# Полоса карточки идёт по тому же ходу, что и большая: только вперёд.
|
||
compact = script[script.index("function Draw-FrameCompact"):script.index("function Draw-Window")]
|
||
self.assertIn("$S.Fill", compact)
|
||
# Смена этапа не обрывает фразу: в карточке они сменяются по времени.
|
||
self.assertIn("if (-not $compact) { Next-Joke }", script)
|
||
|
||
def test_script_takes_nothing_from_install_folder(self) -> None:
|
||
# Ничего не берёт из каталога установки: он как раз заменяется.
|
||
self.assertNotIn("_internal", SPLASH_SCRIPT_TEMPLATE)
|
||
|
||
|
||
class PageSplashTests(unittest.TestCase):
|
||
def test_install_passes_splash_spec(self) -> None:
|
||
from app.feature_facades.updater import UpdaterFeature
|
||
from test_update_check_coordinator import UpdateCheckCoordinatorTests
|
||
|
||
case = UpdateCheckCoordinatorTests()
|
||
page = case._page(UpdaterFeature())
|
||
token = page._updater_feature.begin_update_check(source="manual")
|
||
page._updater_feature.finish_update_check(dict(case._FOUND), source="manual", token=token)
|
||
page._apply_check_snapshot(page._updater_feature.current_update_check_snapshot())
|
||
page._install_service.start.return_value = True
|
||
spec = _spec()
|
||
page._build_restart_splash_spec = Mock(return_value=spec)
|
||
|
||
with patch("updater.ui.page.run_update_setting_write"):
|
||
page._request_install_update()
|
||
|
||
page._install_service.start.assert_called_once_with("21.1.5.80", splash=spec, start_in_tray=False)
|
||
# По кнопке «Обновить» — окно на месте окна обновления.
|
||
self.assertEqual(page._build_restart_splash_spec.call_args.kwargs, {"compact": False})
|
||
|
||
def _automatic_install(self, *, shown: bool, in_use: bool):
|
||
from app.feature_facades.updater import UpdaterFeature
|
||
from test_update_check_coordinator import UpdateCheckCoordinatorTests
|
||
|
||
case = UpdateCheckCoordinatorTests()
|
||
page = case._page(UpdaterFeature())
|
||
token = page._updater_feature.begin_update_check(source="manual")
|
||
page._updater_feature.finish_update_check(dict(case._FOUND), source="manual", token=token)
|
||
page._apply_check_snapshot(page._updater_feature.current_update_check_snapshot())
|
||
page._install_service.start.return_value = True
|
||
page._build_restart_splash_spec = Mock(return_value=_spec())
|
||
page._host_window_shown = Mock(return_value=shown)
|
||
page._host_window_in_use = Mock(return_value=in_use)
|
||
|
||
with patch("updater.ui.page.run_update_setting_write"):
|
||
self.assertTrue(page._start_install(automatic=True))
|
||
return page
|
||
|
||
def test_automatic_install_shows_the_big_window_to_a_person_who_is_in_the_app(self) -> None:
|
||
page = self._automatic_install(shown=True, in_use=True)
|
||
|
||
# Программа перезапускается у человека на глазах — пусть видит всё окно.
|
||
self.assertEqual(page._build_restart_splash_spec.call_args.kwargs, {"compact": False})
|
||
self.assertFalse(page._install_service.start.call_args.kwargs["start_in_tray"])
|
||
|
||
def test_automatic_install_shows_the_small_card_when_the_person_is_elsewhere(self) -> None:
|
||
# Окно программы открыто, но человек в другом окне: карточка в углу экрана.
|
||
page = self._automatic_install(shown=True, in_use=False)
|
||
|
||
self.assertEqual(page._build_restart_splash_spec.call_args.kwargs, {"compact": True})
|
||
# Как в Zapret KVN: новая версия уходит в трей и не выскакивает
|
||
# поверх программы, в которой человек сейчас работает.
|
||
self.assertTrue(page._install_service.start.call_args.kwargs["start_in_tray"])
|
||
|
||
def test_automatic_install_from_tray_also_shows_the_small_card(self) -> None:
|
||
page = self._automatic_install(shown=False, in_use=False)
|
||
|
||
# Раньше из трея обновление шло совсем без окон.
|
||
self.assertEqual(page._build_restart_splash_spec.call_args.kwargs, {"compact": True})
|
||
self.assertIsNotNone(page._install_service.start.call_args.kwargs["splash"])
|
||
self.assertTrue(page._install_service.start.call_args.kwargs["start_in_tray"])
|
||
|
||
def test_update_from_tray_ends_calmly_not_as_cancelled(self) -> None:
|
||
script = render_splash_script()
|
||
|
||
gone = script[script.index("# Запись исчезла."):script.index("# Запись исчезла.") + 800]
|
||
# Новая версия в трее окна не показывает и убирает запись за собой:
|
||
# после успешной установки это конец обновления, а не отмена.
|
||
# Обновление по файлам журнала установщика не пишет: успехом считается
|
||
# и достигнутый этап «готово».
|
||
self.assertIn("if ($S.InstallSucceeded -or $S.Stage -ge 2) {", gone)
|
||
self.assertIn("Start-Closing $false 'новая версия открылась и убрала запись'", gone)
|
||
self.assertIn("Start-Closing $true 'обновление отменено'", gone)
|
||
self.assertLess(gone.index("$S.InstallSucceeded"), gone.index("обновление отменено"))
|
||
|
||
def test_small_card_is_rounded_and_alive(self) -> None:
|
||
script = render_splash_script()
|
||
|
||
card = script[script.index("function Draw-FrameCompact"):script.index("function Draw-Window")]
|
||
# Скруглённая кромка вместо прямоугольной рамки.
|
||
self.assertIn("$g.DrawPath($borderPen, $edge)", card)
|
||
self.assertNotIn("$g.DrawRectangle($borderPen", card)
|
||
# Медоед вертится, содержимое покачивается, по полосе идёт блик и бежит огонёк.
|
||
self.assertIn("$g.RotateTransform([single]$S.Spin)", card)
|
||
self.assertIn("$sway = [single](1.4 * $k * [Math]::Sin($now * 1.5))", card)
|
||
self.assertIn("$glintAt = Ease-InOut (($now % 1.7) / 1.7)", card)
|
||
self.assertIn("$scoutAt", card)
|
||
# В Windows 10 система углы не скругляет: край режет область окна.
|
||
self.assertIn("$S.RoundedBySystem = ([Environment]::OSVersion.Version.Build -ge 22000)", script)
|
||
self.assertIn("$form.Region = New-Object System.Drawing.Region($shape)", script)
|
||
|
||
def test_update_window_stays_on_top_until_the_end(self) -> None:
|
||
script = render_splash_script()
|
||
|
||
watch = script[script.index("function Watch-OldApp"):script.index("function Read-SetupLog")]
|
||
# Поверх всех окон — до конца обновления; снимается только при неудаче,
|
||
# чтобы сообщение установщика об ошибке было видно.
|
||
self.assertNotIn("$form.TopMost = $false", watch)
|
||
self.assertIn("окно остаётся поверх всех", watch)
|
||
failed = script[script.index("'failed' {"):script.index("'failed' {") + 260]
|
||
self.assertIn("$form.TopMost = $false", failed)
|
||
|
||
def test_person_can_put_the_window_away(self) -> None:
|
||
"""Жалоба 15.10.2026: окно на пол-экрана нельзя было ни закрыть, ни свернуть."""
|
||
script = render_splash_script()
|
||
|
||
# Кнопка в правом верхнем углу рисуется у обоих видов окна.
|
||
window = script[script.index("function Draw-Window"):script.index("function Test-InHideButton")]
|
||
self.assertIn("Draw-HideButton $g $width", window)
|
||
# Щелчок по кнопке и Esc прячут окно.
|
||
self.assertIn("if ($pressed -and (Test-InHideButton $e.Location)) { Hide-Splash 'кнопка' }", script)
|
||
self.assertIn("$form.KeyPreview = $true", script)
|
||
self.assertIn("if ($e.KeyCode -eq [System.Windows.Forms.Keys]::Escape) { Hide-Splash 'Esc' }", script)
|
||
hide = script[script.index("function Hide-Splash"):script.index("# Подсказка у кнопки")]
|
||
# Большое окно уходит на панель задач, карточка (её там нет) — закрывается.
|
||
self.assertIn("$form.WindowState = [System.Windows.Forms.FormWindowState]::Minimized", hide)
|
||
self.assertIn("Start-Closing $true", hide)
|
||
self.assertLess(hide.index("if ($compact) {"), hide.index("FormWindowState]::Minimized"))
|
||
# Свёрнутое окно обратно не вытаскивается, когда закрылась старая версия.
|
||
front = script[script.index("function Bring-ToFront"):script.index("function Watch-OldApp")]
|
||
self.assertLess(front.index("FormWindowState]::Minimized"), front.index("$form.Activate()"))
|
||
self.assertIn("$tip.Show([string]$spec.texts.hide", script)
|
||
|
||
def test_quiet_window_is_shown_without_taking_focus(self) -> None:
|
||
script = render_splash_script()
|
||
|
||
self.assertIn("$quiet = ($null -ne $spec.take_focus -and -not [bool]$spec.take_focus)", script)
|
||
front = script[script.index("function Bring-ToFront"):script.index("function Watch-OldApp")]
|
||
self.assertLess(front.index("if ($quiet) {"), front.index("$form.Activate()"))
|
||
self.assertIn("if (($compact -or $quiet) -and $null -ne $native -and -not $S.Snapshot) {", script)
|
||
self.assertTrue(_spec().to_payload(logo_path="", shown_path="", ready_path="", log_path="")["take_focus"])
|
||
quiet = _spec(take_focus=False).to_payload(logo_path="", shown_path="", ready_path="", log_path="")
|
||
self.assertFalse(quiet["take_focus"])
|
||
|
||
def test_small_card_stays_on_top_and_never_takes_focus(self) -> None:
|
||
script = render_splash_script()
|
||
|
||
front = script[script.index("function Bring-ToFront"):script.index("function Watch-OldApp")]
|
||
# Карточка — как уведомление: поверх всех окон, но без активации.
|
||
self.assertIn("if ($compact) {", front)
|
||
self.assertIn("$form.TopMost = $true", front)
|
||
self.assertIn("SetWindowPos($form.Handle, [IntPtr](-1), 0, 0, 0, 0, 0x0013)", front)
|
||
self.assertLess(front.index("return"), front.index("$form.Activate()"))
|
||
self.assertIn("$form.ShowInTaskbar = (-not $compact)", script)
|
||
# Запрет активации и скрытие из Alt+Tab ставятся до показа окна.
|
||
self.assertIn("$style = if ($compact) { 0x08000080 } else { 0x08000000 }", script)
|
||
# Вне экранов карточка встаёт в правый нижний угол, а не в центр.
|
||
self.assertIn("($area.Right - $w - 16), ($area.Bottom - $h - 16)", script)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|