zapretgui/tests/test_notification_dedupe.py
loop-uh 379fb6113e Называть настоящую причину отказа WinDivert вместо состояния покоя
Диагностика кода 1058 четвёртым шагом спрашивала probe и, увидев
отсутствие службы WinDivert, объявляла это причиной: «ещё не установился
или не готов к открытию фильтра (код 1060)». Но probe выполняется уже
после смерти winws2 и с флагом NO_INSTALL, а WinDivert снимает свою
службу при закрытии последнего дескриптора, — «службы нет» это обычное
состояние покоя. Ветка срабатывала почти на каждом отказе и глушила три
проверки ниже: конфликт с чужой программой, антивирус, сетевые адаптеры.
Теперь probe стоит последним и отвечает только в обратном случае, когда
служба есть, а NETWORK layer всё равно не открывается.

Разделены измеренный код ошибки и восстановленный догадкой. Эвристика
«34 без диагностического вывода = усечённый 1058» — единственное место,
где Win32-код не измерен, и теперь она помечает диагноз: «код завершения
процесса 34; предположительно код ошибки Windows 1058», причина подаётся
как вероятная. Код, вычитанный из вывода winws2, остаётся утвердительным.

Опознаётся FWP_E_IN_USE — «The object is referenced by other objects so
cannot be deleted». Это остатки прошлого экземпляра: объекты WFP ещё
удерживают драйвер. Раньше случай попадал в общий fallback как «Ошибка
WinDivert (код 10)» с английской строкой вместо совета и не лечился
вовсе; в логах пользователя за ним шли STATUS_DLL_INIT_FAILED и 1058.
Теперь у него есть запись в таблице кодов, подсказка с именем программы-
держателя и классификация как лечимый конфликт — то есть тяжёлая очистка
и один повтор.

Коды HRESULT/NTSTATUS показываются с hex: 2150760464 / 0x80320010 вместо
голого числа. Формула вынесена в общий format_windows_error_code, дубль
в post_mortem убран.

Одна ошибка запуска больше не показывается двумя уведомлениями. Ключи
дедупликации содержат имя источника, поэтому специализированный канал и
эхо глобального логгера не склеивались. Добавлена подпись по одному лишь
тексту со снятием служебных префиксов; она односторонняя и гасит только
эхо логгера, иначе можно потерять уведомление с кнопкой «Исправить».

Тесты: test_windivert_diagnostics — порядок проверок причины 1058,
недостижимость probe при найденном конфликте или антивирусе, обе формы
подачи кода, разбор FWP_E_IN_USE и формат кодов; test_notification_dedupe
— подавление эха логгера, сохранность специализированного канала, работа
после истечения окна.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:07:59 +03:00

149 lines
6 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.

"""Дедупликация уведомлений между специализированным каналом и логгером.
Одна ошибка запуска попадает в центр уведомлений дважды: сначала от
`launch.dpi_error` (с заголовком и кнопкой автолечения), затем эхом от
глобального логгера, который показывает любую ERROR-строку. Ключи
`dedupe_key` содержат имя источника, поэтому такие пары раньше не
склеивались и пользователь видел два тоста с одним текстом.
"""
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import Mock, patch
from PyQt6.QtWidgets import QApplication
from app_notifications import advisory_notification
from ui.window_notification_center import (
GLOBAL_ERROR_DEDUPE_WINDOW_MS,
GLOBAL_LOGGER_SOURCE,
WindowNotificationCenter,
)
_LAUNCH_ERROR_TEXT = (
"winws2 не запустился. Найдена причина: Объекты WinDivert от предыдущего "
"запуска ещё используются системой (код ошибки Windows 2150760464 / 0x80320010; "
"код завершения процесса 10)"
)
def _launch_error_payload(text: str = _LAUNCH_ERROR_TEXT) -> dict:
return advisory_notification(
level="error",
title="Ошибка запуска Zapret",
content=text,
source="launch.dpi_error",
presentation="infobar",
queue="immediate",
duration=-1,
dedupe_key=f"launch.dpi_error:{' '.join(text.split()).lower()}",
)
def _global_logger_payload(text: str) -> dict:
return advisory_notification(
level="error",
title="Ошибка",
content=text,
source=GLOBAL_LOGGER_SOURCE,
presentation="infobar",
queue="immediate",
duration=10000,
dedupe_key=f"global_logger:{' '.join(text.split()).lower()}",
dedupe_window_ms=GLOBAL_ERROR_DEDUPE_WINDOW_MS,
)
class NotificationDedupeTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls._app = QApplication.instance() or QApplication([])
def _make_center(self) -> WindowNotificationCenter:
return WindowNotificationCenter(
None,
startup_state=SimpleNamespace(post_init_ready=True, background_init_started=True),
runtime_actions=Mock(),
create_open_url_worker=Mock(),
create_notification_action_worker=Mock(),
show_tray_notification=Mock(),
show_page=Mock(),
is_window_visible=lambda: True,
is_window_minimized=lambda: False,
)
def test_logger_echo_of_shown_error_is_suppressed(self) -> None:
center = self._make_center()
with patch.object(center, "_present_notification") as present:
center.notify(_launch_error_payload())
center.notify(_global_logger_payload(f"[ERROR] {_LAUNCH_ERROR_TEXT}"))
self.assertEqual(present.call_count, 1)
self.assertEqual(present.call_args.args[0].get("source"), "launch.dpi_error")
def test_autofix_marker_does_not_break_text_matching(self) -> None:
center = self._make_center()
with patch.object(center, "_present_notification") as present:
center.notify(_launch_error_payload())
center.notify(
_global_logger_payload(f"[ERROR] [AUTOFIX:cleanup_driver]{_LAUNCH_ERROR_TEXT}")
)
self.assertEqual(present.call_count, 1)
def test_specialized_channel_is_not_suppressed_by_logger_echo(self) -> None:
"""Обратное подавление стоило бы уведомления с кнопкой «Исправить»."""
center = self._make_center()
with patch.object(center, "_present_notification") as present:
center.notify(_global_logger_payload(f"[ERROR] {_LAUNCH_ERROR_TEXT}"))
center.notify(_launch_error_payload())
self.assertEqual(present.call_count, 2)
def test_different_errors_from_logger_are_both_shown(self) -> None:
center = self._make_center()
with patch.object(center, "_present_notification") as present:
center.notify(_launch_error_payload())
center.notify(_global_logger_payload("[ERROR] Не удалось сохранить preset"))
self.assertEqual(present.call_count, 2)
def test_expired_window_lets_the_error_appear_again(self) -> None:
center = self._make_center()
with patch.object(center, "_present_notification") as present:
center.notify(_launch_error_payload())
# Сдвигаем историю за пределы окна дедупликации.
shift = (GLOBAL_ERROR_DEDUPE_WINDOW_MS / 1000.0) + 1.0
for registry in (center._recent_signatures, center._recent_content_signatures):
for key in list(registry):
registry[key] -= shift
center.notify(_global_logger_payload(f"[ERROR] {_LAUNCH_ERROR_TEXT}"))
self.assertEqual(present.call_count, 2)
class ServicePrefixStrippingTests(unittest.TestCase):
def test_strips_level_and_autofix_prefixes(self) -> None:
strip = WindowNotificationCenter._strip_log_level_prefix
self.assertEqual(strip("[ERROR] текст"), "текст")
self.assertEqual(strip("[ERROR] [AUTOFIX:cleanup_driver]текст"), "текст")
self.assertEqual(strip("текст"), "текст")
def test_keeps_meaningful_bracketed_text(self) -> None:
strip = WindowNotificationCenter._strip_log_level_prefix
long_bracket = "[очень длинный текст в скобках, который не является служебным префиксом] хвост"
self.assertEqual(strip(long_bracket), long_bracket)
if __name__ == "__main__":
unittest.main()