zapretgui/tests/test_dns_worker_architecture.py
loop-uh 50f9f695ba
All checks were successful
Zapret source guards / validate (push) Successful in 10s
Свой DNS: отдельная страница вместо окна, сервер можно добавить одной строкой DoH — IP-адреса программа находит и проверяет сама; «Автоматически» стала плиткой, кнопка «Сбросить кэш DNS» больше не прокручивает страницу
Страница «Свой DNS» (dns.ui.custom_server_page) открывается из «Настройки DNS»
и показывает путь хлебными крошками. Три поля вместо пяти: адрес DoH, IP-адреса
одной строкой (IPv4 и IPv6 вместе) и название, которое можно не писать.

Если задан только адрес DoH, программа узнаёт адреса сервера у эталонных
серверов по DoH и у DNS системы, а затем проверяет каждый настоящим запросом DoH
с проверкой сертификата (dns.doh_lookup). На Windows без DoH адрес должен
отвечать и на обычный DNS, иначе сервер не сохраняется. Шаблон DoH своего
сервера попадает в настройки адаптера так же, как у серверов программы.

Правила своих серверов вынесены из окна в dns.custom_servers; список читается и
пишется в фоне одной транзакцией (settings.store.update_custom_dns_servers) и
приходит на страницу вместе с её состоянием. Страница DNS обновляет состояние
при каждом открытии.

Кнопка «Вернуть автоматически» заменена первой плиткой сетки и применяется сразу,
без вопроса. Кнопка сброса кэша не выключается на время работы: выключенная
теряла фокус, он уходил на сетку плиток, и страница прокручивалась к выбранному
серверу. Сетка прокручивает к плитке только при фокусе с клавиатуры.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:47:55 +03:00

432 lines
19 KiB
Python

from __future__ import annotations
import importlib.util
import inspect
import unittest
from types import SimpleNamespace
from unittest.mock import ANY, Mock, patch
from app.feature_facades.dns import build_dns_feature
from dns import commands as dns_commands
from dns import dns_check_plans, dns_check_worker, page_workers
from dns.ui.dns_check_page import DNSCheckPage
class DnsWorkerArchitectureTests(unittest.TestCase):
def test_network_action_workers_receive_feature_action_callables(self) -> None:
feature_source = inspect.getsource(build_dns_feature)
worker_source = "\n".join(
(
inspect.getsource(page_workers.DnsFlushCacheWorker),
inspect.getsource(page_workers.DnsIspWarningWorker),
inspect.getsource(page_workers.DnsApplyWorker),
inspect.getsource(page_workers.DnsLatencyWorker),
)
)
self.assertNotIn("dns_feature=feature", feature_source)
self.assertNotIn("self._dns =", worker_source)
self.assertNotIn("self._dns.", worker_source)
self.assertNotIn("import dns.public", worker_source)
self.assertNotIn("dns_public.", worker_source)
self.assertIn("from dns import page_plans", worker_source)
for expected in (
"flush_dns_cache=flush_dns_cache",
"apply_dns=apply_dns",
"reset_to_auto=reset_to_auto",
"load_state=load_state",
"measure_dns_latency=measure_dns_latency",
"is_isp_dns_warning_shown=is_isp_dns_warning_shown",
"mark_isp_dns_warning_shown=mark_isp_dns_warning_shown",
):
self.assertIn(expected, feature_source)
for expected in (
"_flush_dns_cache",
"_apply_dns",
"_reset_to_auto",
"_load_state",
"_measure_dns_latency",
"_is_isp_dns_warning_shown",
"_mark_isp_dns_warning_shown",
):
self.assertIn(expected, worker_source)
for removed in ("create_force_dns_action_worker", "create_connectivity_test_worker", "apply_custom_dns"):
self.assertNotIn(removed, feature_source)
def test_custom_server_worker_runs_a_feature_action_and_can_be_stopped(self) -> None:
from dns.state import CustomServerResult
feature = build_dns_feature()
server = {"id": "a", "name": "Мой", "ipv4": [], "ipv6": [], "doh": "https://dns.example.com/dns-query"}
result = CustomServerResult(success=True)
seen = []
with patch("dns.public.save_custom_server", side_effect=lambda record, cancel: seen.append((record, cancel)) or result), patch(
"dns.public.delete_custom_server", return_value=result
) as delete, patch("dns.public.duplicate_custom_server", return_value=result) as duplicate:
save = feature.create_custom_server_worker(1, action="save", server=server)
emitted = []
save.completed.connect(lambda request_id, value: emitted.append((request_id, value)))
save.run()
feature.create_custom_server_worker(2, action="delete", server_id="a").run()
feature.create_custom_server_worker(3, action="duplicate", server_id="a").run()
self.assertEqual(emitted, [(1, result)])
record, cancel = seen[0]
self.assertEqual(record, server)
# Сохранение ищет адреса в сети: stop() обрывает незаконченные запросы.
self.assertFalse(cancel.cancelled)
save.stop()
self.assertTrue(cancel.cancelled)
delete.assert_called_once_with("a")
duplicate.assert_called_once_with("a")
with self.assertRaises(ValueError):
feature.create_custom_server_worker(4, action="rename")
def test_custom_server_worker_reports_a_crash_as_failure(self) -> None:
worker = page_workers.DnsCustomServerWorker(9, action=Mock(side_effect=RuntimeError("база занята")))
failed = []
worker.failed.connect(lambda request_id, error: failed.append((request_id, error)))
with patch.object(page_workers, "log"):
worker.run()
self.assertEqual(failed, [(9, "база занята")])
def test_dns_page_does_not_touch_settings_itself(self) -> None:
# Свои DNS читает и пишет DNS-слой в фоне; страницы получают готовый список.
from dns.ui import custom_server_page, page
for module in (page, custom_server_page):
source = inspect.getsource(module)
self.assertNotIn("settings.store", source)
self.assertNotIn("MessageBoxBase", source)
self.assertIsNone(importlib.util.find_spec("dns.ui.custom_dns_dialog"))
self.assertIsNone(importlib.util.find_spec("dns.custom_providers"))
def test_dns_check_workers_receive_feature_action_callables(self) -> None:
feature_source = inspect.getsource(build_dns_feature)
worker_source = "\n".join(
(
inspect.getsource(dns_check_worker.DNSCheckWorker),
inspect.getsource(dns_check_worker.DNSCheckSaveWorker),
)
)
for expected in (
"run_dns_poisoning_check=run_dns_poisoning_check",
"save_dns_check_results=save_dns_check_results",
):
self.assertIn(expected, feature_source)
for expected in (
"_run_dns_poisoning_check",
"_save_dns_check_results",
):
self.assertIn(expected, worker_source)
self.assertNotIn("from dns import commands", worker_source)
self.assertNotIn("from dns.commands import", worker_source)
self.assertNotIn("dns_commands.", worker_source)
def test_dns_check_save_action_lives_in_commands_not_plans(self) -> None:
plans_source = inspect.getsource(dns_check_plans)
commands_source = inspect.getsource(dns_commands.save_dns_check_results)
self.assertNotIn("def save_results_text", plans_source)
self.assertNotIn("open(", plans_source)
self.assertNotIn("os.startfile", plans_source)
self.assertIn("open(", commands_source)
self.assertIn("os.startfile", commands_source)
def test_dns_check_page_uses_one_shot_runtime_for_check_and_save(self) -> None:
page_source = inspect.getsource(DNSCheckPage)
start_source = inspect.getsource(DNSCheckPage.start_check)
save_source = inspect.getsource(DNSCheckPage._start_save_results_worker)
cleanup_source = inspect.getsource(DNSCheckPage.cleanup)
self.assertIn("OneShotWorkerRuntime", page_source)
for name in (
"_check_runtime",
"_save_runtime",
):
self.assertIn(name, page_source)
self.assertIn(f"{name}.stop", cleanup_source)
self.assertIn("start_qobject_worker", start_source)
self.assertIn("start_qthread_worker", save_source)
for source in (start_source, save_source):
self.assertNotIn("worker.start()", source)
self.assertNotIn("self.thread = QThread", start_source)
def test_dns_full_check_uses_shared_latest_worker_state(self) -> None:
from ui.latest_value_worker_state import LatestValueWorkerState
page = DNSCheckPage.__new__(DNSCheckPage)
page._check_runtime = SimpleNamespace(is_running=Mock(return_value=False))
init_source = inspect.getsource(DNSCheckPage.__init__)
start_source = inspect.getsource(DNSCheckPage.start_check)
schedule_source = inspect.getsource(DNSCheckPage._schedule_full_dns_check_start)
cleanup_source = inspect.getsource(DNSCheckPage.cleanup)
self.assertIsInstance(DNSCheckPage._check_state_obj(page), LatestValueWorkerState)
self.assertNotIn("self._check_pending = False", init_source)
self.assertNotIn("self._check_start_scheduled = False", init_source)
self.assertIn("_check_state_obj()", start_source)
self.assertIn("_check_state_obj()", schedule_source)
self.assertIn("_check_state_obj().reset()", cleanup_source)
def test_dns_check_save_uses_shared_latest_worker_state(self) -> None:
from ui.latest_value_worker_state import LatestValueWorkerState
page = DNSCheckPage.__new__(DNSCheckPage)
page._save_runtime = SimpleNamespace(is_running=Mock(return_value=False))
init_source = inspect.getsource(DNSCheckPage.__init__)
start_source = inspect.getsource(DNSCheckPage._start_save_results_worker)
schedule_source = inspect.getsource(DNSCheckPage._schedule_save_results_worker_start)
cleanup_source = inspect.getsource(DNSCheckPage.cleanup)
self.assertIsInstance(DNSCheckPage._save_results_state_obj(page), LatestValueWorkerState)
self.assertNotIn("self._save_results_pending: dict[str, str] | None = None", init_source)
self.assertNotIn("self._save_results_start_scheduled = False", init_source)
self.assertIn("_save_results_state_obj()", start_source)
self.assertIn("_save_results_state_obj()", schedule_source)
self.assertIn("_save_results_state_obj().reset()", cleanup_source)
def test_dns_check_save_queues_while_worker_runs(self) -> None:
page = DNSCheckPage.__new__(DNSCheckPage)
page._save_runtime = SimpleNamespace(is_running=Mock(return_value=True), start_qthread_worker=Mock())
page._save_results_pending = None
DNSCheckPage._start_save_results_worker(page, file_path="first.txt", plain_text="latest")
page._save_runtime.start_qthread_worker.assert_not_called()
self.assertEqual(page._save_results_pending, {"file_path": "first.txt", "plain_text": "latest"})
def test_dns_check_save_while_worker_runs_defers_result_text_read(self) -> None:
page = DNSCheckPage.__new__(DNSCheckPage)
page._save_runtime = SimpleNamespace(is_running=Mock(return_value=True), start_qthread_worker=Mock())
page._save_results_pending = None
page._save_results_start_scheduled = False
page._results_plain_text_cache = "latest dns report"
DNSCheckPage._start_save_results_worker(page, file_path="first.txt", plain_text=None)
page._save_runtime.start_qthread_worker.assert_not_called()
self.assertEqual(page._save_results_pending, {"file_path": "first.txt", "plain_text": None})
def test_dns_check_save_uses_cached_result_text(self) -> None:
page = DNSCheckPage.__new__(DNSCheckPage)
page._save_runtime = SimpleNamespace(is_running=Mock(return_value=False), start_qthread_worker=Mock())
page._save_results_pending = None
page._save_results_start_scheduled = False
page._results_plain_text_cache = "cached dns report"
page.create_dns_check_save_worker = Mock(return_value="worker")
DNSCheckPage._start_save_results_worker(page, file_path="first.txt", plain_text=None)
page._save_runtime.start_qthread_worker.assert_called_once()
worker_factory = page._save_runtime.start_qthread_worker.call_args.kwargs["worker_factory"]
self.assertEqual(worker_factory(7), "worker")
page.create_dns_check_save_worker.assert_called_once_with(
7,
file_path="first.txt",
plain_text="cached dns report",
)
def test_dns_check_pending_save_hides_previous_save_result(self) -> None:
import dns.ui.dns_check_page as dns_check_page
page = DNSCheckPage.__new__(DNSCheckPage)
page._cleanup_in_progress = False
page._save_runtime = SimpleNamespace(is_current=Mock(return_value=True))
page._save_results_pending = {"file_path": "second.txt", "plain_text": "newer"}
page.window = Mock(return_value=object())
plan = SimpleNamespace(success=True, title="saved", content="old result")
success = Mock()
error = Mock()
with patch.object(dns_check_page, "InfoBar", SimpleNamespace(success=success, error=error)):
DNSCheckPage._on_save_results_finished(page, 1, plan)
success.assert_not_called()
error.assert_not_called()
def test_dns_check_pending_save_restarts_after_event_loop_turn(self) -> None:
import dns.ui.dns_check_page as dns_check_page
page = DNSCheckPage.__new__(DNSCheckPage)
page._cleanup_in_progress = False
page._save_results_pending = {"file_path": "first.txt", "plain_text": "latest"}
page._start_save_results_worker = Mock()
single_shot = Mock(side_effect=lambda _delay, _callback: None)
with patch.object(dns_check_page, "QTimer", SimpleNamespace(singleShot=single_shot), create=True):
DNSCheckPage._on_save_results_worker_finished(page, object())
single_shot.assert_called_once()
self.assertEqual(single_shot.call_args.args[0], 0)
page._start_save_results_worker.assert_not_called()
single_shot.call_args.args[1]()
page._start_save_results_worker.assert_called_once_with(
file_path="first.txt",
plain_text="latest",
)
def test_stale_dns_check_save_finish_does_not_restart_pending_save(self) -> None:
import dns.ui.dns_check_page as dns_check_page
page = DNSCheckPage.__new__(DNSCheckPage)
page._cleanup_in_progress = False
page._save_runtime = SimpleNamespace(worker=object())
page._save_results_pending = {"file_path": "first.txt", "plain_text": "latest"}
page._save_results_start_scheduled = False
page._start_save_results_worker = Mock()
single_shot = Mock()
with patch.object(dns_check_page, "QTimer", SimpleNamespace(singleShot=single_shot), create=True):
DNSCheckPage._on_save_results_worker_finished(page, object())
single_shot.assert_not_called()
page._start_save_results_worker.assert_not_called()
self.assertEqual(page._save_results_pending, {"file_path": "first.txt", "plain_text": "latest"})
def test_dns_check_scheduled_save_uses_latest_pending_payload(self) -> None:
page = DNSCheckPage.__new__(DNSCheckPage)
page._cleanup_in_progress = False
page._save_results_pending = {"file_path": "second.txt", "plain_text": "newer"}
page._save_results_start_scheduled = True
page._start_save_results_worker = Mock()
DNSCheckPage._run_scheduled_save_results_worker_start(page)
page._start_save_results_worker.assert_called_once_with(
file_path="second.txt",
plain_text="newer",
)
def test_stale_dns_full_check_finish_does_not_restart_pending_check(self) -> None:
import dns.ui.dns_check_page as dns_check_page
page = DNSCheckPage.__new__(DNSCheckPage)
page._cleanup_in_progress = False
page._check_runtime = SimpleNamespace(request_id=2)
page._check_pending = True
page._check_start_scheduled = False
page.start_check = Mock()
single_shot = Mock()
with patch.object(dns_check_page, "QTimer", SimpleNamespace(singleShot=single_shot), create=True):
DNSCheckPage._on_check_worker_finished(page, 1, object())
single_shot.assert_not_called()
page.start_check.assert_not_called()
self.assertTrue(page._check_pending)
def test_dns_check_cleanup_does_not_block_gui_for_one_shot_workers(self) -> None:
page = DNSCheckPage.__new__(DNSCheckPage)
page._cleanup_in_progress = False
page._check_runtime = Mock()
page._save_runtime = Mock()
page._check_pending = True
page._check_start_scheduled = True
page._save_results_pending = {"file_path": "dns.txt", "plain_text": "old"}
page._save_results_start_scheduled = True
DNSCheckPage.cleanup(page)
self.assertTrue(page._cleanup_in_progress)
self.assertFalse(page._check_pending)
self.assertFalse(page._check_start_scheduled)
self.assertIsNone(page._save_results_pending)
self.assertFalse(page._save_results_start_scheduled)
page._check_runtime.stop.assert_called_once_with(
blocking=False,
log_fn=ANY,
warning_prefix="DNS check worker",
)
page._check_runtime.cancel.assert_called_once()
page._save_runtime.stop.assert_called_once_with(
blocking=False,
log_fn=ANY,
warning_prefix="DNS check save worker",
)
page._save_runtime.cancel.assert_called_once()
def test_dns_full_check_queues_while_worker_runs(self) -> None:
page = DNSCheckPage.__new__(DNSCheckPage)
page._check_runtime = SimpleNamespace(is_running=Mock(return_value=True))
page._check_pending = False
page._set_log_available = Mock()
page._apply_interaction_state = Mock()
page._set_status = Mock()
DNSCheckPage.start_check(page)
self.assertTrue(page._check_pending)
page._set_log_available.assert_not_called()
page._apply_interaction_state.assert_not_called()
page._set_status.assert_not_called()
def test_dns_pending_full_check_restarts_after_event_loop_turn(self) -> None:
import dns.ui.dns_check_page as dns_check_page
page = DNSCheckPage.__new__(DNSCheckPage)
page._cleanup_in_progress = False
page._check_pending = True
page._check_start_scheduled = False
page.start_check = Mock()
single_shot = Mock(side_effect=lambda _delay, _callback: None)
with patch.object(dns_check_page, "QTimer", SimpleNamespace(singleShot=single_shot), create=True):
DNSCheckPage._on_check_worker_finished(page, 1, object())
single_shot.assert_called_once()
self.assertEqual(single_shot.call_args.args[0], 0)
page.start_check.assert_not_called()
single_shot.call_args.args[1]()
self.assertFalse(page._check_pending)
page.start_check.assert_called_once_with()
def test_dns_layer_has_no_force_or_startup_dns(self) -> None:
for module in ("dns.dns_core", "dns.dns_force", "dns.dns_worker", "main.post_startup_dns"):
with self.subTest(module=module):
self.assertIsNone(importlib.util.find_spec(module))
def test_dns_feature_does_not_expose_heavy_direct_commands(self) -> None:
feature = build_dns_feature()
for attr_name in (
"load_page_data",
"load_state",
"apply_dns",
"reset_to_auto",
"refresh_dns_info",
"apply_auto_dns",
"apply_provider_dns",
"apply_custom_dns",
"get_force_dns_status",
"is_isp_dns_warning_shown",
"mark_isp_dns_warning_shown",
"enable_force_dns",
"disable_force_dns",
"flush_dns_cache",
"run_connectivity_test",
"run_dns_poisoning_check",
"save_dns_check_results",
"run_quick_dns_check",
):
self.assertFalse(hasattr(feature, attr_name), attr_name)
if __name__ == "__main__":
unittest.main()