zapretgui/tests/test_blockcheck_report_levels.py
loop-uh 606ba90825
All checks were successful
Zapret source guards / validate (push) Successful in 10s
BlockCheck: логика итога вынесена из файла виджетов в модуль без Qt (summary_model)
Группировка проблем, привязка строки итога к карточке (site/section), имя и
пояснение сайта, предупреждения о программах обхода и строки «Прошлых
проверок» переехали без изменений из check_results.py (виджеты, 1750 строк) в
blockcheck/ui/summary_model.py. Виджеты берут их готовыми, тесты чистой логики
импортируют модель без окна; тест слоёв следит, что модель не тянет PyQt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:19:38 +03:00

394 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.

"""Отчёт BlockCheck вглубь: страница одного сервера, выводы DNS по частям, сводка серверов, общие действия группы."""
import os
import unittest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtCore import QPoint, Qt
from PyQt6.QtTest import QTest
from PyQt6.QtWidgets import QApplication, QScrollArea
from blockcheck.ui.check_results import BlockcheckSummaryPanel
from blockcheck.ui.finding_parts import FindingCard, split_finding
from blockcheck.ui.result_cards import ResultDetailView, finding_detail_card, server_card, wants_findings
from blockcheck.ui.result_cards_model import Card, FindingParts, Line, Section, build_cards
from blockcheck.ui.server_matrix import cell_state, count_state, parse_server_table, summarize_servers
TABLE = "\n".join(
[
"ПРОВЕРКА DNS-СЕРВЕРОВ",
"",
"Итог:",
" ✗ что-то",
"",
"Сервер Адрес Пинг UDP 53 TCP 53 DoH 443",
"Cloudflare 1.1.1.1 4 мс 16 мс 21 мс 487 мс",
"Cloudflare 2606:4700:4700::1111 4 мс 41 мс · 2 из 3 37 мс обрыв",
"Quad9 9.9.9.9 36 мс молчит молчит молчит",
"",
"Подробности:",
"Cloudflare (1.1.1.1)",
]
)
AKAMAI = Section(
"Akamai — обрыв у 1 из 2",
(
Line("ok", "SE.AKM-01 · media.miele.com", "получено 32 КБ без обрыва · 5.1 с"),
Line("fail", "US.AKM-02 · www.roxio.com", "загрузка оборвалась на 16 КБ · 4.8 с"),
),
)
HOSTINGS = Card(key="hostings", icon="fa5s.server", title="Зарубежные хостинги", level="fail", status="Обрыв у 1 из 2", sections=(AKAMAI,))
class ServerTableTests(unittest.TestCase):
def test_table_is_read_by_the_header_columns(self) -> None:
columns, rows = parse_server_table(TABLE)
self.assertEqual(columns, ["Пинг", "UDP 53", "TCP 53", "DoH 443"])
self.assertEqual([(row.server, row.address) for row in rows], [("Cloudflare", "1.1.1.1"), ("Cloudflare", "2606:4700:4700::1111"), ("Quad9", "9.9.9.9")])
self.assertEqual(rows[1].cells, ("4 мс", "41 мс · 2 из 3", "37 мс", "обрыв"))
# Текста без таблицы сводка не касается.
self.assertEqual(parse_server_table("узел 1 10.0.0.1"), ([], []))
def test_cells_are_counted_per_service(self) -> None:
self.assertEqual([cell_state(text) for text in ("16 мс", "41 мс · 2 из 3", "молчит", "", "—")], ["ok", "warn", "fail", "none", "none"])
columns, rows = parse_server_table(TABLE)
cloudflare, quad9 = summarize_servers(rows, len(columns))
self.assertEqual((cloudflare.name, len(cloudflare.rows)), ("Cloudflare", 2))
# UDP: один адрес отвечает, второй с потерями; DoH: один работает, один нет.
self.assertEqual(cloudflare.counts, ((2, 0, 0), (1, 1, 0), (2, 0, 0), (1, 0, 1)))
self.assertEqual([count_state(*count) for count in cloudflare.counts], ["ok", "warn", "ok", "warn"])
self.assertEqual([count_state(*count) for count in quad9.counts], ["ok", "fail", "fail", "fail"])
class FindingTests(unittest.TestCase):
def test_finding_is_split_into_title_and_details(self) -> None:
self.assertEqual(split_finding("Отвечают через раз: OpenDNS (1.2.3.4) и ещё 3. Так бывает"), ("Отвечают через раз", "OpenDNS (1.2.3.4) и ещё 3. Так бывает."))
self.assertEqual(split_finding("Потеряли по одному запросу. Похоже на потери в сети"), ("Потеряли по одному запросу", "Похоже на потери в сети."))
self.assertEqual(split_finding("Всё хорошо"), ("Всё хорошо", ""))
def test_only_dns_conclusions_are_shown_in_parts(self) -> None:
conclusions = Section("Выводы", (Line("fail", "Обычные ответы подменяются у серверов: Cloudflare (1.1.1.1)"),))
self.assertTrue(wants_findings(conclusions, Card(key="dns_servers", icon="", title="", level="fail", status="")))
self.assertFalse(wants_findings(conclusions, Card(key="system", icon="", title="", level="fail", status="")))
class ReportOnScreenTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def _view(self) -> ResultDetailView:
area = QScrollArea()
self.addCleanup(area.deleteLater)
area.setWidgetResizable(True)
view = ResultDetailView()
area.setWidget(view)
area.resize(1000, 400)
area.show()
self.app.processEvents()
return view
def test_dns_report_shows_chips_and_a_service_matrix_instead_of_raw_text(self) -> None:
card = Card(
key="dns_servers",
icon="fa5s.network-wired",
title="DNS-серверы",
level="fail",
status="Есть проблемы",
sections=(
Section("Выводы", (Line("warn", "Отвечают через раз: Quad9 (9.9.9.9), Quad9 (149.112.112.112) и ещё 3. Так бывает."),)),
Section("Все серверы и способы связи", text=TABLE),
),
)
view = self._view()
view.show_card(card)
self.app.processEvents()
conclusions, table = view.blocks
# Вывод — карточка: заголовок и серверы метками; пояснение целиком — в подсказке.
[card] = conclusions.rows
self.assertEqual(conclusions.findings_flow.cards(), [card])
self.assertEqual(card.title, "Отвечают через раз")
self.assertEqual([chip.text for chip in card.server_chips], ["Quad9 ×2"])
self.assertEqual(card.more_label.text(), "и ещё 3")
self.assertIn("Так бывает.", card.hint_text)
# Отчёт прошлой проверки хранит только фразу: на странице находки перечень остаётся обрезанным.
past = finding_detail_card(conclusions.section.lines[0].name, "warn")
self.assertEqual([line.name for line in past.sections[1].lines], ["Quad9", "и ещё 3"])
# Таблица — сводкой по сервисам; сырой текст открывается кнопкой, а не лежит на странице.
self.assertEqual([service.name for service in table.matrix.services()], ["Cloudflare", "Quad9"])
self.assertIsNone(table.editor)
self.assertEqual(table.open_text_button.text(), "Подробный текст")
# Строка сводки открывает страницу сервиса: по разделу на адрес, по строке на способ связи.
table.matrix.opened.emit(0)
self.assertEqual((view.card().title, view.card().status), ("Cloudflare", "Отвечает 2 из 2 адресов"))
self.assertEqual([section.title for section in view.card().sections], ["1.1.1.1", "2606:4700:4700::1111"])
self.assertEqual([(line.name, line.state) for line in view.card().sections[1].lines][-1], ("DoH 443", "fail"))
self.assertTrue(view.go_back())
# Карточка вывода открывает страницу находки с полным текстом.
QTest.mouseClick(view.blocks[0].rows[0], Qt.MouseButton.LeftButton)
self.assertEqual(view.card().title, "Отвечают через раз")
self.assertEqual([section.title for section in view.card().sections], ["Что найдено", "Серверы", "Что это значит"])
self.assertTrue(view.go_back())
self.assertIn("2606:4700:4700::1111: Пинг — 4 мс, UDP 53 — 41 мс · 2 из 3", table.matrix.hint(0))
def test_ready_parts_give_the_whole_server_list_without_cutting_the_phrase(self) -> None:
servers = [["Quad9", "9.9.9.9"], ["Quad9", "149.112.112.112"], ["Cloudflare", "1.1.1.1"], ["AdGuard", "94.140.14.14"]]
servers += [["Google DNS", "8.8.8.8"], ["Google DNS", "8.8.4.4"]]
report = {
"dns_servers": {
"level": "warn",
"findings": [
{
"level": "warn",
"text": "Отвечают через раз: Quad9 (9.9.9.9), Quad9 (149.112.112.112), Cloudflare (1.1.1.1), AdGuard (94.140.14.14) и ещё 2. Так бывает.",
"title": "Отвечают через раз",
"servers": servers,
"note": "Так бывает.",
},
# Вторая находка — как в отчёте прошлой проверки: только фраза.
{"level": "info", "text": "Не отвечают совсем: Xbox DNS (111.88.96.50) и ещё 5."},
],
"text": "",
}
}
[card] = [item for item in build_cards(report) if item.key == "dns_servers"]
ready, past = card.sections[0].lines
self.assertEqual(ready.parts, FindingParts("Отвечают через раз", tuple((name, address) for name, address in servers), "Так бывает."))
self.assertIsNone(past.parts)
# Фраза целиком остаётся как была — она идёт в текст для копирования.
self.assertTrue(ready.name.endswith("и ещё 2. Так бывает."))
view = self._view()
view.show_card(card)
self.app.processEvents()
shown, old = view.blocks[0].rows
self.assertEqual(shown.title, "Отвечают через раз")
# Метки — по сервисам со счётчиком; «и ещё» считает не поместившиеся сервисы, а не обрезку фразы.
self.assertEqual([chip.text for chip in shown.server_chips], ["Quad9 ×2", "Cloudflare", "AdGuard"])
self.assertEqual(shown.more_label.text(), "и ещё 1")
self.assertEqual(
shown.hint_text,
"Отвечают через раз\nQuad9: 9.9.9.9, 149.112.112.112\nCloudflare: 1.1.1.1\nAdGuard: 94.140.14.14\n"
"Google DNS: 8.8.8.8, 8.8.4.4\nТак бывает.",
)
# Старая находка показана как раньше: фраза делится на экране.
self.assertEqual(old.title, "Не отвечают совсем")
self.assertEqual([chip.text for chip in old.server_chips], ["Xbox DNS"])
self.assertEqual(old.more_label.text(), "и ещё 5")
page = finding_detail_card(ready.name, ready.state, ready.parts)
self.assertEqual(page.title, "Отвечают через раз")
self.assertEqual(
[(line.name, line.text) for line in page.sections[1].lines],
[
("Quad9", "9.9.9.9, 149.112.112.112"),
("Cloudflare", "1.1.1.1"),
("AdGuard", "94.140.14.14"),
("Google DNS", "8.8.8.8, 8.8.4.4"),
],
)
self.assertEqual(page.sections[2].lines[0].name, "Так бывает.")
def test_server_page_tells_what_was_checked_and_what_it_means(self) -> None:
card = server_card(AKAMAI.lines[1], AKAMAI)
self.assertEqual((card.title, card.level, card.status), ("www.roxio.com", "fail", "Загрузка оборвалась на 16 КБ"))
facts, result, meaning = card.sections
self.assertEqual([(line.name, line.text) for line in facts.lines], [("Провайдер", "Akamai"), ("Адрес", "www.roxio.com"), ("Сервер", "US.AKM-02"), ("Время проверки", "4.8 с")])
self.assertEqual(result.lines[0].text, "загрузка оборвалась на 16 КБ")
self.assertIn("фильтр провайдера", meaning.lines[0].name)
def test_tile_opens_the_server_page_and_back_returns_to_the_list(self) -> None:
view = self._view()
closed = []
view.closed.connect(lambda: closed.append(True))
view.show_card(HOSTINGS)
self.app.processEvents()
grid = view.blocks[0].grid
QTest.mouseClick(grid, Qt.MouseButton.LeftButton, pos=grid.tile_rect(1).center().toPoint())
self.assertEqual(view.card().title, "www.roxio.com")
# Строка пути: BlockCheck → Зарубежные хостинги → сервер.
self.assertEqual(view.breadcrumb.count(), 3)
self.assertTrue(view.go_back())
self.assertEqual(view.card().key, "hostings")
self.assertEqual(view.breadcrumb.count(), 2)
# Выше списка хостингов шагов внутри отчёта нет — дальше отчёт закрывают.
self.assertFalse(view.go_back())
QTest.mouseClick(grid if False else view.blocks[0].grid, Qt.MouseButton.LeftButton, pos=QPoint(20, 20))
self.assertEqual(view.card().title, "media.miele.com")
view._on_breadcrumb(f"{view.LEVEL_KEY}0")
self.assertEqual(view.card().key, "hostings")
# Esc со страницы сервера — шаг назад, а не выход из отчёта.
view.open_child(server_card(AKAMAI.lines[0], AKAMAI))
QTest.keyClick(view, Qt.Key.Key_Escape)
self.assertEqual((view.card().key, closed), ("hostings", []))
QTest.keyClick(view, Qt.Key.Key_Escape)
self.assertEqual(closed, [True])
class GroupActionsTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
@staticmethod
def _dns(text: str) -> dict:
return {"level": "warn", "kind": "dns", "text": text, "title": "", "target": "", "advice": [], "evidence": [], "action": "dns"}
def test_one_action_and_one_report_button_stand_in_the_group_header(self) -> None:
opened, acted = [], []
panel = BlockcheckSummaryPanel(on_action=lambda *args: acted.append(args), on_open=opened.append)
self.addCleanup(panel.deleteLater)
report = {
"dns_servers": {"level": "fail", "findings": [{"level": "fail", "text": "x"}], "text": ""},
"problems": [self._dns("Обычные ответы подменяются у серверов: Cloudflare (1.1.1.1)"), self._dns("Отвечают через раз: Quad9 (9.9.9.9)")],
}
panel.show_report(report)
[group] = panel.problem_groups()
self.assertEqual(group.shared_action_button.text(), "Настройка DNS")
# Строки не повторяют кнопку и не открывают один и тот же отчёт каждая.
self.assertEqual([(row.action_button, row.card_key) for row in group.rows], [(None, ""), (None, "")])
# Находки — карточки одной сетки: заголовок и метки серверов, без абзацев пояснений.
self.assertEqual(group.findings_flow.cards(), group.rows)
self.assertEqual([card.title for card in group.rows], ["Обычные ответы подменяются у серверов", "Отвечают через раз"])
self.assertEqual([chip.text for chip in group.rows[0].server_chips], ["Cloudflare"])
group.shared_action_button.click()
group.report_button.click()
self.assertEqual((acted, opened), ([("dns", "")], ["dns_servers"]))
def test_group_without_a_kind_goes_last(self) -> None:
from blockcheck.ui.summary_model import group_problems
problems = [
{"level": "fail", "kind": "other", "text": "WhatsApp не открывается"},
{"level": "warn", "kind": "dns", "text": "Отвечают через раз"},
{"level": "fail", "kind": "ip", "text": "Telegram не открывается"},
]
self.assertEqual([kind for kind, _rows in group_problems(problems)], ["ip", "dns", "other"])
def test_wide_card_goes_first_so_no_card_is_left_alone_in_a_row(self) -> None:
from blockcheck.ui.result_cards import ResultCardsView
view = ResultCardsView()
self.addCleanup(view.deleteLater)
report = {
"services": [],
"ipv6": {"state": "absent", "text": "в этой сети его нет"},
"freeze": {"level": "ok", "headline": "", "advice": [], "items": [], "servers": [{"provider": "AWS", "id": "A", "host": "a", "state": "ok", "text": "ок", "direction": "download", "seconds": 1.0}]},
}
view.show_report(report, animate=False)
keys = [card.card.key for card in view.checks_grid.cards()]
self.assertEqual(keys[0], "hostings")
self.assertIn("ipv6", keys[1:])
def test_finding_card_is_short_and_opens_its_own_page(self) -> None:
from blockcheck.ui.finding_parts import short_title
self.assertEqual(
short_title("DNS подменяет ответы для www.youtube.com, rutracker.org и ещё 9"),
("DNS подменяет ответы", ["www.youtube.com", "rutracker.org"], 9),
)
self.assertEqual(short_title("Шифрованный DNS AdGuard (94.140.14.140) недоступен"), ("Шифрованный DNS AdGuard недоступен", [], 0))
children = []
panel = BlockcheckSummaryPanel(
on_action=lambda *_args: None, on_open=lambda _key: None, on_open_page=children.append
)
self.addCleanup(panel.deleteLater)
long = "Обычные DNS-запросы перехватываются по дороге (провайдером или роутером): запросы выполняет одна сеть. Отвечает перехватчик"
panel.show_report(
{
"dns_servers": {"level": "fail", "findings": [{"level": "fail", "text": "x"}], "text": ""},
"problems": [{**self._dns(long), "level": "fail"}, self._dns("DNS подменяет ответы для www.youtube.com, rutracker.org и ещё 9. Браузер этого не замечает")],
}
)
[group] = panel.problem_groups()
first, second = group.rows
# На карточке — коротко; полный заголовок и текст — в подсказке и на странице находки.
self.assertEqual(first.title, "Обычные DNS-запросы перехватываются по дороге")
self.assertIn("(провайдером или роутером)", first.hint_text)
self.assertEqual((second.title, [chip.text for chip in second.server_chips], second.more_label.text()), ("DNS подменяет ответы", ["www.youtube.com", "rutracker.org"], "и ещё 9"))
QTest.mouseClick(first, Qt.MouseButton.LeftButton)
# Карточка открывает одну страницу — свою, без отчёта раздела перед ней.
[child] = children
self.assertEqual(child.title, "Обычные DNS-запросы перехватываются по дороге (провайдером или роутером)")
self.assertEqual(child.sections[0].lines[0].name, long)
self.assertEqual([section.title for section in child.sections[1:]], ["Что это значит"])
def test_finding_opens_its_own_page_even_without_a_section_report(self) -> None:
from blockcheck.ui.check_results import problem_detail_card
opened = []
panel = BlockcheckSummaryPanel(on_action=lambda *_args: None, on_open_page=opened.append)
self.addCleanup(panel.deleteLater)
cut = {
"level": "fail",
"kind": "cut16",
"action": "strategy",
"text": "Обрывается загрузка с зарубежных серверов на 16–20 КБ",
"advice": ["Подберите стратегию"],
"chips": [("Akamai", ["US.AKM-01"]), ("Contabo", ["DE.CNT-01", "DE.CNT-02"])],
}
panel.show_report({"problems": [cut]})
[group] = panel.problem_groups()
# Отчёта раздела «Зарубежные хостинги» в этом итоге нет — страница находки открывается всё равно.
self.assertIsNone(group.report_button)
QTest.mouseClick(group.rows[0], Qt.MouseButton.LeftButton)
[page] = opened
self.assertEqual(page, problem_detail_card({**cut, "chips": cut["chips"]}))
self.assertEqual(page.title, "Обрывается загрузка с зарубежных серверов на 16–20 КБ")
# На странице — те же хостинги, что метками на карточке, со всеми серверами, и совет.
servers = next(section for section in page.sections if section.title == "Серверы")
self.assertEqual([(line.name, line.text) for line in servers.lines], [("Akamai", "US.AKM-01"), ("Contabo", "DE.CNT-01, DE.CNT-02")])
self.assertEqual(page.sections[-1].lines[0].name, "Подберите стратегию")
# Серверы — плитками с логотипами, значок страницы — по виду проблемы, хвост фразы — итогом в шапке.
self.assertTrue(servers.tiles)
self.assertEqual(page.icon, "fa5s.cut")
tail = problem_detail_card({**cut, "text": cut["text"] + ": 13 из 31 проверенных серверов"})
self.assertEqual(tail.status, "13 из 31 проверенных серверов")
self.assertNotIn("Что это значит", [section.title for section in tail.sections])
def test_tile_scrolls_the_page_to_its_group(self) -> None:
area = QScrollArea()
self.addCleanup(area.deleteLater)
area.setWidgetResizable(True)
panel = BlockcheckSummaryPanel()
area.setWidget(panel)
area.resize(900, 260)
area.show()
problems = [
{"level": "fail", "kind": "ip", "title": f"Сайт {index}", "text": f"Сайт {index} не открывается", "target": f"s{index}.example", "advice": [], "evidence": [], "action": ""}
for index in range(40)
] + [self._dns("Отвечают через раз: Quad9 (9.9.9.9)")]
panel.show_report({"problems": problems, "services": [{"key": "a", "label": "Сайт 0", "level": "fail", "kind": "ip", "targets": []}]})
self.app.processEvents()
self.assertTrue(panel.scroll_to_group("dns"))
self.assertGreater(area.verticalScrollBar().value(), 0)
self.assertFalse(panel.scroll_to_group("нет такой"))
# Сигнал плитки ведёт туда же.
area.verticalScrollBar().setValue(0)
panel.overview.tiles()[0].opened.emit("dns")
self.assertGreater(area.verticalScrollBar().value(), 0)
def test_single_finding_is_a_card_with_the_button_in_the_group_header(self) -> None:
panel = BlockcheckSummaryPanel(on_action=lambda *_args: None, on_open=lambda _key: None)
self.addCleanup(panel.deleteLater)
panel.show_report({"dns_servers": {"level": "fail", "findings": [], "text": ""}, "problems": [self._dns("Отвечают через раз: Quad9 (9.9.9.9)")]})
[group] = panel.problem_groups()
# Одна находка — такая же карточка ограниченной ширины, как несколько, а не строка во всё окно.
self.assertIsNotNone(group.shared_action_button)
self.assertIsNotNone(group.report_button)
[card] = group.findings_flow.cards()
self.assertIsInstance(card, FindingCard)
self.assertEqual(card.title, "Отвечают через раз")
if __name__ == "__main__":
unittest.main()