zapretgui/tests/test_restart_splash_windows.py
loop-uh e26b8e2475
All checks were successful
Zapret source guards / validate (push) Successful in 11s
Окно обновления можно свернуть: кнопка в углу и Esc
Жалоба 15.10.2026: окно-продолжение на пол-экрана висело поверх всех
окон, и его нельзя было ни закрыть, ни свернуть. Окно только показывает
ход: обновление ведут установщик и новая версия, поэтому спрятать его
можно в любой момент.
- Большое окно: в правом верхнем углу кнопка «Свернуть», как у обычных
  окон Windows, и Esc — окно уходит на панель задач, вернуть его можно
  щелчком по значку. Свёрнутое окно обратно не вытаскивается и гаснет
  вместе с обновлением, как раньше.
- Маленькая карточка (обновление из трея): крестик «Скрыть» — карточка
  уходит, обновление продолжается.
- Подсказка у кнопки на языке программы.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:26:18 +03:00

220 lines
9.7 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
"""Окно-продолжение в работе: настоящий PowerShell и WinForms.
На Linux пропускается — там нет ни PowerShell, ни WinForms. На Windows
проверяет то, ради чего окно существует: оно быстро встаёт на место окна
обновления, уходит при неудаче или отмене и гаснет, когда новая версия
открылась. Без сигналов — ждёт установщик.
"""
import json
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
from updater.install.splash import RestartSplashSpec, build_splash_command
from updater.install.splash_script import render_splash_script
@unittest.skipUnless(sys.platform == "win32", "нужны PowerShell и WinForms")
class RestartSplashWindowsTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.dir = Path(self._tmp.name)
self.state = self.dir / "handoff.json"
self.shown = self.dir / "shown"
self.ready = self.dir / "ready.json"
self.script = self.dir / "splash.ps1"
self.script.write_bytes(render_splash_script().encode("utf-8-sig"))
spec = RestartSplashSpec(
x=100,
y=100,
width=800,
height=560,
title="Обновляем Zapret до v2.0",
subtitle="v1.9 → v2.0",
stages=("Закрываем", "Устанавливаем", "Запускаем"),
footer="Окно закроется само",
window_title="Zapret — обновление",
jokes=("шутка",),
)
self.spec_path = self.dir / "spec.json"
self.spec_path.write_text(
json.dumps(
spec.to_payload(
logo_path="",
shown_path=str(self.shown),
ready_path=str(self.ready),
log_path=str(self.dir / "splash.log"),
),
ensure_ascii=False,
),
encoding="utf-8",
)
def _state(self, value: str | None) -> None:
if value is None:
self.state.unlink(missing_ok=True)
else:
self.state.write_text(json.dumps({"state": value}), encoding="utf-8")
def _start(self) -> subprocess.Popen:
command = build_splash_command(script_path=self.script, spec_path=self.spec_path, state_path=self.state)
process = subprocess.Popen(list(command), creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
self.addCleanup(lambda: process.poll() is None and process.kill())
deadline = time.monotonic() + 10
while not self.shown.exists() and time.monotonic() < deadline:
time.sleep(0.05)
self.assertTrue(self.shown.exists(), "окно не отметилось как показанное")
return process
def test_failed_install_closes_window_at_once(self) -> None:
self._state("launched")
process = self._start()
self._state("failed")
self.assertEqual(process.wait(timeout=10), 0)
def test_new_version_ready_closes_window(self) -> None:
self._state("succeeded")
process = self._start()
time.sleep(0.3)
self.ready.write_text("{}", encoding="utf-8")
self.assertEqual(process.wait(timeout=10), 0)
# Закрытое окно больше не ждёт: обычные запуски метку не пишут.
self.assertFalse(self.spec_path.exists())
def test_cancelled_update_closes_window(self) -> None:
self._state("prepared")
process = self._start()
self._state(None)
self.assertEqual(process.wait(timeout=15), 0)
def test_window_waits_while_installer_works(self) -> None:
self._state("launched")
process = self._start()
with self.assertRaises(subprocess.TimeoutExpired):
process.wait(timeout=3)
def _window(self, process: subprocess.Popen) -> int:
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32
found: list[int] = []
callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
def visit(hwnd, _lparam):
# По процессу и заголовку: без рабочего стола (сеанс ssh) Windows
# считает все окна невидимыми, а у WinForms есть и служебные окна.
pid = wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
if pid.value == process.pid:
title = ctypes.create_unicode_buffer(256)
user32.GetWindowTextW(hwnd, title, 256)
if title.value == "Zapret — обновление":
found.append(int(hwnd))
return True
deadline = time.monotonic() + 5
while not found and time.monotonic() < deadline:
user32.EnumWindows(callback_type(visit), 0)
time.sleep(0.05)
self.assertTrue(found, "окно процесса не найдено")
return found[0]
@staticmethod
def _iconic(hwnd: int) -> bool:
import ctypes
return bool(ctypes.windll.user32.IsIconic(hwnd))
def _click_corner(self, hwnd: int) -> None:
import ctypes
from ctypes import wintypes
rect = wintypes.RECT()
ctypes.windll.user32.GetClientRect(hwnd, ctypes.byref(rect))
point = (12 << 16) | (rect.right - 12)
for message, wparam in ((0x0200, 0), (0x0201, 1), (0x0202, 0)): # MOVE, LBUTTONDOWN, LBUTTONUP
ctypes.windll.user32.PostMessageW(hwnd, message, wparam, point)
time.sleep(0.1)
def test_escape_minimizes_window_and_update_goes_on(self) -> None:
self._state("launched")
process = self._start()
hwnd = self._window(process)
import ctypes
ctypes.windll.user32.PostMessageW(hwnd, 0x0100, 0x1B, 0) # WM_KEYDOWN, Esc
deadline = time.monotonic() + 3
while not self._iconic(hwnd) and time.monotonic() < deadline:
time.sleep(0.05)
self.assertTrue(self._iconic(hwnd), "Esc не свернул окно")
# Свёрнутое окно по-прежнему ждёт и гаснет вместе с обновлением.
self.assertIsNone(process.poll())
self._state("succeeded")
time.sleep(0.3)
self.ready.write_text("{}", encoding="utf-8")
self.assertEqual(process.wait(timeout=10), 0)
def test_corner_button_minimizes_window(self) -> None:
self._state("launched")
process = self._start()
hwnd = self._window(process)
self._click_corner(hwnd)
deadline = time.monotonic() + 3
while not self._iconic(hwnd) and time.monotonic() < deadline:
time.sleep(0.05)
self.assertTrue(self._iconic(hwnd), "кнопка в углу не свернула окно")
self.assertIsNone(process.poll())
def test_corner_button_hides_small_card(self) -> None:
payload = json.loads(self.spec_path.read_text(encoding="utf-8"))
payload.update(layout="compact", width=460, height=190)
self.spec_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
self._state("launched")
process = self._start()
self._click_corner(self._window(process))
self.assertEqual(process.wait(timeout=10), 0)
log_text = (self.dir / "splash.log").read_text(encoding="utf-8", errors="replace")
self.assertIn("карточку скрыли", log_text)
def test_snapshot_draws_every_stage(self) -> None:
for stage in ("prepared", "launched", "succeeded"):
snapshot = self.dir / f"{stage}.png"
command = build_splash_command(script_path=self.script, spec_path=self.spec_path, state_path=self.state)
subprocess.run(
list(command) + ["-SnapshotPath", str(snapshot), "-SnapshotState", stage, "-SnapshotAfterMs", "600"],
timeout=30,
check=True,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
self.assertGreater(snapshot.stat().st_size, 1000, stage)
def test_snapshot_draws_small_card(self) -> None:
"""Маленькая карточка автоматического обновления рисуется на каждом этапе."""
payload = json.loads(self.spec_path.read_text(encoding="utf-8"))
payload.update(layout="compact", width=460, height=190)
self.spec_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
for stage in ("prepared", "launched", "succeeded"):
snapshot = self.dir / f"compact_{stage}.png"
command = build_splash_command(script_path=self.script, spec_path=self.spec_path, state_path=self.state)
subprocess.run(
list(command) + ["-SnapshotPath", str(snapshot), "-SnapshotState", stage, "-SnapshotAfterMs", "600"],
timeout=30,
check=True,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
self.assertGreater(snapshot.stat().st_size, 1000, stage)
log_text = (self.dir / "splash.log").read_text(encoding="utf-8", errors="replace")
self.assertNotIn("Ошибка отрисовки", log_text)
self.assertNotIn("Ошибка таймера", log_text)
if __name__ == "__main__":
unittest.main()