zapretgui/tests/test_blockcheck_result_cards.py

1163 lines
68 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.

import os
import unittest
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from types import SimpleNamespace
from unittest.mock import patch
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtCore import Qt
from PyQt6.QtTest import QTest
from PyQt6.QtWidgets import QApplication, QWidget
from blockcheck.ui.page import SCOPE_ALL, BlockcheckPage
from blockcheck.ui.result_cards import (
CardsGrid,
HostingDots,
ProgressSteps,
ResultCardsView,
ResultDetailView,
card_plain_text,
)
from blockcheck_words import with_words
from blockcheck.ui.result_cards_model import FILTER_MARK, PREVIEW_LINES, Card, Line, build_cards, build_counters
from diagnostics.freeze_check import FreezeState, check_freeze, every_freeze_target
from diagnostics.tls_probe import ProbeResult
def _target(host, purpose="сайт", ok=True, **extra):
item = {
"host": host,
"purpose": purpose,
"ok": ok,
"state": "ok" if ok else "dpi",
"short": "открывается" if ok else "соединение сброшено",
"text": "открывается (20 мс)" if ok else "соединение сброшено (1.2.3.4)",
}
item.update(extra)
return item
def _service(key, label, level, targets, **extra):
# Образец сайта — со словами отчёта (итог, дороги, метки): экран показывает только их.
return with_words({"key": key, "label": label, "level": level, "kind": "", "targets": targets, **extra})
def _servers():
rows = [
("AWS", "DE.AWS-01", "a.example", "freeze", "загрузка оборвалась на 16 КБ", "download"),
("AWS", "FR.AWS-02", "b.example", "ok", "получено 32 КБ без обрыва", "download"),
("Akamai", "SE.AKM-01", "c.example", "freeze", "загрузка проходит, но отправка замирает", "upload"),
("OVH", "FR.OVH-01", "d.example", "unknown", "не удалось проверить", "download"),
]
return [
{"provider": p, "id": i, "host": h, "state": s, "text": t, "direction": d, "seconds": 1.25}
for p, i, h, s, t, d in rows
]
_REPORT = {
"scope": "full",
"services": [
_service("youtube", "YouTube", "warn", [
_target("www.youtube.com", quic="blocked_by_name", quic_text="блокируется по имени сайта"),
_target("i.ytimg.com", "превью видео"),
], dns_note="DNS подменяет адрес www.youtube.com"),
_service("x", "X (Twitter)", "fail", [
_target("x.com", ok=False, cause="by_name", cause_text="Блокировка по имени сайта.", volume="", note="адрес из hosts"),
], kind="sni", advice=["Подберите стратегию"]),
_service("google", "Google", "ok", [_target("www.google.com", quic="ok", quic_text="отвечает")], control=True),
_service("user:my.example", "my.example", "unknown", [_target("my.example", ok=False, state="unknown")]),
],
"freeze": {"level": "fail", "headline": "Обрыв", "advice": ["Подберите стратегию"], "items": [], "servers": _servers()},
"voice": {
"level": "warn",
"headline": "Звонки с перебоями",
"advice": [],
"items": [
{"name": "Google STUN", "ok": True, "state": "ok", "text": "отвечает"},
{"name": "Telegram STUN", "ok": False, "state": "fail", "text": "не ответил"},
],
},
"reference": [
{"label": "Cloudflare", "address": "1.1.1.1", "ok": True, "reason": ""},
{"label": "Google", "address": "8.8.8.8", "ok": False, "reason": "сервер молчит"},
],
"spoofed_hosts": [],
"ipv6": {"state": "absent", "text": "в этой сети его нет"},
"dns_servers": {
"level": "fail",
"findings": [{"level": "fail", "text": "Обычные DNS-запросы перехватываются по дороге."}],
"text": "полная таблица серверов",
},
"filter": {
"host": "rutracker.org",
"address": "104.21.32.39",
"found": True,
"hop": 2,
"text": "фильтр стоит между узлом 1 и узлом 2",
"hops": [
{"ttl": 1, "address": "10.0.0.1", "rtt_ms": 0.4},
{"ttl": 2, "address": "10.0.0.2", "rtt_ms": 42.0},
{"ttl": 3, "address": "", "rtt_ms": None},
],
},
"system": [
{"key": "admin", "title": "Права администратора", "level": "ok", "text": "есть", "advice": ""},
{"key": "bfe", "title": "Служба BFE", "level": "fail", "text": "не работает", "advice": "Включите службу"},
{"key": "proxy", "title": "Системный прокси", "level": "warn", "text": "включён", "advice": ""},
{"key": "av", "title": "Антивирус", "level": "info", "text": "Kaspersky", "advice": ""},
],
}
class CardsModelTests(unittest.TestCase):
def setUp(self) -> None:
self.cards = {card.key: card for card in build_cards(_REPORT)}
def test_broken_sites_go_first_and_checks_follow_sites(self) -> None:
keys = list(self.cards)
self.assertEqual(keys[0], "site:x")
self.assertEqual(keys[4:], ["hostings", "voice", "dns", "dns_servers", "ipv6", "filter", "system"])
self.assertTrue(all(self.cards[key].site for key in keys[:4]))
def test_site_card_names_the_kind_and_keeps_every_measurement(self) -> None:
card = self.cards["site:x"]
self.assertEqual((card.level, card.kind, card.status), ("fail", "sni", "По имени (SNI)"))
self.assertIn(("блокировка по имени", "fail"), card.chips)
detail = card_plain_text(card)
for expected in ("x.com — сайт", "Блокировка по имени сайта.", "адрес из hosts", "Подберите стратегию"):
self.assertIn(expected, detail)
def test_open_site_with_spoofed_dns_is_not_a_broken_site(self) -> None:
card = self.cards["site:youtube"]
self.assertEqual((card.level, card.status), ("ok", "Открывается"))
self.assertIn(("QUIC: закрыт", "warn"), card.chips)
self.assertIn(("DNS: подменён", "warn"), card.chips)
self.assertEqual([line.name for line in card.lines], ["сайт", "превью видео"])
def test_control_and_unknown_sites(self) -> None:
self.assertIn(("контрольный", "info"), self.cards["site:google"].chips)
self.assertIn(("QUIC: работает", "ok"), self.cards["site:google"].chips)
unknown = self.cards["site:user:my.example"]
self.assertEqual((unknown.level, unknown.status, unknown.icon), ("unknown", "Не удалось проверить", "fa5s.globe"))
self.assertEqual(unknown.lines[0].state, "unknown")
def test_hostings_card_counts_directions_and_groups_dots_by_provider(self) -> None:
card = self.cards["hostings"]
self.assertTrue(card.wide)
self.assertEqual(card.status, "Обрыв у 2 из 4")
self.assertEqual(
[(line.name, line.text) for line in card.lines],
[
("Проходит без обрыва", "1"),
("Обрыв загрузки на 16–20 КБ", "1"),
("Обрыв отправки", "1"),
("Сервер не ответил", "1"),
],
)
self.assertEqual([(group.name, group.states) for group in card.dots], [
("AWS", ("fail", "ok")), ("Akamai", ("fail",)), ("OVH", ("unknown",)),
])
self.assertIn("DE.AWS-01 · a.example", card.dots[0].hints[0])
titles = [section.title for section in card.sections]
self.assertIn("AWS — обрыв у 1 из 2", titles)
self.assertIn("1.2 с", card_plain_text(card))
def test_unknown_server_is_not_counted_as_cut(self) -> None:
report = {"freeze": {"level": "unknown", "servers": [dict(_servers()[3])]}}
[card] = build_cards(report)
self.assertEqual(card.status, "Не удалось проверить")
self.assertEqual(card.dots[0].states, ("unknown",))
def test_quick_check_without_server_list_has_no_hostings_card(self) -> None:
self.assertEqual(build_cards({"freeze": {"level": "ok", "items": [{"name": "AWS"}]}}), [])
def test_dns_card_names_silent_reference_and_spoofed_hosts(self) -> None:
card = self.cards["dns"]
self.assertEqual((card.level, card.status), ("warn", "Эталоны: 1 из 2"))
self.assertEqual(card.lines[1].text, "сервер молчит")
spoofed = {c.key: c for c in build_cards({**_REPORT, "spoofed_hosts": ["x.com"]})}["dns"]
self.assertEqual(spoofed.status, "Подмена адресов: 1")
self.assertEqual(spoofed.lines[0].text, "x.com")
def test_ipv6_absent_is_neither_good_nor_bad(self) -> None:
self.assertEqual((self.cards["ipv6"].level, self.cards["ipv6"].status), ("unknown", "Нет в этой сети"))
def test_filter_card_marks_the_hop_in_details(self) -> None:
card = self.cards["filter"]
self.assertEqual((card.level, card.status), ("warn", "Между узлами 1 и 2"))
hops = [line.name for line in card.sections[1].lines]
self.assertEqual(hops, ["Узел 1", f"── {FILTER_MARK} ──", "Узел 2", "Узел 3"])
self.assertEqual(card.sections[1].lines[0].text, "10.0.0.1 · < 1 мс")
self.assertEqual(card.sections[1].lines[3].text, "не ответил")
missing = build_cards({"filter": {"host": "x.com", "found": False, "hop": None, "text": "не найден", "hops": []}})[0]
self.assertEqual((missing.level, missing.status), ("unknown", "Не найдено"))
self.assertNotIn(FILTER_MARK, card_plain_text(missing))
def test_system_card_shows_the_worst_first_and_advice_in_details(self) -> None:
card = self.cards["system"]
self.assertEqual((card.level, card.status), ("fail", "Мешает работе: 1"))
self.assertEqual(card.lines[0].name, "Служба BFE")
self.assertEqual(card.sections[0].title, "Что делать")
self.assertEqual(card.sections[0].lines[0].text, "Включите службу")
fine = build_cards({"system": [dict(_REPORT["system"][0]), dict(_REPORT["system"][3])]})[0]
self.assertEqual((fine.level, fine.status), ("ok", "В порядке: 1 из 2"))
unknown = build_cards({"system": [{"title": "Часы", "level": "unknown", "text": "не удалось"}]})[0]
self.assertEqual(unknown.status, "Не проверено")
def test_dns_servers_card_keeps_full_text_for_details(self) -> None:
card = self.cards["dns_servers"]
self.assertEqual((card.level, card.status), ("fail", "Есть проблемы"))
self.assertEqual(card.sections[-1].text, "полная таблица серверов")
def test_counters_show_the_amount_of_work(self) -> None:
counters = {counter.caption: counter.value for counter in build_counters(_REPORT)}
self.assertEqual(counters["сайтов"], 4)
self.assertEqual(counters["адресов сайтов"], 5)
self.assertEqual(counters["проверок QUIC"], 2)
self.assertEqual(counters["серверов хостингов"], 4)
self.assertEqual(counters["узлов по дороге"], 3)
self.assertEqual(build_counters({}), [])
class NewSectionsCardsTests(unittest.TestCase):
"""Сравнение «с Zapret и без», замедление по имени и время по частям — на карточках."""
COMPARE = {
"level": "warn",
"headline": "Пресет «Default» чинит не всё: помог 1, не помог 1",
"preset": "Default",
"zapret_in": "current",
"other_time": "2026-10-08T12:00:00",
"helped": ["YouTube"],
"not_helped": ["Telegram"],
"fine_anyway": [],
"broken": [],
"notes": ["Это итог для выбранного пресета."],
}
@staticmethod
def _card(report: dict, key: str) -> Card:
return next(card for card in build_cards(report) if card.key == key)
def test_compare_card_names_groups_preset_and_the_other_run(self) -> None:
card = self._card({"compare": self.COMPARE}, "compare")
text = " ".join(f"{line.name} {line.text}" for section in card.sections for line in section.lines)
self.assertEqual((card.level, card.title), ("warn", "С Zapret и без"))
self.assertEqual([(line.state, line.text) for line in card.lines], [("ok", "YouTube"), ("fail", "Telegram")])
for expected in ("Default", "08.10 12:00", "без Zapret", "Это итог для выбранного пресета."):
self.assertIn(expected, text)
# Пустые группы не показываются.
self.assertFalse(any("перестали" in section.title for section in card.sections))
def test_compare_tile_opens_the_site_page_and_says_it_is_one_preset(self) -> None:
protocols = [{"key": "tls13", "title": "TLS 1.3", "state": "ok", "word": "проходит", "text": "проходит"}]
youtube = _service("youtube", "YouTube", "ok", [_target("www.youtube.com", main=True, protocols=protocols, quic="ok", dns_state="ok")])
card = self._card({"compare": self.COMPARE, "services": [youtube]}, "compare")
# Сравнение — итог одного пресета: это сказано в слове итога и первой строкой отчёта.
self.assertIn("пресет «Default»", card.status)
self.assertIn("Это итог одного пресета — «Default»", card.sections[0].lines[0].name)
helped = next(section for section in card.sections if section.title == "Обход помог")
page = helped.lines[0].page
# Страница сайта: что было с Zapret и без, пресет, и все пробы этой проверки ниже.
self.assertEqual((page.title, page.status, page.level, page.site), ("YouTube", "Обход помог", "ok", True))
verdict = page.sections[0]
rows = {line.name: line.text for line in verdict.lines}
self.assertEqual(rows["С Zapret (эта проверка)"], "открывается")
self.assertEqual(rows["Без Zapret (проверка 08.10 12:00)"], "не открывается")
self.assertEqual(rows["Пресет"], "Default")
self.assertTrue(any("одного пресета" in line.name for line in verdict.lines))
self.assertTrue(page.marks)
self.assertGreater(len(page.sections), 1)
# Сайта нет среди проверенных в этот раз — остаётся само сравнение.
alone = next(section for section in card.sections if section.title == "Не открываются и с обходом").lines[0].page
self.assertEqual((alone.title, len(alone.sections)), ("Telegram", 1))
self.assertEqual({line.name: line.text for line in alone.sections[0].lines}["С Zapret (эта проверка)"], "не открывается")
# Прошлая проверка была с Zapret, а эта — без: подписи меняются местами.
swapped = self._card({"compare": {**self.COMPARE, "zapret_in": "past"}, "services": [youtube]}, "compare")
rows = {line.name: line.text for line in next(s for s in swapped.sections if s.title == "Обход помог").lines[0].page.sections[0].lines}
self.assertEqual((rows["Без Zapret (эта проверка)"], rows["С Zapret (проверка 08.10 12:00)"]), ("не открывается", "открывается"))
def test_filter_card_leads_with_the_places_found_for_each_mechanism(self) -> None:
place = {
"state": "disagree", "found": False, "hop": None, "text": "По разным сайтам фильтр оказался на разных узлах.",
"mechanisms": [
{"key": "quic", "title": "QUIC по имени сайта", "hop": 2, "text": "между узлами 1 и 2"},
{"key": "tcp", "title": "TLS по имени сайта", "hop": None, "text": "место не найдено"},
{"key": "dns", "title": "Перехват обычных DNS-запросов", "hop": 9, "text": "между узлами 8 и 9"},
],
}
card = self._card({"filter": place}, "filter")
# Одного места нет, но по способам оно найдено: это и стоит в слове итога и первыми строками.
self.assertEqual((card.status, card.level), ("По способам: узлы 2, 9", "warn"))
self.assertEqual([line.name for line in card.lines[:2]], ["QUIC по имени сайта", "Перехват обычных DNS-запросов"])
# Ничего не нашли вовсе — остаётся прежнее слово.
nothing = self._card({"filter": {**place, "mechanisms": []}}, "filter")
self.assertEqual(nothing.status, "Узлы не совпали")
def test_site_status_names_a_cause_that_is_not_the_provider(self) -> None:
def card(**extra):
[built] = [item for item in build_cards({"services": [_service("chatgpt", "ChatGPT", "warn", [_target("chatgpt.com", main=True, **extra)])]}) if item.site]
return built
# Запись в hosts ведёт на нерабочий адрес: сайт жив, мешает сама запись.
self.assertEqual(card(hosts_stale=True).status, "Мешает запись в hosts")
# Вместо сайта отвечает кто-то другой — названо кто.
self.assertEqual(card(cert={"code": "antivirus", "text": "x"}).status, "Сертификат подменяет антивирус")
self.assertEqual(card(cert={"code": "что-то новое", "text": "x"}).status, "Чужой сертификат")
# Чужой сертификат важнее записи в hosts; без обеих причин слово итога прежнее.
self.assertEqual(card(hosts_stale=True, cert={"code": "other_site"}).status, "Отвечает другой сайт")
self.assertNotIn("hosts", card().status)
def test_site_that_opens_every_other_time_is_a_third_outcome(self) -> None:
from blockcheck.ui.block_kinds_view import kind_color, site_groups
from blockcheck.ui.check_results import BlockcheckSummaryPanel
from blockcheck.ui.site_road import site_road
from diagnostics.block_kind import KIND_ORDER, KIND_UNSTABLE, kind_info
protocols = [{"key": "tls13", "title": "TLS 1.3", "state": "ok", "word": "проходит", "text": "проходит"}]
shaky = _service("signal", "Signal", "warn", [_target("signal.org", main=True, protocols=protocols, unstable="открылся со второй попытки, а при повторе снова сбой", rechecked="unstable")])
steady = _service("github", "GitHub", "ok", [_target("github.com", main=True)])
report = {"services": [shaky, steady], "problems": [{"level": "warn", "kind": "", "title": "Signal", "text": "Signal открывается через раз", "target": "signal.org", "advice": [], "evidence": [], "action": "strategy"}], "working": ["GitHub"]}
[card] = [item for item in build_cards(report) if item.key == "site:signal"]
# На карточке — своё слово и свой вид, а не общее «Есть проблемы».
self.assertEqual((card.kind, card.status, card.level), (KIND_UNSTABLE, "Через раз", "warn"))
self.assertIn(KIND_UNSTABLE, KIND_ORDER)
self.assertIn("через раз", kind_info(KIND_UNSTABLE).title.lower())
# В полосе итога — своя доля между «открываются» и блокировками, своим цветом.
groups = site_groups(report)
self.assertEqual([(group.key, group.names) for group in groups], [("open", ("GitHub",)), (KIND_UNSTABLE, ("Signal",))])
self.assertNotEqual(kind_color(KIND_UNSTABLE), kind_color("open"))
self.assertNotEqual(kind_color(KIND_UNSTABLE), kind_color("sni"))
# Дорога до сайта не оборвана: крестика нет, у сайта — «через раз».
stages, broken = site_road(card)
self.assertEqual((broken, stages[-1].word, stages[-1].state), (-1, "через раз", "warn"))
# В списке проблем — своя группа, а не «Остальное».
panel = BlockcheckSummaryPanel(on_action=lambda *_args: None, on_open=lambda _key: None)
self.addCleanup(panel.deleteLater)
panel.show_report(report)
[group] = panel.problem_groups()
self.assertEqual((group.kind(), group.title_label.text()), (KIND_UNSTABLE, "Открываются через раз"))
# Чужой сертификат и запись в hosts важнее: их слово итога остаётся.
both = _service("x", "X", "warn", [_target("x.com", main=True, unstable="через раз", hosts_stale=True)])
[other] = [item for item in build_cards({"services": [both]}) if item.site]
self.assertEqual(other.status, "Мешает запись в hosts")
def test_mark_explains_itself_on_hover_and_its_word_fits(self) -> None:
from PyQt6.QtCore import QPointF
from PyQt6.QtGui import QMouseEvent
from blockcheck.ui.result_cards import SITE_CARD_MIN_WIDTH, ResultCard
def proto(title: str, state: str, word: str, hint: str, text: str) -> dict:
return {"key": title, "title": title, "state": state, "word": word, "text": text, "hint": hint}
protocols = [
# Браузерное приветствие проходит — обычные «молчат», и это не беда: состояние нейтральное.
proto("TLS 1.2", "info", "не мешает", "TLS 1.2 — старый вид защищённого соединения.", "Молчит, но исправлять ничего не нужно."),
proto("TLS 1.3", "info", "не мешает", "TLS 1.3 — современный вид защищённого соединения.", "Молчит, но исправлять ничего не нужно."),
proto("Как Chrome", "ok", "проходит", "Так здоровается браузер Chrome.", "проходит"),
proto("HTTP", "info", "переход", "HTTP — соединение без защиты.", "переход на HTTPS"),
]
service = _service("youtube", "YouTube", "ok", [_target("www.youtube.com", main=True, protocols=protocols, quic="ok", dns_state="ok")])
[card] = [item for item in build_cards({"services": [service]}) if item.site]
tls, _tls13, chrome, _http, quic, dns = card.marks
# Подсказка: что это за дорога простыми словами и чем кончилась проба; у QUIC и DNS текст свой.
self.assertEqual(tls.state, "info")
self.assertIn("старый вид защищённого соединения", tls.hint)
self.assertIn("исправлять ничего не нужно", tls.hint)
self.assertIn("быстрый способ соединения", quic.hint)
self.assertIn("справочная", dns.hint)
widget = ResultCard(card)
self.addCleanup(widget.deleteLater)
widget.resize(SITE_CARD_MIN_WIDTH, widget.height_for(SITE_CARD_MIN_WIDTH))
widget.show()
# В самой узкой карточке сетки слово у «Chrome» помещается целиком.
cell = widget.mark_rect(2)
need = 18 + widget._text_metrics.horizontalAdvance(chrome.label) + 2 + 6 + widget._text_metrics.horizontalAdvance(chrome.word)
self.assertLessEqual(need, cell.width())
# Наведение на дорогу показывает её подсказку; над остальной карточкой её нет.
center = cell.center()
self.assertEqual(widget.mark_at(center.x(), center.y()), 2)
self.assertEqual(widget.mark_at(5, 5), -1)
self.assertEqual(widget.mark_hint(2), "Chrome: проходит\nТак здоровается браузер Chrome.\nпроходит")
self.assertEqual(widget.mark_hint(-1), "")
move = QMouseEvent(QMouseEvent.Type.MouseMove, QPointF(center), QPointF(widget.mapToGlobal(center.toPoint())), Qt.MouseButton.NoButton, Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier)
widget.mouseMoveEvent(move)
self.assertEqual(widget._mark_hint.text(), widget.mark_hint(2))
widget.grab()
def test_site_card_words_come_from_the_report_and_nothing_is_derived(self) -> None:
"""Сторож: у свежего отчёта слово итога, дороги и метки карточки — ровно те, что в отчёте."""
from blockcheck.ui import result_cards_model as model
roads = [
{"key": "tls12", "label": "TLS 1.2", "state": "info", "word": "не мешает", "text": "молчит, исправлять не нужно", "hint": "старый вид защищённого соединения"},
{"key": "tls13", "label": "TLS 1.3", "state": "fail", "word": "сброс", "text": "соединение сброшено", "hint": "современный вид"},
{"key": "browser", "label": "Chrome", "state": "ok", "word": "проходит", "text": "проходит", "hint": "так здоровается браузер"},
{"key": "http", "label": "HTTP", "state": "info", "word": "переход", "text": "переход на HTTPS", "hint": "без защиты"},
{"key": "quic", "label": "QUIC", "state": "warn", "word": "закрыт", "text": "QUIC режут", "hint": "быстрый способ"},
{"key": "dns", "label": "DNS", "state": "unknown", "word": "—", "text": "сверить не удалось", "hint": "справочная"},
]
tags = [
{"key": "cause", "text": "причина из отчёта", "state": "fail"},
{"key": "registry", "text": "в реестре РКН", "state": "info"},
{"key": "cut16", "text": "обрыв на 16 КБ", "state": "warn"},
]
# Данные адреса нарочно говорят другое: если бы экран выводил слова сам, они бы разошлись с отчётом.
target = _target("example.org", main=True, ok=True, quic="ok", dns_state="spoofed", hosts_stale=True, unstable="через раз", volume="", cert={"code": "antivirus"}, cause="by_name")
service = {
**_service("example", "Example", "warn", [target], kind="fingerprint"),
"status": "Слово из отчёта",
"roads": roads,
"tags": tags,
"dns_note": "подмена",
"control": True,
}
[card] = [item for item in build_cards({"services": [service]}) if item.site]
self.assertEqual((card.status, card.level, card.kind), ("Слово из отчёта", "warn", "fingerprint"))
self.assertEqual([(mark.label, mark.word, mark.state) for mark in card.marks], [(road["label"], road["word"], road["state"]) for road in roads])
# Причина — из отчёта: она идёт в полный набор меток, а на самой карточке не повторяет слово итога.
self.assertEqual(card.tags, tuple((tag["text"], tag["state"]) for tag in tags if tag["key"] != "cause"))
self.assertIn(("причина из отчёта", "fail"), card.chips)
# Своего слова причины экран не подставляет, хотя у адреса стоит код блокировки по имени.
self.assertNotIn("блокировка по имени", [text for text, _state in card.chips])
# Подсказка дороги — пояснение и фраза из отчёта, без добавок.
self.assertEqual(card.marks[4].hint, "быстрый способ\nQUIC режут")
# Значок — по ключу дороги: это вид, он остаётся на экране.
self.assertEqual([mark.icon for mark in card.marks], ["fa5s.lock", "fa5s.lock", "fa5b.chrome", "fa5s.unlock-alt", "fa5s.bolt", "fa5s.exchange-alt"])
# Уровень и вид — как в отчёте, даже когда данные адресов намекают на другое.
self.assertEqual(model.site_level({**service, "level": "ok"}), "ok")
self.assertEqual(model.site_kind({**service, "kind": ""}, "warn"), "")
# Запасного расчёта у экрана нет вовсе: источник слов один — отчёт.
self.assertFalse([name for name in dir(model) if "legacy" in name.lower()])
# Отчёт, сохранённый до появления готовых слов, открывается без падения — но без слова итога,
# дорог и меток: экран показывает только то, что в отчёте есть, и ничего не достраивает.
old = {"key": "example", "label": "Example", "level": "warn", "kind": "", "targets": [_target("example.org", main=True, hosts_stale=True, cause="by_name", quic="ok", protocols=[{"title": "TLS 1.3", "state": "fail", "word": "сброс", "text": "сброс"}])]}
[bare] = [item for item in build_cards({"services": [old]}) if item.site]
self.assertEqual((bare.status, bare.marks, bare.tags, bare.chips), ("", (), (), ()))
self.assertEqual((bare.level, bare.title), ("warn", "Example"))
self.assertTrue(bare.sections)
from blockcheck.ui.result_cards import ResultCard
widget = ResultCard(bare)
self.addCleanup(widget.deleteLater)
widget.resize(304, widget.height_for(304))
widget.grab()
def test_crowd_card_shows_the_report_words_and_says_the_check_caused_the_stop(self) -> None:
crowd = {
"state": "freeze", "level": "warn", "host": "www.instagram.com", "address": "163.70.151.174", "label": "Instagram",
"text": "Instagram замирает от нескольких соединений сразу.",
"advice": "Остановку вызвала сама проверка: так фильтр наказывает за пачку соединений.",
"before": "ok", "crowd": ["ok", "timeout"], "after": "timeout", "control": "ok", "recovered_s": 42.0, "waited_s": 60.0,
}
card = self._card({"crowd": crowd}, "crowd")
self.assertEqual((card.title, card.level), ("Несколько соединений сразу", "warn"))
# Вывод и оговорка «остановку вызвала сама проверка» — прямо на карточке, словами отчёта.
self.assertEqual([line.name for line in card.lines], [crowd["text"], crowd["advice"]])
facts = {line.name: line.text for line in card.sections[-1].lines}
self.assertEqual((facts["Сайт"], facts["Адрес сервера"], facts["Сайт вернулся через"]), ("Instagram · www.instagram.com", "163.70.151.174", "42.0 с"))
# Коды исходов соединений экран в слова не переводит: строки появятся, когда их даст отчёт.
self.assertNotIn("timeout", " ".join(f"{line.name} {line.text}" for section in card.sections for line in section.lines))
with_rows = self._card({"crowd": {**crowd, "status": "Замирает", "rows": [{"title": "Соединение 2 из пачки", "text": "без ответа", "state": "fail"}]}}, "crowd")
self.assertEqual(with_rows.status, "Замирает")
self.assertIn(("Соединение 2 из пачки", "без ответа", "fail"), [(line.name, line.text, line.state) for line in with_rows.sections[-1].lines])
# Готовые строки отчёта уже говорят, когда сайт вернулся: своих чисел экран рядом не дописывает.
self.assertNotIn("Сайт вернулся через", [line.name for line in with_rows.sections[-1].lines])
# Проба не делалась — карточки нет.
self.assertFalse([item for item in build_cards({"crowd": None}) if item.key == "crowd"])
def test_no_comparison_no_card(self) -> None:
self.assertFalse([card for card in build_cards({"compare": None}) if card.key == "compare"])
def test_speed_card_shows_throttling_by_name(self) -> None:
speed = {
"level": "ok",
"headline": "Заметной разницы нет",
"items": [{"name": "OVH", "state": "ok", "text": "40 Мбит/с"}],
"names": {
"headline": "Замедляют по имени сайта: YouTube",
"server": "mirror.yandex.ru",
"control": "40 Мбит/с",
"items": [
{"name": "YouTube", "host": "www.youtube.com", "state": "warn", "text": "0.2 Мбит/с — замедляют"},
{"name": "GitHub", "host": "github.com", "state": "ok", "text": "38 Мбит/с"},
],
},
}
card = self._card({"speed": speed}, "speed")
rows = [(line.state, line.name) for line in card.sections[0].lines]
self.assertEqual((card.level, card.status), ("warn", "Замедляют: YouTube"))
self.assertIn(("warn", "YouTube (www.youtube.com)"), rows)
self.assertIn(("ok", "GitHub (github.com)"), rows)
self.assertIn("mirror.yandex.ru", rows[0][1])
calm = dict(speed, names=dict(speed["names"], items=[speed["names"]["items"][1]]))
calm_card = self._card({"speed": calm}, "speed")
self.assertEqual((calm_card.level, calm_card.status), ("ok", "Разницы нет"))
self.assertTrue(any("GitHub" in line.name for section in calm_card.sections for line in section.lines))
def test_site_row_tells_where_the_time_went(self) -> None:
from diagnostics.report_text import stages_text
self.assertEqual(stages_text({"dns": 0.4, "reach": 6.2, "cause": 3.0, "roads": 1.0}), "соединение 6 с, причина 3 с, дороги 1 с")
self.assertEqual(stages_text({"dns": 0.2}), "")
self.assertEqual(stages_text(None), "")
class CardsWidgetsTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def test_grid_uses_more_columns_on_a_wide_window_and_wide_card_takes_a_row(self) -> None:
grid = CardsGrid(250)
self.addCleanup(grid.deleteLater)
self.assertEqual([grid.columns_for(width) for width in (200, 520, 1040, 1600)], [1, 2, 4, 6])
grid.show()
grid.resize(1040, 10)
grid.show_cards(build_cards(_REPORT), animate=False)
widgets = {widget.card.key: widget for widget in grid.cards()}
sites = [widgets[key] for key in ("site:x", "site:youtube", "site:google", "site:user:my.example")]
# Четыре сайта встали в один ряд, каждый в свою колонку.
self.assertEqual(len({widget.x() for widget in sites}), 4)
self.assertEqual({widget.y() for widget in sites}, {0})
hostings = widgets["hostings"]
self.assertEqual((hostings.x(), hostings.width()), (0, 1040))
self.assertGreaterEqual(hostings.y(), max(widget.geometry().bottom() for widget in sites))
# Высота сетки — по нижней карточке: вложенная прокрутка её сама не узнает.
self.assertEqual(grid.height(), max(widget.geometry().bottom() for widget in grid.cards()) + 1)
grid.resize(300, 10)
self.assertEqual({widget.x() for widget in grid.cards()}, {0})
def test_cards_stand_in_even_rows_of_equal_height(self) -> None:
grid = CardsGrid(250)
self.addCleanup(grid.deleteLater)
grid.resize(800, 10)
grid.show()
tall = tuple(Line("ok", f"строка {n}", "да") for n in range(4))
cards = [Card(f"site:{n}", "fa5s.globe", f"Сайт {n}", "ok", "Открывается", lines=tall if n % 2 else tall[:1]) for n in range(5)]
grid.show_cards(cards, animate=False)
widgets = grid.cards()
self.app.processEvents()
# Три столбца: первые три карточки — один ряд, оставшиеся две — второй.
self.assertEqual([widget.y() for widget in widgets[:3]], [0, 0, 0])
self.assertEqual(len({widget.y() for widget in widgets[3:]}), 1)
# В ряду все одной высоты — по самой высокой, хотя строк на карточках разное число.
self.assertEqual(len({widget.height() for widget in widgets[:3]}), 1)
self.assertEqual(widgets[0].height(), widgets[1].height_for(widgets[1].width()))
self.assertEqual(widgets[3].y(), widgets[0].height() + 10)
self.assertEqual(grid.height(), widgets[4].geometry().bottom() + 1)
def test_site_card_shows_its_roads_in_fixed_places_instead_of_a_pile_of_chips(self) -> None:
def proto(title: str, state: str, word: str) -> dict:
return {"key": title, "title": title, "state": state, "word": word, "text": word}
def site(key: str, quic: str, **extra) -> dict:
protocols = [proto("TLS 1.2", "fail", "сброс"), proto("TLS 1.3", "fail", "нет соединения"), proto("Как Chrome", "ok", "проходит"), proto("HTTP", "info", "переход")]
main = _target(f"www.{key}.com", ok=False, main=True, protocols=protocols, quic=quic, dns_state="ok", cause="by_name", registry={"listed": True})
return _service(key, key.title(), "fail", [main], **extra)
report = {"services": [site("instagram", "ok"), site("linkedin", "blocked_by_name", dns_note="подмена")]}
first, second = [card for card in build_cards(report) if card.site]
# Дороги у каждого сайта — в одном порядке: по ним глаз сравнивает сайты между собой.
self.assertEqual([mark.label for mark in first.marks], ["TLS 1.2", "TLS 1.3", "Chrome", "HTTP", "QUIC", "DNS"])
self.assertEqual([mark.label for mark in second.marks], [mark.label for mark in first.marks])
self.assertEqual([(mark.word, mark.state) for mark in first.marks], [("сброс", "fail"), ("нет связи", "fail"), ("проходит", "ok"), ("переход", "info"), ("работает", "ok"), ("честный", "ok")])
self.assertEqual([(mark.word, mark.state) for mark in second.marks[4:]], [("закрыт", "warn"), ("подменён", "warn")])
# Метками остаётся только то, чего в дорогах нет; полный набор меток — для отчёта и подсказки.
self.assertEqual(first.tags, (("в реестре РКН", "info"),))
self.assertIn(("TLS 1.2: сброс", "fail"), first.chips)
self.assertIn(("блокировка по имени", "fail"), first.chips)
# Сайт без проверки по протоколам остаётся с метками, как раньше.
[plain] = [card for card in build_cards({"services": [_service("x", "X", "fail", [_target("x.com", ok=False)])]}) if card.site]
self.assertEqual(plain.marks, ())
grid = CardsGrid(250)
self.addCleanup(grid.deleteLater)
grid.resize(640, 10)
grid.show()
grid.show_cards([first, second], animate=False)
one, two = grid.cards()
self.app.processEvents()
# Одна и та же дорога стоит на одном месте у обеих карточек.
for index in range(6):
self.assertEqual((one.mark_rect(index).top(), one.mark_rect(index).left()), (two.mark_rect(index).top(), two.mark_rect(index).left()))
self.assertEqual(one.mark_rect(0).top(), one.mark_rect(1).top())
self.assertLess(one.mark_rect(1).top(), one.mark_rect(2).top())
self.assertEqual(one.height(), two.height())
one.grab()
def test_cards_appear_as_the_check_goes_and_shown_ones_are_not_rebuilt(self) -> None:
view = ResultCardsView()
self.addCleanup(view.deleteLater)
view.resize(900, 600)
view.show()
first = _service("discord", "Discord", "ok", [_target("discord.com")])
second = _service("youtube", "YouTube", "fail", [_target("www.youtube.com", ok=False)])
empty = {"partial": True, "services": [], "freeze": None, "voice": None, "system": None}
view.show_partial(empty)
self.assertFalse(view.has_cards())
view.show_partial({**empty, "services": [first]})
[discord] = view.sites_grid.cards()
self.assertTrue(view.has_cards())
# Счётчики «что проверено» ждут итога.
self.assertEqual(view.counters.tiles(), [])
view.show_partial({**empty, "services": [first, second]})
# Пока проверка идёт, показанная карточка стоит на месте (и это тот же виджет), новая — следом,
# хотя сайт с проблемой в итоге встанет первым.
kept, youtube = view.sites_grid.cards()
self.assertIs(kept, discord)
self.assertEqual(youtube.card.title, "YouTube")
self.assertGreater(youtube.x(), kept.x())
# Сайт перепроверили, и он открылся: его карточка заменилась, соседняя не тронута.
fixed = _service("youtube", "YouTube", "ok", [_target("www.youtube.com")])
view.show_partial({**empty, "services": [first, fixed]})
by_key = {widget.card.key: widget for widget in view.sites_grid.cards()}
self.assertIs(by_key["site:discord"], discord)
self.assertIsNot(by_key["site:youtube"], youtube)
self.assertEqual(by_key["site:youtube"].card.level, "ok")
# Итог: карточки сайтов остаются те же, добавляются разделы и счётчики.
final = {**_REPORT, "services": [first, fixed], "partial": False}
view.show_report(final, animate=False)
after = {widget.card.key: widget for widget in view.sites_grid.cards()}
self.assertIs(after["site:discord"], discord)
self.assertIs(after["site:youtube"], by_key["site:youtube"])
self.assertTrue(view.checks_grid.cards())
self.assertTrue(view.counters.tiles())
# Порядок «проблемные первыми» наводит итог.
view.show_report({**final, "services": [first, second]}, animate=False)
self.assertEqual([widget.card.title for widget in view.sites_grid.cards()], ["YouTube", "Discord"])
# Новая проверка начинает с чистого места.
view.clear()
self.assertFalse(view.has_cards())
self.assertEqual(view.sites_grid.cards(), [])
def test_site_road_shows_where_the_connection_is_cut(self) -> None:
from blockcheck.ui.site_road import SiteRoad, site_road
def proto(title: str, state: str, word: str) -> dict:
return {"key": title, "title": title, "state": state, "word": word, "text": word}
def card(key: str, level: str, kind: str, words: tuple[str, str, str, str], **extra):
states = ["ok" if word == "проходит" else "fail" for word in words]
protocols = [proto(title, state, word) for title, state, word in zip(("TLS 1.2", "TLS 1.3", "Как Chrome", "HTTP"), states, words)]
target = _target(f"{key}.com", ok=level == "ok", main=True, protocols=protocols, quic="ok", dns_state="ok", **extra)
[built] = [item for item in build_cards({"services": [_service(key, key.title(), level, [target], kind=kind)]}) if item.site]
return built
by_name = card("instagram", "fail", "sni", ("сброс", "сброс", "проходит", "проходит"))
stages, broken = site_road(by_name)
self.assertEqual([stage.title for stage in stages], ["Вы", "DNS", "Сервер", "TLS", "Сайт"])
# Режут на TLS: до него всё зелёное, сам он красный, до сайта не дошли.
self.assertEqual(broken, 3)
self.assertEqual([stage.state for stage in stages], ["ok", "ok", "ok", "fail", "unknown"])
self.assertEqual((stages[3].word, stages[4].word), ("сброс", "не дошли"))
by_address = card("telegram", "fail", "ip", ("нет соединения",) * 4)
stages, broken = site_road(by_address)
self.assertEqual((broken, stages[2].word, stages[3].word), (2, "адрес закрыт", "не дошли"))
opens = card("youtube", "ok", "", ("проходит",) * 4)
stages, broken = site_road(opens)
self.assertEqual((broken, [stage.state for stage in stages]), (-1, ["ok"] * 5))
view = ResultDetailView()
self.addCleanup(view.deleteLater)
view.resize(1000, 600)
view.show()
view.show_card(by_name)
self.assertIsInstance(view.road, SiteRoad)
# Точка доезжает до места обрыва, а не до сайта.
self.assertEqual(view.road.last_reached(), 3)
self.assertLess(view.road.node_center(0).x(), view.road.node_center(4).x())
view.road.grab()
# У отчёта не про сайт дороги нет.
view.show_card(Card("hostings", "fa5s.server", "Хостинги", "ok", "В порядке"))
self.assertIsNone(view.road)
def test_card_shows_few_lines_and_says_how_many_are_hidden(self) -> None:
view = ResultCardsView()
self.addCleanup(view.deleteLater)
many = {"system": [{"title": f"Пункт {n}", "level": "ok", "text": "да"} for n in range(PREVIEW_LINES + 3)]}
view.show_report(many, animate=False)
card = view.card("system")
self.assertEqual(len(card.shown_lines()), PREVIEW_LINES)
self.assertIn("и ещё 3", card.more_text())
# Карточку рисует один виджет: надписей-виджетов на каждую строку в ней нет.
self.assertEqual(card.findChildren(QWidget), [])
self.assertEqual(card.height_for(330), card.heightForWidth(330))
self.assertGreater(card.height_for(330), PREVIEW_LINES * card.LINE)
card.resize(330, card.height_for(330))
card.grab()
# Тот же итог повторно (вернулись на страницу) карточки не пересобирает.
view.show_report(many, animate=False)
self.assertIs(view.card("system"), card)
self.assertEqual(card.accessibleName(), f"Этот компьютер: В порядке: {PREVIEW_LINES + 3} из {PREVIEW_LINES + 3}")
def test_click_and_enter_open_the_card(self) -> None:
view = ResultCardsView()
self.addCleanup(view.deleteLater)
view.resize(900, 600)
view.show()
view.show_report(_REPORT, animate=False)
opened = []
view.opened.connect(opened.append)
QTest.mouseClick(view.card("voice"), Qt.MouseButton.LeftButton)
QTest.keyClick(view.card("site:x"), Qt.Key.Key_Return)
self.assertEqual([card.key for card in opened], ["voice", "site:x"])
def test_view_separates_sites_from_other_checks_and_clears(self) -> None:
view = ResultCardsView()
self.addCleanup(view.deleteLater)
view.show_report(_REPORT, animate=False)
self.assertEqual(len(view.sites_grid.cards()), 4)
self.assertEqual(len(view.checks_grid.cards()), 7)
self.assertEqual(view.accessibleName(), "Результаты BlockCheck: карточек 11, с проблемами 7")
self.assertEqual(len(view.counters.tiles()), len(build_counters(_REPORT)))
view.clear()
self.assertEqual(view.cards(), [])
self.assertEqual(view.counters.tiles(), [])
def test_hosting_dots_wrap_and_give_a_hint_per_server(self) -> None:
card = {c.key: c for c in build_cards(_REPORT)}["hostings"]
dots = HostingDots(card.dots)
self.addCleanup(dots.deleteLater)
self.assertGreater(dots.heightForWidth(90), dots.heightForWidth(900))
dots.resize(900, dots.heightForWidth(900))
dots.grab()
hints = [hint for _rect, hint in dots._hits]
self.assertEqual(len(hints), 4)
rect = dots._hits[0][0]
self.assertEqual(dots.hint_at(rect.center().x(), rect.center().y()), hints[0])
self.assertEqual(dots.hint_at(-5, -5), "")
self.assertEqual(dots.accessibleName(), "Серверов хостингов: 4, с обрывом: 2")
def test_progress_steps_count_and_hide_steps_of_the_full_check(self) -> None:
steps = ProgressSteps()
self.addCleanup(steps.deleteLater)
steps.start(("sites", "hostings", "voice"))
self.assertTrue(steps.rows["filter"].isHidden())
self.assertFalse(steps.rows["hostings"].isHidden())
self.assertEqual(steps.rows["hostings"].count_label.text(), "ждёт")
steps.set_progress("hostings", 23, 59)
steps.set_progress("voice", 1, 1)
steps.set_progress("нет такого", 1, 1)
self.assertEqual(steps.rows["hostings"].count_label.text(), "23 / 59")
self.assertEqual(steps.rows["hostings"].bar.value(), 23)
self.assertEqual(steps.rows["voice"].count_label.text(), "готово")
self.assertEqual(steps.accessibleName(), "Ход BlockCheck: готово шагов 1 из 3")
steps.start(("sites",))
self.assertEqual(steps.rows["voice"].count_label.text(), "ждёт")
def test_progress_rows_show_how_long_each_step_took(self) -> None:
# Секунды приходят из отчёта проверки (step_seconds), а не считаются экраном.
steps = ProgressSteps()
self.addCleanup(steps.deleteLater)
steps.start(("sites", "hostings", "voice"))
steps.set_progress("sites", 45, 45)
steps.set_progress("hostings", 3, 57)
steps.set_seconds({"sites": 23.4, "hostings": 5.0, "voice": 9.0, "нет такого": 1.0})
self.assertEqual(steps.rows["sites"].count_label.text(), "45 / 45 · 23 с")
self.assertEqual(steps.rows["hostings"].count_label.text(), "3 / 57 · 5 с")
# Шаг ещё не начинался: секунд у него на экране нет.
self.assertEqual(steps.rows["voice"].count_label.text(), "ждёт")
steps.set_progress("voice", 1, 1)
self.assertEqual(steps.rows["voice"].count_label.text(), "готово · 9 с")
steps.start(("sites",))
steps.set_progress("sites", 0, 45)
self.assertEqual(steps.rows["sites"].count_label.text(), "0 / 45")
def test_run_card_shows_step_times_conditions_and_the_registry_list(self) -> None:
report = {
"step_seconds": {"sites": 95.0, "hostings": 30.2},
"environment": ["🖥️ Windows 10"],
"zapret_line": "✅ Zapret запущен",
"other_bypass_tools": ["sing-box"],
"registry": {"state": "fresh", "updated": 1791454670.0, "hosts": 1709109, "networks": 69858},
}
[card] = build_cards(report)
self.assertEqual((card.key, card.title, card.level), ("run", "Ход проверки", "warn"))
self.assertIn("сайты, 95 с", card.status)
self.assertEqual([section.title for section in card.sections], ["Время по шагам", "Условия проверки", "Список реестра РКН"])
self.assertEqual([(line.name, line.text) for line in card.sections[0].lines], [("Сайты", "95 с"), ("Хостинги", "30 с")])
self.assertIn("sing-box", card.sections[1].lines[-1].text)
self.assertEqual(card.sections[2].lines[1].text, "1 709 109")
self.assertEqual(build_cards({"partial": True}), [])
def test_detail_view_shows_sections_and_closes_by_breadcrumb_and_escape(self) -> None:
detail = ResultDetailView()
self.addCleanup(detail.deleteLater)
card = {c.key: c for c in build_cards(_REPORT)}["hostings"]
closed = []
detail.closed.connect(lambda: closed.append(True))
detail.show_card(card)
self.assertEqual(detail.title_label.text(), "Зарубежные хостинги")
self.assertEqual(len(detail.blocks), len(card.sections))
self.assertEqual(detail.breadcrumb.count(), 2)
detail._on_breadcrumb(detail.CARD_KEY)
self.assertEqual(closed, [])
detail._on_breadcrumb(detail.ROOT_KEY)
QTest.keyClick(detail, Qt.Key.Key_Escape)
self.assertEqual(closed, [True, True])
detail.copy_button.click()
self.assertEqual(QApplication.clipboard().text(), card_plain_text(card))
# Повторный показ не копит разделы прошлой карточки.
detail.show_card({c.key: c for c in build_cards(_REPORT)}["ipv6"])
self.assertEqual(len(detail.blocks), 1)
class _FeatureStub:
pass
class PageCardsTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def _page(self) -> BlockcheckPage:
with patch.object(BlockcheckPage, "_request_page_initial_state_load", lambda self: None):
page = BlockcheckPage(
blockcheck_feature=_FeatureStub(),
dns_feature=_FeatureStub(),
create_strategy_scan_worker=lambda *_args, **_kwargs: None,
)
self.addCleanup(page.deleteLater)
return page
def test_sites_are_first_and_selected(self) -> None:
page = self._page()
self.assertEqual(page._scope_combo.currentIndex(), 0)
self.assertEqual(page._current_scope(), SCOPE_ALL)
self.assertEqual(page._scope_combo.itemText(2), "Глубокая диагностика (около минуты)")
self.assertEqual([page._scope_combo.itemData(index) for index in range(3)], ["all", "main", "full"])
# Смена языка расставляет названия в том же порядке.
page.set_ui_language("ru")
self.assertEqual(page._scope_combo.itemText(0), "Только сайты (быстро)")
self.assertEqual(page._scope_combo.itemText(2), "Глубокая диагностика (около минуты)")
def test_card_opens_details_over_the_page_and_closing_brings_it_back(self) -> None:
page = self._page()
page._report_lines = ["x"]
page._on_finished({**_REPORT, "problems": [], "working": []})
self.assertTrue(page._progress_card.isHidden())
page.resize(900, 420)
page.show()
QApplication.processEvents()
bar = page.verticalScrollBar()
bar.setValue(min(180, bar.maximum()))
was_at = bar.value()
page._open_card_detail(page._result_cards.card("hostings").card)
self.assertFalse(page._detail_view.isHidden())
self.assertTrue(page._tabs_pivot.isHidden())
self.assertTrue(all(widget.isHidden() for widget in page._tab_widgets))
# На подстранице первой идёт строка пути: название и описание раздела скрыты.
# Заголовок стоит в ряду со ссылкой на инструкцию — прячется весь ряд, поэтому смотрим, виден ли он.
self.assertFalse(page.title_label.isVisible())
self.assertTrue(page.subtitle_label.isHidden())
self.assertEqual(bar.value(), 0)
page._detail_view.closed.emit()
QApplication.processEvents()
self.assertTrue(page._detail_view.isHidden())
self.assertFalse(page._tabs_pivot.isHidden())
self.assertTrue(page.title_label.isVisible())
self.assertFalse(page.subtitle_label.isHidden())
# Возврат — к тому месту списка, с которого уходили.
self.assertGreater(was_at, 0)
self.assertEqual(bar.value(), was_at)
# Длинный текст из отчёта карточки открывается страницей-редактором.
page._open_card_by_key("dns_servers")
page._detail_view.text_opened.emit("Все серверы", "строка 1\nстрока 2")
self.assertFalse(page._log_report_view.isHidden())
self.assertTrue(page._detail_view.isHidden())
self.assertEqual(page._log_report_view.report().title, "Все серверы")
page._escape_shortcut.activated.emit()
self.assertTrue(page._log_report_view.isHidden())
# Строка итога открывает ту же страницу по ключу карточки, а Esc закрывает её с любого места.
page._open_card_by_key("hostings")
self.assertFalse(page._detail_view.isHidden())
page._open_card_by_key("нет такой")
self.assertEqual(page._escape_shortcut.context(), Qt.ShortcutContext.WidgetWithChildrenShortcut)
page._escape_shortcut.activated.emit()
self.assertTrue(page._detail_view.isHidden())
self.assertFalse(page._tabs_pivot.isHidden())
self.assertFalse(page._results_card.isHidden())
self.assertTrue(page._progress_card.isHidden())
def test_window_history_gets_tabs_details_and_reports_as_separate_screens(self) -> None:
page = self._page()
page._report_lines = ["x"]
page._on_finished({**_REPORT, "problems": [], "working": []})
page.show()
QApplication.processEvents()
seen = []
page.navigation_screen_changed.connect(lambda: seen.append(page.navigation_screen().key))
self.assertEqual(page.navigation_screen().key, "tab:blockcheck")
self.assertEqual(page.navigation_screen().title, "")
page._open_card_by_key("hostings")
card_screen = page.navigation_screen()
self.assertEqual(card_screen.key, "card:hostings")
self.assertEqual(card_screen.title, page._result_cards.card("hostings").card.title)
page._open_report()
report_screen = page.navigation_screen()
self.assertEqual(report_screen.key, "report:Подробный отчёт BlockCheck")
page._escape_shortcut.activated.emit()
self.assertEqual(page.navigation_screen().key, "tab:blockcheck")
page.switch_to_tab(page.TAB_DNS_SPOOFING)
tab_screen = page.navigation_screen()
self.assertEqual(tab_screen.key, "tab:dns_spoofing")
self.assertEqual(tab_screen.title, "DNS подмена")
self.assertEqual(seen[-1], "tab:dns_spoofing")
self.assertIn("card:hostings", seen)
self.assertIn(report_screen.key, seen)
# «Назад» окна: журнал просит открыть записанный экран заново.
self.assertTrue(page.restore_navigation_screen(card_screen))
self.assertFalse(page._detail_view.isHidden())
self.assertEqual(page.TAB_ORDER[page._active_tab_index], page.TAB_BLOCKCHECK)
self.assertEqual(page.navigation_screen().key, "card:hostings")
self.assertTrue(page.restore_navigation_screen(report_screen))
self.assertFalse(page._log_report_view.isHidden())
self.assertTrue(page._detail_view.isHidden())
self.assertEqual(page._log_report_view.report().title, "Подробный отчёт BlockCheck")
self.assertTrue(page.restore_navigation_screen(tab_screen))
self.assertTrue(page._log_report_view.isHidden())
self.assertFalse(page._tabs_pivot.isHidden())
self.assertEqual(page.navigation_screen().key, "tab:dns_spoofing")
self.assertFalse(page.restore_navigation_screen(type(tab_screen)(key="tab:нет такой")))
self.assertFalse(page.restore_navigation_screen(type(tab_screen)(key="card:hostings")))
def test_back_to_a_card_shows_the_current_check_not_an_old_snapshot(self) -> None:
page = self._page()
page._report_lines = ["x"]
page._on_finished({**_REPORT, "problems": [], "working": []})
page.show()
QApplication.processEvents()
page._open_card_by_key("hostings")
card_screen = page.navigation_screen()
old_card = page._detail_view.card()
page._escape_shortcut.activated.emit()
# Новый запуск проверки заменил отчёт: «назад» к карточке показывает свежие данные.
page._on_finished({**_REPORT, "problems": [], "working": []})
widget = page._result_cards.card("hostings")
widget.card = replace(widget.card, status="Обрыв у 0 из 4")
self.assertTrue(page.restore_navigation_screen(card_screen))
self.assertEqual(page._detail_view.card().status, "Обрыв у 0 из 4")
self.assertNotEqual(old_card.status, "Обрыв у 0 из 4")
# Карточки больше нет в отчёте — экран честно недоступен.
page._escape_shortcut.activated.emit()
self.assertFalse(page.restore_navigation_screen(type(card_screen)(key="card:нет такой", payload=("blockcheck", None))))
def test_step_inside_a_card_report_is_a_screen_of_the_window_history(self) -> None:
page = self._page()
page._report_lines = ["x"]
page._on_finished({**_REPORT, "problems": [], "working": []})
page.show()
QApplication.processEvents()
page._open_card_by_key("hostings")
detail = page._detail_view
child = next(card for block in detail.blocks for card in block.child_cards())
steps, returns = [], []
page.navigation_screen_changed.connect(lambda: steps.append(page.navigation_screen().key))
page.navigation_screen_returned.connect(lambda: returns.append(page.navigation_screen().key))
# Шаг вглубь: сервер из списка хостингов — новый экран журнала.
detail.open_child(child)
deep = page.navigation_screen()
self.assertEqual(deep.key, f"card:hostings>{child.key}")
self.assertEqual(steps, [deep.key])
# Esc возвращает к списку — для журнала это шаг «назад», а не новый.
page._escape_shortcut.activated.emit()
self.assertEqual(returns, ["card:hostings"])
self.assertFalse(detail.isHidden())
# Из журнала шаг вглубь открывается заново, даже с другой вкладки.
page.switch_to_tab(page.TAB_DNS_SPOOFING)
self.assertTrue(page.restore_navigation_screen(deep))
self.assertEqual(detail.card().key, child.key)
self.assertEqual(page.navigation_screen().key, deep.key)
# Такого сервера в отчёте нет — экран недоступен.
gone = type(deep)(key="card:hostings>нет такого", payload=deep.payload)
self.assertFalse(page.restore_navigation_screen(gone))
def test_report_opened_from_a_tab_is_rebuilt_from_fresh_data(self) -> None:
page = self._page()
page._report_lines = ["первый запуск"]
page.show()
QApplication.processEvents()
page._open_report()
screen = page.navigation_screen()
self.assertEqual(screen.key, "tabreport:blockcheck")
page._escape_shortcut.activated.emit()
page._report_lines = ["второй запуск"]
self.assertTrue(page.restore_navigation_screen(screen))
self.assertIn("второй запуск", page._log_report_view.report().text)
def test_live_server_details_that_are_gone_are_not_reopened(self) -> None:
page = self._page()
page._dns_servers_tab_page = SimpleNamespace(live_details=lambda _server: None)
self.assertIsNone(page._screen_opener("server", "9.9.9.9", (object(), True)))
def test_escape_on_a_tab_is_the_window_back_step(self) -> None:
page = self._page()
page.show()
QApplication.processEvents()
steps = []
page.set_navigation_back(lambda: steps.append("back") or True)
page._escape_shortcut.activated.emit()
self.assertEqual(steps, ["back"])
def test_progress_reaches_the_steps_widget(self) -> None:
page = self._page()
page._on_progress("hostings", 5, 59)
self.assertEqual(page._progress_steps.rows["hostings"].count_label.text(), "5 / 59")
class EveryHostingTests(unittest.TestCase):
TARGETS = (
{"id": "A-1", "provider": "AWS", "url": "https://a.example/file"},
{"id": "A-2", "provider": "AWS", "url": "https://b.example/file"},
{"id": "H-1", "provider": "Old", "url": "http://plain.example/file"},
{"id": "C-1", "provider": "CDN", "url": "https://c.example/file"},
)
def test_every_https_address_is_its_own_check(self) -> None:
picked = every_freeze_target(self.TARGETS)
self.assertEqual([(provider, [item["id"] for item in items]) for provider, items in picked], [
("AWS", ["A-1"]), ("AWS", ["A-2"]), ("CDN", ["C-1"]),
])
def _run(self, *, every: bool):
def download(host, _path):
if host == "a.example":
return ProbeResult(ip="1.1.1.1", kind="ok", body_size=16_500, body_cut=True)
if host == "b.example":
return ProbeResult(ip="1.1.1.2", kind="ok", body_size=65_536)
return None
seen = []
with ThreadPoolExecutor(max_workers=8) as pool, patch("blockcheck.data_lists.TCP_16_20_TARGETS", self.TARGETS):
servers = check_freeze(
pool.submit,
lambda future: future.result(),
download,
every=every,
on_server=lambda server, done, total: seen.append((server.ident, done, total)),
)
return servers, seen
def test_full_check_reports_each_server_with_provider_and_progress(self) -> None:
servers, seen = self._run(every=True)
self.assertEqual([(s.provider, s.ident, s.host, s.state) for s in servers], [
("AWS", "A-1", "a.example", FreezeState.FREEZE),
("AWS", "A-2", "b.example", FreezeState.OK),
("CDN", "C-1", "c.example", FreezeState.UNKNOWN),
])
self.assertEqual(sorted(done for _ident, done, _total in seen), [1, 2, 3])
self.assertEqual({total for _ident, _done, total in seen}, {3})
def test_quick_check_still_takes_one_server_per_provider_with_spares(self) -> None:
servers, seen = self._run(every=False)
self.assertEqual([(s.provider, s.ident) for s in servers], [("AWS", "A-1"), ("CDN", "C-1")])
self.assertEqual({total for _ident, _done, total in seen}, {2})
def test_full_check_limits_simultaneous_servers(self) -> None:
import threading
import time
targets = tuple({"id": f"S-{n}", "provider": "P", "url": f"https://h{n}.example/f"} for n in range(12))
lock = threading.Lock()
active = [0]
peak = [0]
def download(_host, _path):
with lock:
active[0] += 1
peak[0] = max(peak[0], active[0])
time.sleep(0.03)
with lock:
active[0] -= 1
return None
# Предел держит очередь прогона: ждущий сервер не занимает поток.
from diagnostics.run_context import Run
run = Run(None, workers=12)
self.addCleanup(run.close)
with patch("blockcheck.data_lists.TCP_16_20_TARGETS", targets):
servers = check_freeze(run.lane(3).submit, lambda future: future.result(), download, every=True)
self.assertEqual(len(servers), 12)
self.assertLessEqual(peak[0], 3)
if __name__ == "__main__":
unittest.main()