zapret-kvn/tests/test_update_notification.py
loop-uh b97087c041
Some checks failed
Windows project source guards / test (push) Failing after 33s
Обновление не мешает игре: общее определение экрана, быстрее после игры
- Определение «на экране игра» — из общего ядра zapret_update.screen (то
  же, что у Zapret 2 GUI): игры «окно без рамки» и память об игре на 10
  минут — свернул игру и ушёл в Discord, а обновление ждёт.
- Пока человек в игре, программа раз в 5 с смотрит только на экран
  (было: полная проверка выпуска раз в 3 минуты) и проверяет выпуск,
  когда он освободился; занятая программа переспрашивает сервер раз в 15 с.
- Разрешение сервера не теряется: пока оно действует, получасовая проверка
  не пропускается и повторяет сорвавшуюся установку.
- Паузы после ошибок сети — не дольше 3 минут (было 15).

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

591 lines
24 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 ctypes
import inspect
from pathlib import Path
import sys
import unittest
from types import SimpleNamespace
from xray_fluent.constants import APP_VERSION
from unittest.mock import Mock, patch
class _FakeFunction:
def __call__(self, *args, **kwargs):
return 0
class _FakeLibrary:
def __getattr__(self, name: str):
function = _FakeFunction()
setattr(self, name, function)
return function
class _FakeWindll:
def __getattr__(self, name: str):
library = _FakeLibrary()
setattr(self, name, library)
return library
if sys.platform == "win32":
from xray_fluent.updates.app_updater import AppUpdate
from xray_fluent.ui.main_window import (
APP_UPDATE_INITIAL_DELAY_MS,
APP_UPDATE_INTERVAL_MS,
MainWindow,
_runtime_identity_log_line,
)
else:
_original_windll = getattr(ctypes, "windll", None)
ctypes.windll = _FakeWindll() # type: ignore[attr-defined]
try:
from xray_fluent.updates.app_updater import AppUpdate
from xray_fluent.ui.main_window import (
APP_UPDATE_INITIAL_DELAY_MS,
APP_UPDATE_INTERVAL_MS,
MainWindow,
_runtime_identity_log_line,
)
finally:
if _original_windll is None:
del ctypes.windll
else:
ctypes.windll = _original_windll # type: ignore[attr-defined]
from main import _runtime_identity_message, _setup_bootstrap_logging
class _FakeSignal:
def __init__(self) -> None:
self.callback = None
def disconnect(self) -> None:
raise TypeError
def connect(self, callback) -> None:
self.callback = callback
class _FakeUpdatesPage:
def __init__(self) -> None:
self.download_btn = SimpleNamespace(clicked=_FakeSignal())
self.available_version = ""
self.status = ""
def show_update_available(self, version: str) -> None:
self.available_version = version
def set_app_status(self, text: str) -> None:
self.status = text
class _FakeTimer:
def __init__(self) -> None:
self.interval = None
self.active = False
self.single_shot = None
self.start_count = 0
self.stop_count = 0
def setSingleShot(self, value: bool) -> None:
self.single_shot = value
def setInterval(self, value: int) -> None:
self.interval = value
def start(self) -> None:
self.active = True
self.start_count += 1
def stop(self) -> None:
self.active = False
self.stop_count += 1
def isActive(self) -> bool:
return self.active
def _update() -> AppUpdate:
return AppUpdate(
version="0.4.67",
tag="v0.4.67",
download_url="https://example.invalid/ZapretKVN-windows-x64.zip",
size=1,
notes="",
digest_sha256="0" * 64,
)
class UpdateNotificationTests(unittest.TestCase):
def test_runtime_identity_logs_version_executable_and_base_dir(self) -> None:
from xray_fluent.constants import APP_VERSION, BASE_DIR
startup_line = _runtime_identity_message()
app_line = _runtime_identity_log_line()
expected = (
f"app_version={APP_VERSION} "
f"executable={Path(sys.executable).resolve()} "
f"base_dir={BASE_DIR.resolve()}"
)
self.assertIn(expected, startup_line)
self.assertIn(f"[runtime] {expected}", app_line)
self.assertIn("_runtime_identity_message()", inspect.getsource(_setup_bootstrap_logging))
self.assertIn("_runtime_identity_log_line()", inspect.getsource(MainWindow.initialize))
self.assertNotIn("token=", startup_line.lower())
self.assertNotIn("token=", app_line.lower())
def test_startup_update_timer_uses_one_timer_then_switches_to_30_minutes(self) -> None:
timer = _FakeTimer()
settings = SimpleNamespace(check_updates=True)
window = SimpleNamespace(
_app_update_timer=timer,
_app_update_scheduler_ready=True,
_quitting=False,
controller=SimpleNamespace(
state=SimpleNamespace(settings=settings),
),
_check_updates=Mock(),
)
MainWindow._sync_app_update_timer(window, settings)
self.assertEqual(timer.interval, APP_UPDATE_INITIAL_DELAY_MS)
self.assertEqual(timer.start_count, 1)
self.assertTrue(timer.active)
# A second synchronisation while the initial timer is active must not
# reset its deadline or create another timer.
MainWindow._sync_app_update_timer(window, settings)
self.assertEqual(timer.start_count, 1)
MainWindow._on_app_update_timer_timeout(window)
window._check_updates.assert_called_once_with(silent=True)
self.assertEqual(timer.interval, APP_UPDATE_INTERVAL_MS)
self.assertEqual(timer.start_count, 2)
self.assertTrue(timer.active)
def test_disabling_update_checks_stops_the_timer(self) -> None:
timer = _FakeTimer()
timer.active = True
window = SimpleNamespace(
_app_update_timer=timer,
_quitting=False,
)
MainWindow._sync_app_update_timer(
window,
SimpleNamespace(check_updates=False),
)
self.assertFalse(timer.active)
self.assertEqual(timer.stop_count, 1)
def test_timer_callback_does_not_start_after_quit(self) -> None:
timer = _FakeTimer()
window = SimpleNamespace(
_app_update_timer=timer,
_app_update_scheduler_ready=True,
_quitting=True,
_check_updates=Mock(),
)
MainWindow._on_app_update_timer_timeout(window)
window._check_updates.assert_not_called()
self.assertEqual(timer.start_count, 0)
def test_update_check_guard_rejects_a_concurrent_checker(self) -> None:
window = SimpleNamespace(_update_in_progress=True)
# This is the guard at the top of _check_updates; both the manual
# button and the periodic timer use the same method.
MainWindow._check_updates(window, silent=True)
self.assertTrue(window._update_in_progress)
def test_silent_update_error_is_logged_without_ui_notification(self) -> None:
logger = Mock()
page = SimpleNamespace(
show_idle=Mock(),
set_app_error=Mock(),
)
window = SimpleNamespace(
_update_in_progress=True,
controller=SimpleNamespace(_logger=logger),
updates_page=page,
_show_status=Mock(),
)
MainWindow._on_update_check_error(
window,
"Сервер обновлений временно не отвечает.",
silent=True,
)
self.assertFalse(window._update_in_progress)
logger.warning.assert_called_once()
self.assertIn(
"Фоновая проверка обновлений не выполнена",
logger.warning.call_args.args[0],
)
page.show_idle.assert_not_called()
page.set_app_error.assert_not_called()
window._show_status.assert_not_called()
def _check_result_window(self, block_reason: str = ""):
return SimpleNamespace(
_update_in_progress=True,
_pending_update=None,
controller=SimpleNamespace(
state=SimpleNamespace(settings=SimpleNamespace(allow_updates=True)),
_logger=Mock(),
),
updates_page=_FakeUpdatesPage(),
_start_update_download=Mock(),
_auto_install_block_reason=Mock(return_value=block_reason),
# Сервер с очередью обновлений в этих тестах не участвует.
_update_wait_reason=Mock(return_value=""),
)
def test_silent_check_installs_in_background_without_dialog(self) -> None:
window = self._check_result_window()
update = _update()
with patch("qfluentwidgets.MessageBox") as box:
MainWindow._on_update_check_result(window, update, silent=True)
box.assert_not_called()
self.assertFalse(window._update_in_progress)
self.assertIs(window._pending_update, update)
self.assertEqual(window.updates_page.available_version, update.version)
window._start_update_download.assert_called_once_with(update, background=True)
def test_blocked_auto_install_only_updates_page_status(self) -> None:
window = self._check_result_window("установка не удалась уже 3 раза")
MainWindow._on_update_check_result(window, _update(), silent=True)
window._start_update_download.assert_not_called()
self.assertIn("не удалась уже 3 раза", window.updates_page.status)
# Кнопка страницы остаётся ручным путём в обход лимита.
window.updates_page.download_btn.clicked.callback()
window._start_update_download.assert_called_once_with(window._pending_update)
def test_download_button_keeps_its_version_while_a_new_check_runs(self) -> None:
"""14.10.2026: новая проверка обнулила версию, а кнопка на экране качала «ничего»."""
window = self._check_result_window("версия раздаётся по очереди")
update = _update()
MainWindow._on_update_check_result(window, update, silent=True)
window._pending_update = None # так начинается каждая проверка
window.updates_page.download_btn.clicked.callback()
window._start_update_download.assert_called_once_with(update)
def test_download_without_a_version_does_nothing(self) -> None:
# У «окна» нет ничего: любое обращение дальше первой строки упало бы.
MainWindow._start_update_download(SimpleNamespace(), None)
def test_manual_check_installs_without_dialog_and_ignores_attempt_limit(self) -> None:
window = self._check_result_window("установка не удалась уже 3 раза")
update = _update()
MainWindow._on_update_check_result(window, update, silent=False)
window._auto_install_block_reason.assert_not_called()
window._start_update_download.assert_called_once_with(update)
def _restart_window(self, *, visible=True, minimized=False, active=False, tray=True):
return SimpleNamespace(
_tray_available=tray,
isVisible=lambda: visible,
isMinimized=lambda: minimized,
isActiveWindow=lambda: active,
)
def test_background_restart_shows_window_only_when_user_is_in_it(self) -> None:
restart_in_tray = MainWindow._restart_update_in_tray
self.assertFalse(restart_in_tray(self._restart_window(active=True), True))
# Окно за другими программами или свёрнутое не всплывает поверх всего.
self.assertTrue(restart_in_tray(self._restart_window(active=False), True))
self.assertTrue(restart_in_tray(self._restart_window(minimized=True, active=True), True))
self.assertTrue(restart_in_tray(self._restart_window(visible=False), True))
# Без трея окну некуда уйти — показываем как раньше.
self.assertFalse(restart_in_tray(self._restart_window(tray=False), True))
def test_manual_restart_keeps_visible_window(self) -> None:
restart_in_tray = MainWindow._restart_update_in_tray
self.assertFalse(restart_in_tray(self._restart_window(active=False), False))
self.assertTrue(restart_in_tray(self._restart_window(visible=False), False))
def test_password_protection_blocks_silent_restart(self) -> None:
window = SimpleNamespace(
controller=SimpleNamespace(state=SimpleNamespace(security=SimpleNamespace(enabled=True)))
)
reason = MainWindow._auto_install_block_reason(window, _update())
self.assertIn("защита паролем", reason)
def test_silent_up_to_date_check_does_not_repaint_updates_page(self) -> None:
window = SimpleNamespace(
_update_in_progress=True,
updates_page=SimpleNamespace(show_up_to_date=Mock()),
_show_status=Mock(),
)
MainWindow._on_update_check_result(window, None, silent=True)
self.assertFalse(window._update_in_progress)
window.updates_page.show_up_to_date.assert_not_called()
window._show_status.assert_not_called()
def test_active_update_proxy_url_uses_effective_runtime_port(self) -> None:
window = SimpleNamespace(
controller=SimpleNamespace(
connected=True,
get_effective_http_proxy_port=Mock(return_value=1401),
)
)
proxy_url = MainWindow._active_update_proxy_url(window)
self.assertEqual(proxy_url, "http://127.0.0.1:1401")
def test_stale_checker_result_cannot_overwrite_current_check(self) -> None:
current_checker = object()
stale_checker = object()
window = SimpleNamespace(
_update_checker=current_checker,
_update_in_progress=True,
updates_page=SimpleNamespace(show_up_to_date=Mock()),
_show_status=Mock(),
)
MainWindow._on_update_check_result(
window,
None,
silent=False,
checker=stale_checker,
)
self.assertTrue(window._update_in_progress)
window.updates_page.show_up_to_date.assert_not_called()
def _apply_window(self, *, busy: bool, deadline_passed: bool = False):
downloader = SimpleNamespace(update=_update(), prepared=object(), via="files", downloaded=196_000)
return SimpleNamespace(
_quitting=False,
_update_downloader=downloader,
_update_apply_deadline=float("-inf") if deadline_passed else float("inf"),
controller=SimpleNamespace(
transition_busy=Mock(return_value=busy),
connected=True,
_desired_connected=True,
_logger=Mock(),
),
_install_plan=Mock(),
_await_installer=Mock(),
_quit_for_update=Mock(),
_on_update_error=Mock(),
_apply_downloaded_update=Mock(),
)
def test_downloaded_update_waits_for_running_transition(self) -> None:
window = self._apply_window(busy=True)
with patch("xray_fluent.ui.main_window.QTimer.singleShot") as single_shot, \
patch("xray_fluent.ui.main_window.record_attempt") as record, \
patch("xray_fluent.ui.main_window.launch_installer") as launch:
MainWindow._apply_downloaded_update(window)
single_shot.assert_called_once()
record.assert_not_called()
launch.assert_not_called()
window._quit_for_update.assert_not_called()
def test_downloaded_update_records_attempt_then_launches_and_awaits_installer(self) -> None:
for busy, deadline_passed in ((False, False), (True, True)):
window = self._apply_window(busy=busy, deadline_passed=deadline_passed)
order: list[str] = []
with patch(
"xray_fluent.ui.main_window.record_attempt",
side_effect=lambda *a, **k: order.append("record"),
) as record, patch(
"xray_fluent.ui.main_window.launch_installer",
side_effect=lambda *a, **k: order.append("launch") or "installer",
) as launch:
MainWindow._apply_downloaded_update(window)
self.assertEqual(order, ["record", "launch"])
# Вместе с попыткой запоминается, с какой версии идёт обновление:
# новая версия сообщит серверу, что оно дошло.
# и какой ступенью и сколько скачано — это тоже уйдёт серверу.
record.assert_called_once_with(
"0.4.67", reconnect=True, from_version=APP_VERSION, granted_at=0.0, via="files", downloaded=196_000
)
launch.assert_called_once_with(window._install_plan.return_value)
# Выход — только после подтверждения установщика.
window._quit_for_update.assert_not_called()
window._await_installer.assert_called_once()
self.assertEqual(window._await_installer.call_args.args[0], "installer")
def test_failed_installer_launch_keeps_app_running(self) -> None:
window = self._apply_window(busy=False)
with patch("xray_fluent.ui.main_window.record_attempt"), \
patch("xray_fluent.ui.main_window.launch_installer", side_effect=OSError("denied")):
MainWindow._apply_downloaded_update(window)
window._quit_for_update.assert_not_called()
window._await_installer.assert_not_called()
window._on_update_error.assert_called_once()
def _await_window(self):
return SimpleNamespace(
_quitting=False,
controller=SimpleNamespace(_logger=Mock()),
_quit_for_update=Mock(),
_on_update_error=Mock(),
)
def test_app_quits_once_the_installer_confirms_it_is_running(self) -> None:
window = self._await_window()
installer = Mock(poll=Mock(return_value=None))
marker = Mock(exists=Mock(return_value=True))
MainWindow._await_installer(window, installer, marker, float("inf"))
window._quit_for_update.assert_called_once()
window._on_update_error.assert_not_called()
def test_app_keeps_waiting_while_the_installer_starts(self) -> None:
window = self._await_window()
installer = Mock(poll=Mock(return_value=None))
marker = Mock(exists=Mock(return_value=False))
with patch("xray_fluent.ui.main_window.QTimer.singleShot") as single_shot:
MainWindow._await_installer(window, installer, marker, float("inf"))
single_shot.assert_called_once()
window._quit_for_update.assert_not_called()
window._on_update_error.assert_not_called()
def test_app_stays_open_when_the_installer_dies_or_stays_silent(self) -> None:
marker = Mock(exists=Mock(return_value=False))
for exit_code, deadline, killed in ((1, float("inf"), False), (None, float("-inf"), True)):
window = self._await_window()
installer = Mock(poll=Mock(return_value=exit_code))
MainWindow._await_installer(window, installer, marker, deadline)
window._quit_for_update.assert_not_called()
window._on_update_error.assert_called_once()
self.assertEqual(installer.kill.called, killed)
def test_background_download_error_is_logged_without_toast(self) -> None:
window = SimpleNamespace(
_update_in_progress=True,
_update_background=True,
updates_page=SimpleNamespace(show_idle=Mock(), set_app_error=Mock()),
controller=SimpleNamespace(_logger=Mock()),
_show_status=Mock(),
)
MainWindow._on_update_error(window, "нет сети")
self.assertFalse(window._update_in_progress)
window._show_status.assert_not_called()
window.controller._logger.warning.assert_called_once()
window._update_background = False
MainWindow._on_update_error(window, "нет сети")
window._show_status.assert_called_once_with("error", "нет сети")
def test_broken_archive_counts_as_attempt_but_network_error_does_not(self) -> None:
for permanent in (True, False):
window = SimpleNamespace(
_update_in_progress=True,
_update_background=True,
_update_downloader=SimpleNamespace(update=_update(), failure_permanent=permanent),
updates_page=SimpleNamespace(show_idle=Mock(), set_app_error=Mock()),
controller=SimpleNamespace(_logger=Mock()),
_show_status=Mock(),
)
with patch("xray_fluent.ui.main_window.record_attempt") as record:
MainWindow._on_update_error(window, "ошибка")
if permanent:
record.assert_called_once_with("0.4.67", reconnect=False, restarting=False)
else:
record.assert_not_called()
class CoreUpdateStatusColorTests(unittest.TestCase):
"""«Актуален» у sing-box зелёный, как у Xray (раньше был серым)."""
def _window(self):
page = SimpleNamespace(
set_singbox_error=Mock(), set_singbox_success=Mock(),
set_singbox_status=Mock(), set_singbox_version=Mock(),
)
return SimpleNamespace(updates_page=page, logs_page=SimpleNamespace(append_line=Mock()))
def test_up_to_date_and_updated_are_success(self) -> None:
from xray_fluent.engines.singbox.core_updater import SingboxCoreUpdateResult
for status in ("up_to_date", "updated"):
window = self._window()
MainWindow._on_singbox_update_result(
window, SingboxCoreUpdateResult(status=status, message="m", current_version="1.14.1-extended-2.7.2")
)
window.updates_page.set_singbox_success.assert_called_once_with("m")
window.updates_page.set_singbox_status.assert_not_called()
def test_available_stays_neutral_and_error_is_error(self) -> None:
from xray_fluent.engines.singbox.core_updater import SingboxCoreUpdateResult
window = self._window()
MainWindow._on_singbox_update_result(window, SingboxCoreUpdateResult(status="available", message="a"))
window.updates_page.set_singbox_status.assert_called_once_with("a")
window = self._window()
MainWindow._on_singbox_update_result(window, SingboxCoreUpdateResult(status="error", message="e"))
window.updates_page.set_singbox_error.assert_called_once_with("e")
if __name__ == "__main__":
unittest.main()
class PeriodicCheckWithQueueServerTests(unittest.TestCase):
"""Пока сервер с очередью на связи, о версии сообщает он, а не расписание."""
def _window(self, *, reachable: bool, first_done: bool, live_grant: bool = False):
settings = SimpleNamespace(check_updates=True)
return SimpleNamespace(
_app_update_timer=_FakeTimer(),
_app_update_scheduler_ready=True,
_quitting=False,
_app_update_first_check_done=first_done,
_update_signal=SimpleNamespace(queue_reachable=lambda: reachable, has_live_grant=lambda: live_grant),
controller=SimpleNamespace(state=SimpleNamespace(settings=settings)),
_check_updates=Mock(),
)
def test_first_check_after_start_always_runs(self) -> None:
window = self._window(reachable=True, first_done=False)
MainWindow._on_app_update_timer_timeout(window)
window._check_updates.assert_called_once_with(silent=True)
def test_later_checks_are_skipped_while_the_server_is_reachable(self) -> None:
window = self._window(reachable=True, first_done=True)
MainWindow._on_app_update_timer_timeout(window)
window._check_updates.assert_not_called()
def test_failed_install_is_retried_while_the_permission_lasts(self) -> None:
# Сервер разрешил, а версия не встала: второго разрешения не будет,
# поэтому получасовая проверка повторяет установку сама.
window = self._window(reachable=True, first_done=True, live_grant=True)
MainWindow._on_app_update_timer_timeout(window)
window._check_updates.assert_called_once_with(silent=True)
def test_without_the_server_program_checks_itself_as_before(self) -> None:
window = self._window(reachable=False, first_done=True)
MainWindow._on_app_update_timer_timeout(window)
window._check_updates.assert_called_once_with(silent=True)