zapretgui/tests/test_check_history.py
loop-uh fceab51704
All checks were successful
Zapret source guards / validate (push) Successful in 10s
BlockCheck и подбор стратегий: не терять отчёт, не засчитывать остановленную стратегию
- отчёт проверки пишется через временный файл; сбой записи попадает в журнал
- новый запуск сбрасывает путь прошлого отчёта, чтобы обращение не взяло чужой
- Стоп в последней контрольной пробе больше не записывает стратегию как рабочую
- совет про нерабочий адрес в hosts не пропадает, когда сломан и другой адрес
- сбой открытия журнала запуска пишется в журнал, а не молчит

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:18:06 -04:00

435 lines
20 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 json
import os
import unittest
from datetime import datetime
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from diagnostics import history
from settings import schema
from settings.normalize import normalize_check_history, normalize_settings
from settings.schema import build_default_settings
WHEN = datetime(2026, 10, 7, 20, 15, 30)
def _service(label, level, *, control=False, key=""):
return {"key": key or label.lower(), "label": label, "level": level, "control": control}
def _report(*services, problems=(), scope="all") -> dict:
return {
"scope": scope,
"services": list(services),
"problems": [{"level": level, "text": text} for level, text in problems],
}
class EntryTests(unittest.TestCase):
def test_entry_keeps_summary_and_state_of_every_service(self) -> None:
report = _report(
_service("Discord", "ok"),
_service("YouTube", "fail"),
_service("Google", "ok", control=True),
problems=(("fail", "YouTube не открывается"), ("warn", "DNS подменяет ответы")),
)
entry = history.blockcheck_entry(report, log_file="C:\\logs\\run.log", when=WHEN)
self.assertEqual(entry["kind"], history.KIND_BLOCKCHECK)
self.assertEqual(entry["time"], "2026-10-07T20:15:30")
self.assertEqual(entry["title"], "Все сайты")
self.assertEqual(entry["level"], "fail")
self.assertEqual(entry["headline"], "YouTube не открывается")
self.assertEqual(entry["problems"], ["YouTube не открывается", "DNS подменяет ответы"])
# Контрольные сайты в сравнение не идут: они нужны только чтобы понять, есть ли интернет.
self.assertEqual(entry["states"], {"Discord": "ok", "YouTube": "fail"})
self.assertEqual(entry["log_file"], "C:\\logs\\run.log")
def test_clean_run_and_scope_title(self) -> None:
entry = history.blockcheck_entry(_report(_service("Discord", "ok"), scope="main"), when=WHEN)
self.assertEqual((entry["level"], entry["headline"], entry["title"]), ("ok", "Всё открывается", "Discord и YouTube"))
def test_full_check_has_its_own_title_and_is_not_compared_with_all_sites(self) -> None:
full = history.blockcheck_entry(_report(_service("YouTube", "fail"), scope="full"), when=WHEN)
earlier = [history.blockcheck_entry(_report(_service("YouTube", "ok"), scope="all"), when=WHEN)]
self.assertEqual(full["title"], "Глубокая диагностика")
self.assertIsNone(history.previous_run(earlier, full))
def test_deep_diagnostics_is_compared_with_the_full_checks_of_before_the_rename(self) -> None:
full = history.blockcheck_entry(_report(_service("YouTube", "fail"), scope="full"), when=WHEN)
old = {**history.blockcheck_entry(_report(_service("YouTube", "ok"), scope="full"), when=WHEN), "title": "Полная проверка"}
self.assertIs(history.previous_run([old], full), old)
def test_overall_level_is_the_worst_problem_level(self) -> None:
def level(*problems):
return history.blockcheck_entry(_report(problems=problems), when=WHEN)["level"]
self.assertEqual(level(("warn", "a"), ("unknown", "b")), "warn")
self.assertEqual(level(("unknown", "b")), "unknown")
self.assertEqual(level(("warn", "a"), ("fail", "c")), "fail")
class DnsServersAdapterTests(unittest.TestCase):
def test_report_becomes_plain_dictionary_for_the_engine(self) -> None:
from types import SimpleNamespace
from blockcheck import commands
from dns.server_check import Finding
report = SimpleNamespace(
findings=(
Finding(
"info",
"dead",
"Не отвечают совсем: X (1.1.1.1).",
title="Не отвечают совсем",
servers=(("X", "1.1.1.1"),),
),
Finding("warn", "dot_blocked", "DoT закрыт у: Y."),
)
)
stops: list = []
def run_server_check(*, on_progress=None, should_stop=None):
stops.append(should_stop)
return report
marker = lambda: False # noqa: E731
with (
patch("dns.commands.run_server_check", run_server_check),
patch("dns.server_check_plans.build_text_report", lambda _report: "таблица"),
):
result = commands.check_dns_servers(should_stop=marker)
self.assertEqual(result["level"], "warn")
# Находка идёт фразой и готовыми частями; если частей нет — только фразой.
self.assertEqual(
result["findings"][0],
{
"level": "info",
"text": "Не отвечают совсем: X (1.1.1.1).",
"title": "Не отвечают совсем",
"servers": [["X", "1.1.1.1"]],
"note": "",
},
)
self.assertEqual(result["findings"][1], {"level": "warn", "text": "DoT закрыт у: Y."})
self.assertEqual(result["text"], "таблица")
self.assertIs(stops[0], marker)
def test_level_is_worst_finding_or_unknown_without_findings(self) -> None:
from types import SimpleNamespace
from blockcheck import commands
from dns.server_check import Finding
def level(*levels):
report = SimpleNamespace(findings=tuple(Finding(item, "code", "x") for item in levels))
with (
patch("dns.commands.run_server_check", lambda **_k: report),
patch("dns.server_check_plans.build_text_report", lambda _report: ""),
):
return commands.check_dns_servers()["level"]
self.assertEqual(level("ok", "info"), "ok")
self.assertEqual(level("warn", "fail"), "fail")
self.assertEqual(level(), "unknown")
class ChangesTests(unittest.TestCase):
@staticmethod
def _entry(**states):
return {"kind": "blockcheck", "title": "Все сайты", "time": "2026-10-06T10:00:00", "states": states}
def test_stopped_and_recovered_services_are_named(self) -> None:
before = self._entry(Discord="ok", YouTube="warn", Telegram="fail", X="fail")
after = self._entry(Discord="fail", YouTube="fail", Telegram="ok", X="fail")
self.assertEqual(
history.describe_changes(before, after),
["перестали открываться: Discord, YouTube", "снова открываются: Telegram"],
)
def test_unknown_or_missing_state_is_not_a_change(self) -> None:
before = self._entry(Discord="ok", YouTube="unknown")
after = self._entry(Discord="unknown", YouTube="fail", Новый="fail")
self.assertEqual(history.describe_changes(before, after), [])
def test_no_previous_run_means_nothing_to_compare(self) -> None:
self.assertEqual(history.describe_changes(None, self._entry(Discord="fail")), [])
def test_previous_run_is_the_latest_of_same_kind_and_scope(self) -> None:
runs = [
{"kind": "blockcheck", "title": "Все сайты", "time": "1"},
{"kind": "blockcheck", "title": "Discord и YouTube", "time": "2"},
{"kind": "blockcheck", "title": "Все сайты", "time": "3"},
{"kind": "dns_servers", "title": "Все сайты", "time": "4"},
]
self.assertEqual(history.previous_run(runs, {"kind": "blockcheck", "title": "Все сайты"})["time"], "3")
self.assertEqual(history.previous_run(runs, {"kind": "blockcheck", "title": "Discord и YouTube"})["time"], "2")
self.assertIsNone(history.previous_run(runs, {"kind": "blockcheck", "title": "Другое"}))
def test_time_is_shown_short(self) -> None:
self.assertEqual(history.format_time("2026-10-07T20:15:30"), "07.10 20:15")
self.assertEqual(history.format_time("когда-то"), "когда-то")
class JsonReportTests(unittest.TestCase):
def test_document_names_its_format_and_keeps_russian_text(self) -> None:
report = _report(_service("Discord", "ok"), problems=(("warn", "DNS подменяет ответы"),))
entry = history.blockcheck_entry(report, when=WHEN)
text = history.report_json(report, entry, app_version="21.1.7.50")
document = json.loads(text)
self.assertEqual(document["format"], "zapretgui.blockcheck/1")
self.assertEqual(document["app_version"], "21.1.7.50")
self.assertEqual(document["time"], "2026-10-07T20:15:30")
self.assertEqual(document["report"]["problems"][0]["text"], "DNS подменяет ответы")
self.assertIn("DNS подменяет ответы", text)
class NormalizeTests(unittest.TestCase):
def test_default_settings_have_empty_history(self) -> None:
self.assertEqual(build_default_settings()["blockcheck"]["check_history"], [])
self.assertEqual(normalize_settings({})["blockcheck"]["check_history"], [])
def test_only_known_fields_survive_and_garbage_is_dropped(self) -> None:
runs = normalize_check_history(
[
{
"kind": "blockcheck",
"time": "2026-10-07T20:15:30",
"title": "Все сайты",
"level": "катастрофа",
"headline": "x" * 1000,
"problems": ["a", "", 5, "b"] + ["лишнее"] * 30,
"states": {"Discord": "ok", "YouTube": "сломан", "": "ok"},
"log_file": "C:\\logs\\run.log",
"password": "секрет",
},
{"kind": "", "time": "2026-10-07T20:15:30"},
{"kind": "blockcheck"},
"мусор",
]
)
self.assertEqual(len(runs), 1)
run = runs[0]
self.assertEqual(run["level"], "unknown")
self.assertEqual(len(run["headline"]), schema.CHECK_HISTORY_TEXT_LIMIT)
self.assertEqual(run["problems"][:3], ["a", "5", "b"])
self.assertEqual(len(run["problems"]), schema.CHECK_HISTORY_PROBLEMS_LIMIT)
self.assertEqual(run["states"], {"Discord": "ok"})
self.assertNotIn("password", run)
self.assertEqual(normalize_check_history("не список"), [])
def test_only_the_latest_runs_are_kept(self) -> None:
many = [{"kind": "blockcheck", "time": f"t{index}"} for index in range(schema.CHECK_HISTORY_LIMIT + 7)]
runs = normalize_check_history(many)
self.assertEqual(len(runs), schema.CHECK_HISTORY_LIMIT)
self.assertEqual(runs[0]["time"], "t7")
self.assertEqual(runs[-1]["time"], f"t{schema.CHECK_HISTORY_LIMIT + 6}")
class StoreAndRememberTests(unittest.TestCase):
def setUp(self) -> None:
self._folder = TemporaryDirectory()
self.addCleanup(self._folder.cleanup)
self.root = Path(self._folder.name)
patcher = patch("settings.store.MAIN_DIRECTORY", str(self.root))
patcher.start()
self.addCleanup(patcher.stop)
from settings import store
self.store = store
self.addCleanup(store.close_settings_database)
store.close_settings_database()
def test_history_is_stored_next_to_other_blockcheck_settings(self) -> None:
self.store.set_blockcheck_settings({"user_domains": ["example.com"]})
runs = self.store.add_check_history_run({"kind": "blockcheck", "time": "t1", "level": "ok"})
self.store.add_check_history_run({"kind": "blockcheck", "time": "t2", "level": "fail"})
self.assertEqual([run["time"] for run in runs], ["t1"])
self.assertEqual([run["time"] for run in self.store.get_check_history()], ["t1", "t2"])
# Запись истории не затёрла соседнюю настройку.
self.assertEqual(self.store.get_blockcheck_settings()["user_domains"], ["example.com"])
def test_remembering_a_run_reports_what_changed_and_writes_json(self) -> None:
from blockcheck import commands
log_file = str(self.root / "blockcheck_run_1.log")
first = commands.remember_blockcheck_run(_report(_service("Discord", "ok"), _service("YouTube", "ok")), log_file)
self.assertEqual((first["changes"], first["previous_time"]), ([], ""))
second_report = _report(
_service("Discord", "ok"), _service("YouTube", "fail"), problems=(("fail", "YouTube не открывается"),)
)
second = commands.remember_blockcheck_run(second_report, log_file)
self.assertEqual(second["changes"], ["перестали открываться: YouTube"])
self.assertEqual([run["level"] for run in second["history"]], ["ok", "fail"])
self.assertTrue(second["previous_time"])
self.assertEqual(len(self.store.get_check_history()), 2)
document = json.loads(Path(second["json_file"]).read_text("utf-8"))
self.assertEqual(Path(second["json_file"]).name, "blockcheck_run_1.json")
self.assertEqual(document["format"], history.REPORT_FORMAT)
self.assertEqual(document["level"], "fail")
# Отчёт, открытый из «Прошлых проверок», показывает ту же полосу изменений.
self.assertEqual(document["report"]["changes"], ["перестали открываться: YouTube"])
self.assertEqual(document["report"]["previous_time"], second["previous_time"])
def test_run_of_another_scope_is_not_compared(self) -> None:
from blockcheck import commands
commands.remember_blockcheck_run(_report(_service("YouTube", "ok"), scope="all"), None)
note = commands.remember_blockcheck_run(_report(_service("YouTube", "fail"), scope="main"), None)
self.assertEqual((note["changes"], note["json_file"]), ([], ""))
class WorkerTests(unittest.TestCase):
def _worker(self, remember):
from blockcheck.worker import BlockcheckWorker
return BlockcheckWorker(
report_path=lambda *_a: "",
save_report=lambda *_a: "",
remember_run=remember,
)
def test_changes_are_added_to_report_and_printed(self) -> None:
def remember(report, _log):
# Как настоящая запись: изменения ложатся в отчёт до сохранения файла.
report["changes"] = ["перестали открываться: YouTube"]
return {
"changes": ["перестали открываться: YouTube"],
"previous_time": "2026-10-06T10:00:00",
"json_file": "x.json",
"lines": ["🕘 С прошлой проверки (06.10 10:00) перестали открываться: YouTube."],
}
worker = self._worker(remember)
lines: list[str] = []
worker.log_message.connect(lines.append)
report = {"scope": "all"}
worker._remember(report)
self.assertEqual(report["changes"], ["перестали открываться: YouTube"])
self.assertEqual(report["json_file"], "x.json")
self.assertEqual(report["history"], [])
self.assertIn("🕘 С прошлой проверки (06.10 10:00) перестали открываться: YouTube.", lines)
def test_failed_history_write_does_not_cost_the_result(self) -> None:
def broken(_report, _log):
raise OSError("диск переполнен")
report = {"scope": "all", "services": []}
self._worker(broken)._remember(report)
self.assertNotIn("changes", report)
def test_no_changes_means_no_extra_lines(self) -> None:
worker = self._worker(lambda report, log: {"changes": [], "previous_time": "", "json_file": ""})
lines: list[str] = []
worker.log_message.connect(lines.append)
worker._remember({"scope": "all"})
self.assertEqual(lines, [])
if __name__ == "__main__":
unittest.main()
class SingleReportFileTests(unittest.TestCase):
"""У проверки один файл — .json: в нём и данные, и текст отчёта, и прерванный прогон тоже."""
def _run(self, engine_result, *, raises: Exception | None = None):
from unittest.mock import patch
from blockcheck.worker import BlockcheckWorker
saved: list[tuple[dict, str]] = []
remembered: list[tuple[dict, str]] = []
def remember(report, path):
remembered.append((dict(report), path))
return {"changes": [], "previous_time": "", "json_file": path}
def fake_engine(scope, *, emit, partial, **_kwargs):
emit("строка первая")
partial({"scope": scope, "services": ["discord"], "partial": True})
emit("строка вторая")
if raises is not None:
raise raises
return engine_result
worker = BlockcheckWorker(
scope="full",
report_path=lambda scope: f"run_{scope}.json",
save_report=lambda report, path: saved.append((report, path)) or path,
remember_run=remember,
)
announced: list = []
worker.run_log_started.connect(announced.append)
with patch("diagnostics.engine.run_blockcheck", fake_engine):
worker.run()
return saved, remembered, announced
def test_finished_run_keeps_its_text_inside_the_report(self) -> None:
saved, remembered, announced = self._run({"scope": "full", "services": []})
[(report, path)] = remembered
self.assertEqual(path, "run_full.json")
self.assertEqual(report["text"], ["строка первая", "строка вторая"])
self.assertEqual((saved, announced), ([], ["run_full.json"]))
def test_stopped_and_crashed_runs_are_saved_with_what_was_learned(self) -> None:
for kwargs, mark in (({"engine_result": {"stopped": True}}, "stopped"), ({"engine_result": None, "raises": RuntimeError("сеть")}, "failed")):
saved, remembered, announced = self._run(**kwargs)
[(report, path)] = saved
self.assertTrue(report[mark])
self.assertEqual(report["services"], ["discord"])
self.assertEqual(report["text"][:2], ["строка первая", "строка вторая"])
self.assertEqual((remembered, announced), ([], [path]))
def test_failed_write_keeps_the_old_report_whole_and_leaves_no_temp_file(self) -> None:
import tempfile
from blockcheck import commands
with tempfile.TemporaryDirectory() as folder:
path = str(Path(folder) / "blockcheck_run_1_full.json")
self.assertEqual(commands.save_blockcheck_report({"scope": "full", "text": ["старый"]}, path), path)
with patch("blockcheck.commands.os.replace", side_effect=OSError("disk full")):
self.assertEqual(commands.save_blockcheck_report({"scope": "full", "text": ["новый"]}, path), "")
# Оборванная запись не портит отчёт, лежавший на этом месте, и не оставляет мусора.
self.assertEqual(commands.load_past_blockcheck_report(path)["text"], ["старый"])
self.assertEqual(sorted(item.name for item in Path(folder).iterdir()), ["blockcheck_run_1_full.json"])
def test_saved_file_is_read_back_as_a_past_report(self) -> None:
import tempfile
from blockcheck import commands
with tempfile.TemporaryDirectory() as folder:
path = str(Path(folder) / "blockcheck_run_1_full.json")
self.assertEqual(commands.save_blockcheck_report({"scope": "full", "text": ["раз", "два"]}, path), path)
self.assertEqual(commands.load_past_blockcheck_report(path)["text"], ["раз", "два"])
self.assertEqual(sorted(item.name for item in Path(folder).iterdir()), ["blockcheck_run_1_full.json"])
self.assertEqual(commands.save_blockcheck_report({}, None), "")