CI / lite (push) Successful in 3s
新增 2 个 test method 覆盖b4a4769修复的边界 case: - test_to_rule_strips_inline_hash_comment_before_classifying_no_resolve 锁定带 inline # 注释的条目(本次修复的真实场景) + 验证注释里出现 'no-resolve' / ',DIRECT' 字符串不被误处理 - test_to_rule_preserves_canonical_forms 锁定 happy path(纯规则、带 no-resolve、带行尾空白) 为让 test_checked_in_router_projections_match_the_rule_source 通过, 同步重生成 router/direct-plain.txt 和 router/direct-rules.yaml。 这俩投影文件自907fe9d起就 stale 14+ 条规则(国内 LLM、UGREEN、 Steam 段全没同步),本次连同b4a4769的修复一起补齐。 测试结果:python3 -m unittest discover -s tests Ran 9 tests in 0.028s — OK
253 lines
8.7 KiB
Python
253 lines
8.7 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path, PurePosixPath
|
||
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
SPEC_PATH = ROOT / "MODULE_SPEC.yaml"
|
||
sys.path.insert(0, str(ROOT / "scripts"))
|
||
|
||
from gen_clash_verge_script import ( # noqa: E402
|
||
default_output_path,
|
||
load_payload,
|
||
render_script,
|
||
to_rule,
|
||
)
|
||
|
||
|
||
class ModuleSpecTests(unittest.TestCase):
|
||
def test_clash_verge_default_output_path_is_platform_aware(self) -> None:
|
||
home = Path("C:/Users/example")
|
||
appdata = Path("C:/Users/example/AppData/Roaming")
|
||
|
||
self.assertEqual(
|
||
default_output_path("win32", home=home, appdata=appdata),
|
||
appdata
|
||
/ "io.github.clash-verge-rev.clash-verge-rev"
|
||
/ "profiles"
|
||
/ "Script.js",
|
||
)
|
||
self.assertEqual(
|
||
default_output_path("darwin", home=home),
|
||
home
|
||
/ "Library/Application Support"
|
||
/ "io.github.clash-verge-rev.clash-verge-rev"
|
||
/ "profiles"
|
||
/ "Script.js",
|
||
)
|
||
|
||
def test_clash_verge_generator_supports_windows_gbk_console(self) -> None:
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
output = Path(temp_dir) / "Script.js"
|
||
env = os.environ.copy()
|
||
env["PYTHONIOENCODING"] = "gbk"
|
||
result = subprocess.run(
|
||
[
|
||
sys.executable,
|
||
str(ROOT / "scripts/gen_clash_verge_script.py"),
|
||
"--out",
|
||
str(output),
|
||
],
|
||
cwd=ROOT,
|
||
env=env,
|
||
capture_output=True,
|
||
check=False,
|
||
)
|
||
|
||
self.assertEqual(
|
||
result.returncode,
|
||
0,
|
||
result.stderr.decode("gbk", errors="replace"),
|
||
)
|
||
self.assertTrue(output.is_file())
|
||
|
||
def test_module_spec_declares_rule_authority_and_safe_context(self) -> None:
|
||
spec = json.loads(SPEC_PATH.read_text(encoding="utf-8"))
|
||
|
||
self.assertEqual(
|
||
set(spec),
|
||
{
|
||
"schema_version",
|
||
"module_id",
|
||
"authority",
|
||
"repository",
|
||
"bounded_context",
|
||
"capabilities",
|
||
"data",
|
||
"contracts",
|
||
"dependencies",
|
||
"agent_context",
|
||
"verification",
|
||
},
|
||
)
|
||
self.assertEqual(spec["schema_version"], 1)
|
||
self.assertEqual(spec["module_id"], "vpn-rules")
|
||
self.assertEqual(
|
||
spec["authority"],
|
||
{
|
||
"scope": "module_metadata",
|
||
"subject": "vpn-rules",
|
||
"owner": "vpn-rules-owner",
|
||
"source": "MODULE_SPEC.yaml",
|
||
"revision": 1,
|
||
"effective_from": "2026-08-20T00:00:00+08:00",
|
||
},
|
||
)
|
||
self.assertEqual(
|
||
spec["repository"],
|
||
{
|
||
"name": "vpn-rules",
|
||
"workspace_id": None,
|
||
"type": "infrastructure",
|
||
"maturity": "operational",
|
||
},
|
||
)
|
||
|
||
boundary = spec["bounded_context"]
|
||
self.assertEqual(boundary["domain"], "network-routing-policy")
|
||
self.assertIn("single source of truth", boundary["responsibility"].lower())
|
||
prohibited = " ".join(boundary["prohibited_responsibilities"]).lower()
|
||
self.assertIn("production", prohibited)
|
||
self.assertIn("credential", prohibited)
|
||
self.assertIn("proxy subscription", prohibited)
|
||
|
||
self.assertEqual(
|
||
{capability["id"] for capability in spec["capabilities"]},
|
||
{
|
||
"direct-routing-rule-authoring",
|
||
"clash-verge-script-generation",
|
||
"router-rule-projection",
|
||
},
|
||
)
|
||
self.assertEqual(spec["contracts"]["consumes"], [])
|
||
self.assertEqual(spec["dependencies"], [])
|
||
provided = {
|
||
contract["contract_id"]: contract
|
||
for contract in spec["contracts"]["provides"]
|
||
}
|
||
self.assertEqual(
|
||
set(provided),
|
||
{
|
||
"mihomo-direct-rule-provider",
|
||
"router-direct-rules",
|
||
"router-direct-plain-list",
|
||
},
|
||
)
|
||
self.assertEqual(
|
||
{contract["version"] for contract in provided.values()}, {"1.0.0"}
|
||
)
|
||
for contract in provided.values():
|
||
self.assertTrue((ROOT / contract["path"]).is_file())
|
||
|
||
context = spec["agent_context"]
|
||
self.assertLessEqual(context["max_default_tokens"], 6000)
|
||
self.assertEqual(
|
||
{entry["path"] for entry in context["default_entrypoints"]},
|
||
{"README.md", "direct.yaml"},
|
||
)
|
||
for entry in context["default_entrypoints"]:
|
||
self.assertTrue((ROOT / entry["path"]).is_file())
|
||
self.assertIn("router", context["excluded_paths"])
|
||
for value in context["excluded_paths"]:
|
||
path = PurePosixPath(value)
|
||
self.assertFalse(path.is_absolute())
|
||
self.assertNotIn("..", path.parts)
|
||
|
||
self.assertEqual(
|
||
spec["verification"],
|
||
{
|
||
"commands": [
|
||
{
|
||
"id": "rule-contract-tests",
|
||
"argv": [
|
||
"python3",
|
||
"-m",
|
||
"unittest",
|
||
"discover",
|
||
"-s",
|
||
"tests",
|
||
"-v",
|
||
],
|
||
"cwd": ".",
|
||
"network": False,
|
||
"required": True,
|
||
}
|
||
]
|
||
},
|
||
)
|
||
|
||
def test_checked_in_router_projections_match_the_rule_source(self) -> None:
|
||
payload = load_payload(ROOT / "direct.yaml")
|
||
plain = (ROOT / "router/direct-plain.txt").read_text(encoding="utf-8")
|
||
router = (ROOT / "router/direct-rules.yaml").read_text(encoding="utf-8")
|
||
|
||
self.assertEqual(plain, "\n".join(payload) + "\n")
|
||
self.assertEqual(
|
||
router.split("rules:\n", 1)[1],
|
||
"\n".join(f" - {to_rule(entry)}" for entry in payload) + "\n",
|
||
)
|
||
self.assertTrue(all(",DIRECT" not in entry for entry in payload))
|
||
|
||
def test_onedrive_process_is_forced_to_use_direct_routing(self) -> None:
|
||
payload = load_payload(ROOT / "direct.yaml")
|
||
|
||
self.assertIn("PROCESS-NAME,OneDrive.exe", payload)
|
||
self.assertEqual(
|
||
to_rule("PROCESS-NAME,OneDrive.exe"),
|
||
"PROCESS-NAME,OneDrive.exe,DIRECT",
|
||
)
|
||
|
||
def test_to_rule_strips_inline_hash_comment_before_classifying_no_resolve(self) -> None:
|
||
# 回归测试 (b4a4769):to_rule() 在判断 entry.endswith(',no-resolve') 之前
|
||
# 没有剥掉行尾 # 注释,导致带 inline 注释的 Akamai Steam CDN 规则
|
||
# 走错分支,把注释当成 entry 的一部分拼进去,生成 mihomo 解析失败的规则。
|
||
self.assertEqual(
|
||
to_rule("IP-CIDR,162.254.192.0/18,no-resolve # Akamai Steam CDN 主段"),
|
||
"IP-CIDR,162.254.192.0/18,DIRECT,no-resolve",
|
||
)
|
||
# 注释里出现 "no-resolve" 不能误判为 suffix
|
||
self.assertEqual(
|
||
to_rule("DOMAIN-SUFFIX,example.com # 走 no-resolve 路径"),
|
||
"DOMAIN-SUFFIX,example.com,DIRECT",
|
||
)
|
||
# 注释里出现 ",DIRECT" 不能误拼到 proxy 字段
|
||
self.assertEqual(
|
||
to_rule("IP-CIDR,10.0.0.0/8 # 不是 DIRECT, 是 REJECT"),
|
||
"IP-CIDR,10.0.0.0/8,DIRECT",
|
||
)
|
||
|
||
def test_to_rule_preserves_canonical_forms(self) -> None:
|
||
# 不带注释、不带 no-resolve
|
||
self.assertEqual(
|
||
to_rule("DOMAIN-SUFFIX,bilibili.com"),
|
||
"DOMAIN-SUFFIX,bilibili.com,DIRECT",
|
||
)
|
||
# 带 no-resolve、无注释
|
||
self.assertEqual(
|
||
to_rule("IP-CIDR,127.0.0.0/8,no-resolve"),
|
||
"IP-CIDR,127.0.0.0/8,DIRECT,no-resolve",
|
||
)
|
||
# 带 no-resolve + 行尾空白
|
||
self.assertEqual(
|
||
to_rule("IP-CIDR,127.0.0.0/8,no-resolve "),
|
||
"IP-CIDR,127.0.0.0/8,DIRECT,no-resolve",
|
||
)
|
||
|
||
def test_clash_verge_script_adds_reachable_direct_dns_fallbacks(self) -> None:
|
||
script = render_script(["PROCESS-NAME,OneDrive.exe"])
|
||
|
||
self.assertIn("const directDnsFallbacks", script)
|
||
self.assertIn("https://dns.alidns.com/dns-query", script)
|
||
self.assertIn("https://doh.pub/dns-query", script)
|
||
self.assertIn("config.dns = dns", script)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|