zapretgui/tests/test_strategy_search_engine.py
loop-uh 75112b8a56
All checks were successful
Zapret source guards / validate (push) Successful in 7s
Подбор стратегии: при повторном запуске выбор — продолжить или начать заново
Подбор помнит стратегии, которые недавно (14 дней) не сработали на цели, и ставит их в конец очереди, поэтому после остановки новый запуск шёл дальше по списку без спроса. Теперь, если для цели уже есть проверенные стратегии, при нажатии «Старт» появляется окно: «Продолжить с места остановки», «Начать заново» или «Отмена». «Начать заново» строит очередь без учёта прошлых провалов, как в первый раз; история при этом не стирается и пополняется итогами нового прохода. При первом запуске окно не показывается.

Выбор проходит страница → start_strategy_scan_run → StrategyScanWorker → SearchRequest.from_start → порядок кандидатов в движке. Текст после остановки подбора обновлён. Тесты: порядок очереди с историей и без, подсчёт недавних провалов, передача выбора до движка, кнопки окна на настоящей странице.
2026-09-29 20:35:40 +03:00

547 lines
22 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.

"""Порядок работы подбора стратегии на подставной сети и подставном winws2.
Каждый тест описывает сеть функцией «ответ на проверку при такой-то
запущенной стратегии» и смотрит, что подбор решил и сообщил.
"""
from __future__ import annotations
import unittest
from dataclasses import dataclass
from blockcheck.strategy_search import verdict as rules
from blockcheck.strategy_search.engine import SearchRequest, StrategySearch
from blockcheck.strategy_search.history import TargetHistory
from blockcheck.strategy_search.ordering import Candidate
from blockcheck.strategy_search.verdict import ProbeOutcome
from blockcheck.strategy_search.winws_session import SessionStart
OK = ProbeOutcome(rules.PROBE_OK, "HTTP 200", 120.0)
RESET = ProbeOutcome(rules.PROBE_BLOCKED, "соединение сброшено", 30.0)
TIMEOUT = ProbeOutcome(rules.PROBE_BLOCKED, "нет шифрования (таймаут)", 4000.0)
NO_CONNECT = ProbeOutcome(rules.PROBE_UNREACHABLE, "сервер не отвечает на подключение", 4000.0)
def cand(strategy_id: str, function: str = "fake") -> Candidate:
return Candidate(strategy_id, strategy_id, f"--lua-desync={function}:id={strategy_id}")
def args_id(args: str | None) -> str | None:
"""Кто сейчас запущен: id стратегии, "pass" или None (winws2 не запущен)."""
if args is None:
return None
if args.strip() == "--lua-desync=pass":
return "pass"
return args.split("id=", 1)[1].split()[0] if "id=" in args else args
class FakeSession:
def __init__(self, env: "FakeEnv", config_text: str) -> None:
self.env = env
self.config_text = config_text
self.strategy = next(
(line.split("id=", 1)[1] for line in config_text.splitlines() if "id=" in line),
"pass" if "--lua-desync=pass" in config_text else None,
)
def start(self):
if self.env.start_fails.get(self.strategy, 0) > 0:
self.env.start_fails[self.strategy] -= 1
return SessionStart(False, exit_code=1, output_tail="lua error")
self.env.running = self.strategy
return SessionStart(True, ready_ms=100.0)
def alive(self) -> bool:
if self.env.die_during_probe.get(self.strategy, 0) > 0:
self.env.die_during_probe[self.strategy] -= 1
return False
return True
def stop(self) -> bool:
self.env.running = None
self.env.stops += 1
return True
def output_tail(self, lines: int = 6) -> str:
return "crash"
@dataclass
class FakeEnv:
network: object = None # (running_id, call_number) -> ProbeOutcome
candidates: list | None = None
addresses: tuple = ("1.1.1.10", "1.1.1.11")
resolve_error: str = ""
control: list | None = None # последовательность ответов control_alive
port80_open: bool = False
control_default: bool = True
def __post_init__(self):
self.running = None
self.sessions: list[FakeSession] = []
self.calls: dict = {}
self.start_fails: dict = {}
self.die_during_probe: dict = {}
self.stops = 0
self.pre_cleaned = False
self.post_cleaned = False
self.recovered = 0
self.history_saved = None
self.slept = 0.0
self.clock = 0.0
self.control = list(self.control or [])
# каталог и история
def load_candidates(self, scan_protocol):
return list(self.candidates or [])
def load_history(self, key):
return TargetHistory()
def record_history(self, key, *, confirmed, failed):
self.history_saved = (key, list(confirmed), list(failed))
def blob_lines(self, strategy_args):
return []
def games_ipset_paths(self, scope):
return ["lists/ipset-roblox.txt"]
# процессы
def pre_cleanup(self):
self.pre_cleaned = True
def post_cleanup(self):
self.post_cleaned = True
def start_session(self, config_text):
session = FakeSession(self, config_text)
self.sessions.append(session)
return session
def recover_after_crash(self):
self.recovered += 1
def strategy_pause_seconds(self):
return 0.0
# сеть
def control_alive(self):
return self.control.pop(0) if self.control else self.control_default
def resolve(self, host, port, *, udp):
if self.resolve_error:
return [], self.resolve_error
return list(self.addresses), ""
def _answer(self):
key = self.running
number = self.calls.get(key, 0)
self.calls[key] = number + 1
return self.network(key, number)
def probe_https(self, host, addresses):
outcome = self._answer()
return [outcome for _address in addresses]
def probe_udp(self, specs):
return [(spec, self.network(self.running, spec)) for spec in specs]
def tcp_port_open(self, address, port):
return self.port80_open
def cancel_probes(self):
pass
# время
def monotonic(self):
self.clock += 0.01
return self.clock
def wall_time(self):
return 1_000_000.0
def sleep(self, seconds):
self.slept += seconds
self.clock += seconds
class FakeEvents:
def __init__(self, answer: bool = False) -> None:
self.answer = answer
self.questions: list[str] = []
self.results = []
self.logs: list[str] = []
self.cancel_after_results: int | None = None
self.started_args: list[str] = []
self.stages: list[tuple[str, str]] = []
def log(self, message):
self.logs.append(message)
def phase(self, text):
pass
def strategy_started(self, name, index, total, args=""):
self.started_args.append(args)
def stage(self, step, status, text=""):
self.stages.append((step, status))
def strategy_result(self, result):
self.results.append(result)
def ask_continue(self, reason):
self.questions.append(reason)
return self.answer
def is_cancelled(self):
return self.cancel_after_results is not None and len(self.results) >= self.cancel_after_results
def run(env: FakeEnv, events: FakeEvents | None = None, **request):
events = events or FakeEvents()
params = {"target": "discord.com", "mode": "quick"}
params.update(request)
report = StrategySearch(SearchRequest(**params), env=env, events=events).run()
return report, events
def blocked_unless(*working: str):
"""Цель закрыта (сброс), пока не запущена одна из рабочих стратегий."""
def network(running, _number):
return OK if running in working else RESET
return network
class BaselineTests(unittest.TestCase):
def test_open_without_bypass_asks_and_stops_when_declined(self) -> None:
env = FakeEnv(network=lambda running, n: OK, candidates=[cand("a"), cand("b")])
report, events = run(env, FakeEvents(answer=False))
self.assertEqual(len(events.questions), 1)
self.assertIn("без обхода", events.questions[0])
self.assertTrue(report.baseline_accessible)
self.assertEqual(report.total_tested, 0)
self.assertEqual(env.sessions, [])
self.assertTrue(env.post_cleaned)
def test_open_without_bypass_continue_tests_all_without_controls(self) -> None:
env = FakeEnv(network=lambda running, n: OK, candidates=[cand("a"), cand("b")])
report, events = run(env, FakeEvents(answer=True))
self.assertTrue(report.baseline_accessible)
self.assertEqual(report.total_tested, 2)
# Ни контрольного pass, ни обратной проверки: цель и так открыта.
self.assertEqual([session.strategy for session in env.sessions], ["a", "b"])
self.assertTrue(all(result.raw_data.get("forced") for result in events.results))
def test_connect_refused_everywhere_is_not_a_dpi_block(self) -> None:
env = FakeEnv(network=lambda running, n: NO_CONNECT, candidates=[cand("a")], port80_open=True)
report, _events = run(env)
self.assertIn("порт 80 открыт", report.fatal_error)
self.assertIn("не снимают", report.fatal_error)
self.assertEqual(env.sessions, [])
self.assertTrue(report.cancelled)
def test_unresolvable_target_stops(self) -> None:
env = FakeEnv(network=lambda running, n: OK, candidates=[cand("a")], resolve_error="нет такого имени")
report, _events = run(env)
self.assertIn("нет такого имени", report.fatal_error)
self.assertEqual(env.sessions, [])
def test_dns_stub_address_stops_with_dns_advice(self) -> None:
env = FakeEnv(network=lambda running, n: RESET, candidates=[cand("a")], addresses=("195.82.146.214",))
report, _events = run(env)
self.assertIn("Настройка DNS", report.fatal_error)
self.assertEqual(env.sessions, [])
def test_no_internet_stops_before_anything(self) -> None:
env = FakeEnv(network=lambda running, n: RESET, candidates=[cand("a")], control=[False])
report, _events = run(env)
self.assertIn("Нет интернета", report.fatal_error)
self.assertEqual(env.sessions, [])
self.assertTrue(env.pre_cleaned)
self.assertTrue(env.post_cleaned)
def test_pass_control_runs_first_and_passing_pass_asks_user(self) -> None:
env = FakeEnv(network=blocked_unless("pass"), candidates=[cand("a")])
report, events = run(env, FakeEvents(answer=False))
self.assertEqual([session.strategy for session in env.sessions], ["pass"])
self.assertEqual(len(events.questions), 1)
self.assertIn("без приёмов обхода", events.questions[0])
self.assertEqual(report.total_tested, 0)
class StrategyVerdictTests(unittest.TestCase):
def test_working_strategy_needs_three_passes_and_carries_apply_lines(self) -> None:
env = FakeEnv(network=blocked_unless("good"), candidates=[cand("bad"), cand("good", "multisplit")])
report, events = run(env)
by_id = {result.strategy_id: result for result in events.results}
good = by_id["good"]
self.assertTrue(good.success)
self.assertEqual(good.verdict, rules.VERDICT_WORKING)
self.assertEqual((good.attempts_ok, good.attempts_total), (3, 3))
self.assertEqual(env.calls["good"], 3)
# «Применить» получит ровно проверенный профиль.
good_config = next(session.config_text for session in env.sessions if session.strategy == "good")
for line in good.apply_lines:
self.assertIn(line, good_config)
self.assertIn("--hostlist-domains=discord.com", good.apply_lines)
self.assertFalse(by_id["bad"].success)
self.assertEqual(by_id["bad"].apply_lines, ())
self.assertEqual([result.strategy_id for result in report.working_strategies], ["good"])
def test_strategy_that_fails_on_recheck_is_unstable_not_working(self) -> None:
def network(running, number):
if running == "flaky":
return OK if number == 0 else TIMEOUT
return RESET
env = FakeEnv(network=network, candidates=[cand("flaky")])
report, events = run(env)
result = events.results[0]
self.assertEqual(result.verdict, rules.VERDICT_UNSTABLE)
self.assertFalse(result.success)
self.assertEqual(report.working_strategies, [])
def test_success_is_not_counted_when_target_opens_without_bypass_afterwards(self) -> None:
state = {"flipped": False}
def network(running, number):
if running == "lucky":
state["flipped"] = True
return OK
if running is None and state["flipped"]:
return OK # сеть «починилась» сама — стратегия тут ни при чём
return RESET
env = FakeEnv(network=network, candidates=[cand("lucky")])
_report, events = run(env)
self.assertEqual(events.results[0].verdict, rules.VERDICT_NOT_COUNTED)
self.assertFalse(events.results[0].success)
def test_winws2_crash_during_probe_is_retried_not_counted_as_failure(self) -> None:
env = FakeEnv(network=blocked_unless("good"), candidates=[cand("good")])
env.die_during_probe["good"] = 1
_report, events = run(env)
self.assertEqual(events.results[0].verdict, rules.VERDICT_WORKING)
self.assertEqual(env.recovered, 1)
def test_strategy_that_never_starts_is_reported_as_crash(self) -> None:
env = FakeEnv(network=blocked_unless(), candidates=[cand("broken")])
env.start_fails["broken"] = 99
report, events = run(env)
self.assertEqual(events.results[0].verdict, rules.VERDICT_CRASH)
# Сбой winws2 не записывается в «не сработала».
self.assertEqual(env.history_saved[2], [])
self.assertEqual(report.working_strategies, [])
def test_network_drop_rechecks_strategy_instead_of_failing_it(self) -> None:
state = {"down": True}
def network(running, number):
if running == "good" and not state["down"]:
return OK
if running == "good":
state["down"] = False # первая проверка попала на пропавший интернет
return TIMEOUT
return RESET
# 1) перед подбором сеть есть; 2) после таймаута — нет; 3) через паузу вернулась.
env = FakeEnv(network=network, candidates=[cand("good")], control=[True, False, True])
_report, events = run(env)
self.assertEqual(len(events.results), 1)
self.assertEqual(events.results[0].verdict, rules.VERDICT_WORKING)
self.assertGreater(env.slept, 0)
def test_network_that_never_returns_stops_the_scan(self) -> None:
env = FakeEnv(
network=lambda running, n: TIMEOUT,
candidates=[cand("a"), cand("b")],
control=[True],
control_default=False,
)
report, events = run(env)
self.assertIn("пропал интернет", report.fatal_error)
self.assertEqual(events.results, [])
def test_reset_failure_does_not_waste_time_on_network_check(self) -> None:
env = FakeEnv(network=blocked_unless(), candidates=[cand("a")], control=[True, False])
_report, events = run(env)
# Сброс — след работы DPI: сеть жива, второй ответ control не спрашивался.
self.assertEqual(env.control, [False])
self.assertEqual(events.results[0].verdict, rules.VERDICT_FAILED)
class StageEventTests(unittest.TestCase):
def test_successful_scan_walks_all_steps(self) -> None:
env = FakeEnv(network=blocked_unless("good"), candidates=[cand("good")])
_report, events = run(env)
done = [step for step, status in events.stages if status == "done"]
self.assertEqual(done, ["network", "baseline", "control", "strategies"])
self.assertIn("--lua-desync=fake:id=good", events.started_args)
def test_stop_marks_running_step_failed_and_reports_kind(self) -> None:
env = FakeEnv(network=lambda running, n: NO_CONNECT, candidates=[cand("a")], port80_open=True)
report, events = run(env)
self.assertEqual(report.stop_kind, "address_block")
self.assertIn(("baseline", "failed"), events.stages)
def test_no_internet_kind(self) -> None:
env = FakeEnv(network=lambda running, n: RESET, candidates=[cand("a")], control=[False])
report, events = run(env)
self.assertEqual(report.stop_kind, "no_internet")
self.assertIn(("network", "failed"), events.stages)
class BatchTests(unittest.TestCase):
def test_whole_quick_batch_is_tested_even_after_finding_working(self) -> None:
candidates = [cand(f"s{i}", f"f{i % 5}") for i in range(40)]
env = FakeEnv(network=blocked_unless("s0", "s1", "s2", "s3"), candidates=candidates)
report, events = run(env)
self.assertEqual(report.total_available, 40)
self.assertEqual(report.total_tested, 30)
self.assertEqual(len(events.results), 30)
def test_history_records_confirmed_and_failed(self) -> None:
env = FakeEnv(network=blocked_unless("good"), candidates=[cand("good"), cand("bad", "multisplit")])
run(env)
key, confirmed, failed = env.history_saved
self.assertEqual(key, "tcp_https|discord.com")
self.assertEqual(confirmed, ["good"])
self.assertEqual(failed, ["bad"])
def test_recent_failures_go_last_unless_started_over(self) -> None:
candidates = [cand("a", "fake"), cand("b", "multisplit")]
class HistoryEnv(FakeEnv):
def load_history(self, key):
# «a» не сработала минуту назад — обычный запуск отодвигает её в конец.
return TargetHistory(failed={"a": 1_000_000.0 - 60})
_report, events = run(HistoryEnv(network=blocked_unless(), candidates=candidates))
self.assertEqual([result.strategy_id for result in events.results], ["b", "a"])
started_over = HistoryEnv(network=blocked_unless(), candidates=candidates)
_report, events = run(started_over, from_start=True)
self.assertEqual([result.strategy_id for result in events.results], ["a", "b"])
self.assertTrue(any("заново" in line for line in events.logs))
# История не стирается: итоги нового прохода дописываются как обычно.
self.assertEqual(started_over.history_saved[2], ["a", "b"])
def test_forced_scan_does_not_pollute_history(self) -> None:
env = FakeEnv(network=lambda running, n: OK, candidates=[cand("a")])
run(env, FakeEvents(answer=True))
self.assertIsNone(env.history_saved)
def test_pass_strategy_from_catalog_is_never_a_candidate(self) -> None:
env = FakeEnv(
network=blocked_unless(),
candidates=[Candidate("pass", "pass", "--lua-desync=pass"), cand("a")],
)
report, events = run(env)
self.assertEqual([result.strategy_id for result in events.results], ["a"])
self.assertEqual(report.total_available, 1)
def test_cancel_stops_and_cleans_up(self) -> None:
env = FakeEnv(network=blocked_unless(), candidates=[cand(f"s{i}") for i in range(5)])
events = FakeEvents()
events.cancel_after_results = 2
report, events = run(env, events)
self.assertTrue(report.cancelled)
self.assertEqual(len(events.results), 2)
self.assertTrue(env.post_cleaned)
self.assertIsNone(env.running)
def test_googlevideo_probe_host_differs_from_profile_domain(self) -> None:
env = FakeEnv(network=blocked_unless("good"), candidates=[cand("good")])
seen_hosts = []
original = env.probe_https
def probe_https(host, addresses):
seen_hosts.append(host)
return original(host, addresses)
env.probe_https = probe_https
_report, events = run(env, target="googlevideo.com", probe_host="rr1---sn-abc.googlevideo.com")
self.assertEqual(set(seen_hosts), {"rr1---sn-abc.googlevideo.com"})
self.assertIn("--hostlist-domains=googlevideo.com", events.results[0].apply_lines)
class UdpTests(unittest.TestCase):
def test_games_success_ignores_probes_open_without_bypass(self) -> None:
# Основная цель (Google STUN) открыта и без обхода, игровой сервер закрыт.
def network(running, spec):
if spec.primary:
return OK
if spec.name == "Rust A2S" and running == "good":
return OK
return TIMEOUT
env = FakeEnv(
network=network,
candidates=[cand("useless"), cand("good", "udplen")],
addresses=("9.9.9.9",),
)
report, events = run(env, target="stun.l.google.com:19302", scan_protocol="udp_games")
by_id = {result.strategy_id: result for result in events.results}
self.assertFalse(by_id["useless"].success)
self.assertTrue(by_id["good"].success)
profile = by_id["good"].apply_lines
self.assertIn("--ipset=lists/ipset-roblox.txt", profile)
self.assertIn("--ipset-ip=9.9.9.9", profile)
def test_games_profile_intercepts_every_probe_port(self) -> None:
env = FakeEnv(network=lambda running, spec: OK if running == "good" else TIMEOUT, candidates=[cand("good")])
_report, events = run(env, target="stun.cloudflare.com:3478", scan_protocol="udp_games")
lines = events.results[0].apply_lines
wf = next(line for line in lines if line.startswith("--wf-udp-out="))
flt = next(line for line in lines if line.startswith("--filter-udp="))
# Иначе winws2 не видит ни одного проверяемого пакета и стратегия ни при чём.
for port in (3478, 28015, 27015, 19132):
self.assertIn(str(port), wf.split("=", 1)[1].split(","))
self.assertIn(str(port), flt.split("=", 1)[1].split(","))
self.assertEqual(wf.split("=", 1)[1], flt.split("=", 1)[1])
def test_voice_requires_the_users_target(self) -> None:
def network(running, spec):
return OK if running == "good" else TIMEOUT
env = FakeEnv(network=network, candidates=[cand("good"), cand("bad", "udplen")], addresses=("8.8.8.8",))
_report, events = run(env, target="stun.l.google.com:19302", scan_protocol="stun_voice")
by_id = {result.strategy_id: result for result in events.results}
self.assertTrue(by_id["good"].success)
self.assertFalse(by_id["bad"].success)
self.assertIn("--filter-l7=stun,discord", by_id["good"].apply_lines)
if __name__ == "__main__":
unittest.main()