zapret-kvn/tests/test_app_singbox_routing_page.py
loop-uh e26419216a
Some checks failed
Windows project source guards / test (push) Failing after 1m12s
Программа «через VPN» — кроме российских сайтов; диагностика видит решения маршрута
Пользователь 0.8.36: VK и Яндекс перестали открываться, пока не добавил
прямые правила. В диагностике через туннель шли 29–39 % соединений к
российским IP (geoip-ru), но по ней нельзя было понять, каким правилом:
сайдкар видит только IP, а конфиг — уже исправленный. Шаблон и данные
маршрутизации в 0.8.30…0.8.36 не менялись (srs побайтно равны); причина —
правило пользователя, вставшее выше стокового «РФ напрямую».

- «Простое правило»: программа «Через VPN» по умолчанию «Кроме российских
  сайтов» — native logical and с {rule_set geosite-category-ru/geoip-ru (если
  есть в документе), domain_suffix ru/xn--p1ai/su, invert}; российское
  назначение идёт дальше по списку. Без галочки — предупреждение, что VK,
  Яндекс, банки уйдут в туннель. Семантика проверена ядром той же версии:
  `domain_suffix && !(domain_suffix=[ru xn--p1ai su]) => route(...)`.
- diagnostics/route_decisions: сводка решений маршрута по последним четырём
  сессиям (процесс, сайт из двух меток или IP, правило ядра «… => …»/final,
  маршрут, число соединений; отдельно российские сайты в туннеле) из того же
  опроса Clash API /connections, что метрики; журнал живёт у контроллера и
  переживает переподключение.
- runtime_redacted: route_history — правила route последних восьми запусков
  sing-box.
- Тесты: форма правила, explain_route (российские сайты мимо правила к
  стоковому direct), страница (галочка, предупреждение, «Напрямую»), сводка и
  история; docs/sing-box/routing-gui.md.

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

459 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Structured sing-box routing pages edit the native JSON and nothing else."""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import json
from pathlib import Path
import unittest
from PyQt6.QtCore import QCoreApplication, QEvent, Qt
from PyQt6.QtTest import QTest
from PyQt6.QtWidgets import QApplication
_app = QApplication.instance()
if _app is None:
_app = QApplication([])
elif not isinstance(_app, QApplication):
raise RuntimeError("A bare QCoreApplication already exists; UI tests need QApplication")
from xray_fluent.ui.configs_page import ConfigsPage # noqa: E402
from xray_fluent.ui.singbox.sections import ItemPage, count_references # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
TEMPLATE = ROOT / "data" / "templates" / "sing-box" / "default.json"
TEMPLATE_TEXT = TEMPLATE.read_text(encoding="utf-8")
LIST_SECTIONS = ("rules", "rule_sets", "dns", "outbounds", "system")
#: Shared page kept alive for the whole module (deleting widgets mid-run
#: crashes on Windows, see test_app_detail_pages).
_shared: dict[str, ConfigsPage] = {}
def _page() -> ConfigsPage:
if "page" not in _shared:
_shared["page"] = ConfigsPage()
page = _shared["page"]
page.show_section("overview")
page.set_document("singbox", Path("data/configs/sing-box/default.json"), TEMPLATE_TEXT)
page.set_template_source("singbox", TEMPLATE)
return page
def _section(page: ConfigsPage, key: str):
"""Show a routing sub-page the way the navigation does and return it."""
page.show_section(key)
return page._sections[key]
def _guide(open_guide):
"""Open a guide the way the link does and keep it for the module.
The dialog stays open (hidden mask) instead of being destroyed mid-run:
deleting widgets mid-run crashes on Windows, see test_app_detail_pages.
"""
from xray_fluent.ui.singbox.guide import GuideDialog
before = set(map(id, _shared.setdefault("guides", [])))
result = open_guide()
dialog = result if isinstance(result, GuideDialog) else None
if dialog is None:
page = _shared["page"]
dialog = next(d for d in page.findChildren(GuideDialog) if id(d) not in before)
dialog.finished.disconnect()
dialog.hide()
_shared["guides"].append(dialog)
return dialog
def _pump() -> None:
for _ in range(3):
QCoreApplication.processEvents()
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
def _object_lists(section):
from xray_fluent.ui.singbox.lists import ObjectList
return [view for view in section.root.body.findChildren(ObjectList) if view.isVisibleTo(section.root.body)]
class RoutingPageTests(unittest.TestCase):
def test_browsing_every_section_and_item_never_changes_the_json(self) -> None:
page = _page()
for key in LIST_SECTIONS + ("json", "overview"):
page.show_section(key)
_pump()
for key in LIST_SECTIONS:
section = _section(page, key)
for view in _object_lists(section):
for index in range(len(view._items())):
view.open_requested.emit(index)
_pump()
self.assertIsInstance(section.nav.currentWidget(), ItemPage)
section.nav.pop()
_pump()
self.assertFalse(page.is_dirty("singbox"))
self.assertEqual(page.session.text(), TEMPLATE_TEXT)
def test_edit_marks_dirty_and_undo_restores(self) -> None:
page = _page()
rules = _section(page, "rules")
rules.rules.open_requested.emit(4)
_pump()
item_page = rules.nav.currentWidget()
item_page.item["domain_keyword"] = ["example"]
item_page.form.changed.emit()
self.assertTrue(page.is_dirty("singbox"))
self.assertIn('"domain_keyword"', page.session.text())
item_page._undo()
self.assertFalse(page.is_dirty("singbox"))
rules.nav.pop()
def test_switching_action_drops_fields_the_new_action_rejects(self) -> None:
page = _page()
rules = _section(page, "rules")
rules.rules.open_requested.emit(4)
_pump()
item_page = rules.nav.currentWidget()
item_page.form._switch_variant("action", "reject")
_pump()
self.assertEqual(item_page.item.get("action"), "reject")
self.assertNotIn("outbound", item_page.item)
item_page._undo()
rules.nav.pop()
self.assertFalse(page.is_dirty("singbox"))
def test_new_rule_is_native_and_reorder_moves_it(self) -> None:
page = _page()
rules = _section(page, "rules")
before = len(rules.rules._items())
rules.rules._add(_first_option(rules.rules))
_pump()
rules.nav.pop()
document = json.loads(page.session.text())
self.assertEqual(len(document["route"]["rules"]), before + 1)
self.assertEqual(document["route"]["rules"][-1], {"action": "route", "outbound": "proxy"})
rules.rules._move(before, -1)
document = json.loads(page.session.text())
self.assertEqual(document["route"]["rules"][before - 1], {"action": "route", "outbound": "proxy"})
page.session.revert()
self.assertFalse(page.is_dirty("singbox"))
def test_program_through_vpn_defaults_to_except_russian_sites(self) -> None:
page = _page()
rules = _section(page, "rules")
simple = rules.open_simple_rule()
simple.set_target("proxy")
simple.editors["programs"].setPlainText("chrome")
simple._refresh()
self.assertFalse(simple.except_russian.isHidden())
self.assertTrue(simple.except_russian.isChecked())
rule = simple.parsed().rule
self.assertEqual((rule["type"], rule["mode"]), ("logical", "and"))
self.assertTrue(rule["rules"][1]["invert"])
self.assertIn("кроме российских сайтов", simple.preview.text())
simple.except_russian.setChecked(False)
_pump()
self.assertNotIn("type", simple.parsed().rule)
self.assertIn("включая российские", simple.russian_note.text())
simple.set_target("direct")
self.assertTrue(simple.except_russian.isHidden())
self.assertNotIn("type", simple.parsed().rule)
def test_simple_rule_writes_nothing_until_added_and_goes_on_top(self) -> None:
from xray_fluent.ui.singbox.simple_rule_page import SimpleRulePage
page = _page()
rules = _section(page, "rules")
before = page.session.text()
simple = rules.open_simple_rule()
self.assertIsInstance(rules.nav.currentWidget(), SimpleRulePage)
simple.set_target("direct")
simple.editors["sites"].setPlainText("https://www.2ip.ru/\n2ip.io")
self.assertEqual(page.session.text(), before)
self.assertFalse(page.is_dirty("singbox"))
applied: list[str] = []
checks: list[tuple[str, str]] = []
page.apply_requested.connect(lambda core, text: applied.append(text))
page.route_check_requested.connect(lambda host, text: checks.append((host, text)))
try:
simple._submit(apply=True)
finally:
page.apply_requested.disconnect()
page.route_check_requested.disconnect()
_pump()
self.assertIs(rules.nav.currentWidget(), rules.root)
document = json.loads(page.session.text())
self.assertEqual(
document["route"]["rules"][4],
{"domain_suffix": ["2ip.ru", "2ip.io"], "action": "route", "outbound": "direct"},
)
self.assertEqual(len(applied), 1)
self.assertEqual(json.loads(applied[0])["route"]["rules"][4]["outbound"], "direct")
self.assertEqual([host for host, _text in checks], ["https://www.2ip.ru/"])
from xray_fluent.singbox_config.route_explain import explain_route
verdict = explain_route(document, "2ip.ru", tun=False, match_set=lambda _d, _v: False)
page.show_route_check(verdict)
self.assertIn("Напрямую", rules.check_row.headline.text())
self.assertIn("Правило 5", rules.check_row.details.text())
self.assertIn("Применить", rules.check_row.details.text())
page.session.mark_saved(None, page.session.text())
page.show_route_check(verdict, unapplied=True)
self.assertIn("по прежним правилам", rules.check_row.details.text())
page.session.revert()
self.assertFalse(page.is_dirty("singbox"))
def test_full_form_list_splits_commas_and_rejects_invalid_values(self) -> None:
from xray_fluent.ui.singbox.fields import ListEditor, create_editor
from xray_fluent.ui.singbox.sections import _rule_condition_check
page = _page()
rules = _section(page, "rules")
rule = {"domain_suffix": ["2ip.ru, 2ip.io"], "action": "route", "outbound": "direct"}
self.assertIn("несколько значений", _rule_condition_check(rule))
shape = rules.schema.fields(rules.schema.definition("Rule"), rule)["domain_suffix"].shape
editor = create_editor(rules.ctx(), rule, "domain_suffix", shape, rules.body)
_shared.setdefault("editors", []).append(editor)
self.assertIsInstance(editor, ListEditor)
editor.edit.setPlainText("2ip.ru, 2ip.io")
editor.flush()
self.assertEqual(rule["domain_suffix"], ["2ip.ru", "2ip.io"])
self.assertEqual(editor.edit.toPlainText(), "2ip.ru\n2ip.io")
self.assertEqual(_rule_condition_check(rule), "")
editor.edit.setPlainText("2ip.ru\nbad domain!")
editor.flush()
self.assertEqual(rule["domain_suffix"], ["2ip.ru", "2ip.io"]) # недопустимое не сохранено
self.assertTrue(editor.error.isVisibleTo(editor))
self.assertIn("Не похоже на домен", editor.error.text())
editor.hide()
def test_invalid_json_makes_structured_pages_read_only(self) -> None:
page = _page()
page.show_section("json")
page.json_panel.editor.setPlainText('{"route": ')
page.show_section("rules")
_pump()
self.assertFalse(page.session.editable)
self.assertEqual(_object_lists(_section(page, "rules")), [])
self.assertEqual(page.session.text(), '{"route": ')
page.session.revert()
self.assertTrue(page.session.editable)
def test_json_panel_and_sections_share_one_document(self) -> None:
page = _page()
page.show_section("json")
document = json.loads(page.json_panel.editor.toPlainText())
document["route"]["final"] = "direct"
page.json_panel.editor.setPlainText(json.dumps(document, ensure_ascii=False, indent=2))
page.show_section("rules")
_pump()
self.assertEqual(page.session.document["route"]["final"], "direct")
self.assertTrue(page.is_dirty("singbox"))
page.session.revert()
def test_reset_section_restores_stock_only_there(self) -> None:
page = _page()
page.session.document["dns"]["final"] = "bootstrap-dns"
page.session.document["log"]["level"] = "debug"
page.session.mark_edited()
self.assertTrue(page.session.reset_section("dns"))
stock = json.loads(TEMPLATE_TEXT)
self.assertEqual(page.session.document["dns"], stock["dns"])
self.assertEqual(page.session.document["log"]["level"], "debug")
page.session.revert()
def test_proxy_outbound_opens_locked_with_explanation(self) -> None:
page = _page()
outbounds = _section(page, "outbounds")
view = _object_lists(outbounds)[0]
index = next(i for i, item in enumerate(view._items()) if item.get("tag") == "proxy")
view.open_requested.emit(index)
_pump()
item_page = outbounds.nav.currentWidget()
self.assertTrue(item_page.form.locked)
outbounds.nav.pop()
def test_mark_saved_with_rewritten_text_reloads(self) -> None:
page = _page()
page.session.document["log"]["level"] = "info"
page.session.mark_edited()
saved = page.session.text()
page.mark_saved("singbox", Path("x.json"), saved)
self.assertFalse(page.is_dirty("singbox"))
def _open_rule(self, page, index: int):
rules = _section(page, "rules")
page.show_section("rules")
rules.rules.open_requested.emit(index)
_pump()
return rules, rules.nav.currentWidget()
def test_typed_list_value_survives_immediate_done(self) -> None:
from xray_fluent.ui.singbox.fields import ListEditor
page = _page()
rules, item_page = self._open_rule(page, 4)
editor = next(e for e in item_page.form.findChildren(ListEditor) if e.name == "process_name")
editor.edit.setFocus()
editor.edit.moveCursor(editor.edit.textCursor().MoveOperation.End)
QTest.keyClick(editor.edit, Qt.Key.Key_Return)
QTest.keyClicks(editor.edit, "new.exe")
item_page.request_back() # before the debounce fires
_pump()
self.assertEqual(rules.nav.depth, 0)
self.assertIn("new.exe", page.session.document["route"]["rules"][4]["process_name"])
page.session.revert()
def test_typed_list_value_reaches_save_signal(self) -> None:
from xray_fluent.ui.singbox.fields import ListEditor
page = _page()
rules, item_page = self._open_rule(page, 4)
editor = next(e for e in item_page.form.findChildren(ListEditor) if e.name == "process_name")
editor.edit.moveCursor(editor.edit.textCursor().MoveOperation.End)
QTest.keyClick(editor.edit, Qt.Key.Key_Return)
QTest.keyClicks(editor.edit, "saved.exe")
received: list[str] = []
page.save_requested.connect(lambda _core, text: received.append(text))
page.save_btn.click()
self.assertTrue(received and "saved.exe" in received[-1])
rules.nav.pop()
page.session.revert()
def test_switching_action_through_the_real_combo_popup(self) -> None:
from qfluentwidgets import ComboBox
page = _page()
for attempt in range(4):
rules, item_page = self._open_rule(page, 4)
combo = next(
c for c in item_page.form.findChildren(ComboBox)
if c.currentData() in ("route", "reject")
)
target = "reject" if combo.currentData() == "route" else "route"
index = next(i for i in range(combo.count()) if combo.itemData(i) == target)
combo._showComboMenu()
_pump()
combo.dropMenu.actions()[index].trigger()
_pump()
_pump()
self.assertEqual(item_page.item.get("action"), target, attempt)
if target == "reject":
self.assertNotIn("outbound", item_page.item)
item_page._undo()
item_page.request_back()
_pump()
self.assertFalse(page.is_dirty("singbox"))
def test_launch_required_tags_are_explained(self) -> None:
from xray_fluent.singbox_config.document import app_owned_note
self.assertIn("служебные", app_owned_note("outbounds", {"type": "direct", "tag": "direct"}))
self.assertIn("доменное имя", app_owned_note("dns.servers", {"type": "fallback", "tag": "bootstrap-dns"}))
page = _page()
warn = _section(page, "dns").reference_warning("сервер", "dns.servers")
bootstrap = next(s for s in page.session.document["dns"]["servers"] if s["tag"] == "bootstrap-dns")
self.assertIn("не запустится", warn(bootstrap))
def test_nav_stack_stays_consistent_through_animated_slides(self) -> None:
page = _page()
rules = _section(page, "rules")
for _ in range(3):
rules.rules.open_requested.emit(1)
self.assertEqual(rules.nav.depth, 1)
self.assertIsInstance(rules.nav.currentWidget(), ItemPage)
rules.nav.pop()
self.assertEqual(rules.nav.depth, 0)
self.assertIs(rules.nav.currentWidget(), rules.root)
_pump()
def test_rows_show_badges_and_guide_art_shows_the_config(self) -> None:
from xray_fluent.ui.singbox.art import KindBadge, RouteMapArt, RuleScanArt
page = _page()
rules = _section(page, "rules")
badges = [b for b in rules.rules.findChildren(KindBadge) if b.isVisibleTo(rules.rules)]
self.assertEqual(len(badges), len(rules.rules._items()))
scan = _guide(rules.open_guide)
self.assertIsInstance(scan.art, RuleScanArt)
self.assertEqual(len(scan.art._visuals), min(8, len(rules.rules._items())))
page.show_section("overview")
route_map = _guide(page.overview.open_guide)
self.assertIsInstance(route_map.art, RouteMapArt)
self.assertEqual(route_map.art._counts, (2, 5, 2))
page.session.document["route"]["rules"].append({"domain": ["x.org"], "action": "reject"})
page.session.mark_edited()
self.assertEqual(_guide(page.overview.open_guide).art._counts, (2, 5, 3))
self.assertTrue(page.dirty_dot.isVisibleTo(page))
page.session.revert()
self.assertFalse(page.dirty_dot.isVisibleTo(page))
def test_every_page_has_only_a_link_and_the_guide_explains_it(self) -> None:
from qfluentwidgets import CaptionLabel
from xray_fluent.ui.configs_page import SECTIONS
from xray_fluent.ui.singbox.art import ArtCanvas
from xray_fluent.ui.singbox.guide import ART_CLASSES, GUIDES, LINK_LABEL, LINK_TEXT, GuideDialog
page = _page()
# «zapret» explains the Zapret section with the same dialog.
self.assertEqual(set(GUIDES) - {"zapret"}, {key for key, _title, _icon in SECTIONS})
self.assertEqual(set(ART_CLASSES), set(GUIDES))
for key, _title, _icon in SECTIONS:
_section(page, key)
widget = page._sections[key]
link = widget.help_link
self.assertEqual(link.text(), LINK_LABEL)
self.assertEqual(link.toolTip(), LINK_TEXT)
self.assertTrue(link.isVisibleTo(widget), key)
# Explanations and animations live only in the guide dialog.
self.assertEqual(widget.findChildren(ArtCanvas), [], key)
if key != "overview": # the overview body shows file/stock captions
header = link.parentWidget()
captions = [c.text() for c in header.findChildren(CaptionLabel) if c.isVisibleTo(header)]
self.assertEqual(captions, [], key)
dialog = _guide(widget.open_guide if hasattr(widget, "open_guide") else link.click)
self.assertIsInstance(dialog, GuideDialog)
self.assertEqual(dialog.key, key)
self.assertIsInstance(dialog.art, ART_CLASSES[key])
self.assertGreater(len(dialog.text()), 300, key)
self.assertFalse(dialog.cancelButton.isVisibleTo(dialog))
self.assertLessEqual(dialog.widget.width(), max(360, page.window().width() - 2 * dialog.MARGIN) + 1)
def test_guide_closes_and_is_disposed(self) -> None:
from PyQt6 import sip
page = _page()
rules = _section(page, "rules")
dialog = rules.open_guide()
self.assertFalse(dialog.isHidden())
dialog.yesButton.click()
for _ in range(40):
QTest.qWait(10)
if sip.isdeleted(dialog):
break
self.assertTrue(sip.isdeleted(dialog))
self.assertEqual(page.current_section(), "rules")
def test_reference_counting(self) -> None:
document = json.loads(TEMPLATE_TEXT)
own = next(item for item in document["route"]["rule_set"] if item["tag"] == "geoip-ru")
self.assertEqual(count_references(document, "geoip-ru", own), 1)
def _first_option(view):
"""The first add option that appends a core object (not «Простое правило»)."""
return next(option for option in view.options if option.factory is not None)
if __name__ == "__main__":
unittest.main()