| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- import csv
- import json
- import os
- import pathlib
- import shutil
- import subprocess
- import sys
- import tempfile
- import unittest
- PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent
- sys.path.insert(0, str(PROJECT_ROOT / "scripts"))
- from rotator.main import run_updater
- class TestCfstLocalLifecycle(unittest.TestCase):
- """Local Python cfst_local 模式生命周期与选优逻辑测试套件"""
- def setUp(self):
- self.tmp_dir = tempfile.TemporaryDirectory()
- self.work_dir = pathlib.Path(self.tmp_dir.name)
- def tearDown(self):
- self.tmp_dir.cleanup()
- def _create_mock_cfst_env(self, csv_rows: list, run_args=None, skip_run=True, last_good=None) -> tuple[pathlib.Path, pathlib.Path]:
- """创建隔离的 cfst_local 运行目录、配置文件与模拟 CSV 数据"""
- cfst_dir = self.work_dir / "cfst_test"
- cfst_dir.mkdir(parents=True, exist_ok=True)
- csv_path = cfst_dir / "result.csv"
- with open(csv_path, "w", encoding="utf-8", newline="") as f:
- writer = csv.writer(f)
- writer.writerow(["IP 地址", "已发送", "已接收", "丢包率", "平均延迟", "下载速度 (MB/s)", "地区码"])
- for row in csv_rows:
- writer.writerow(row)
- runtime_dir = self.work_dir / "runtime"
- runtime_dir.mkdir(parents=True, exist_ok=True)
- if last_good:
- (runtime_dir / "state.json").write_text(json.dumps({
- "last_good_ip": last_good,
- "updated_at": "2026-06-01T00:00:00Z"
- }))
- config = {
- "source": {"type": "cfst_local"},
- "cfst_local": {
- "work_dir": str(cfst_dir),
- "binary": "./cfst",
- "run_args": run_args or ["-f", "ip.txt", "-o", "result.csv", "-p", "10"],
- "result_file": "result.csv",
- "run_timeout_sec": 60,
- "skip_run": skip_run,
- "header_rows": 1,
- "columns": {
- "ip": 0,
- "sent": 1,
- "received": 2,
- "loss_rate": 3,
- "avg_latency": 4,
- "download_speed": 5,
- "region": 6,
- },
- },
- "domain_filter": {
- "include_suffixes": [],
- "exclude_regex": [],
- },
- "selection": {"top_n": 3},
- "output": {
- "runtime_dir": str(runtime_dir),
- "selected_value_file": "current_ip.txt",
- "selected_value_json": "current_ip.json",
- "selected_value_json_key": "ip",
- "state_file": "state.json",
- "state_last_good_key": "last_good_ip",
- "export_vars_file": "substore_vars.json",
- "substore_value_key": "AUTO_CFIP",
- },
- "notify": {"command": ""},
- }
- config_file = self.work_dir / "test_local.json"
- config_file.write_text(json.dumps(config, indent=2))
- return config_file, runtime_dir
- def test_cfst_local_standard_run_and_output_artifacts(self):
- """[全流程 1] 标准 cfst_local 流程:解析 CSV -> 过滤选优 -> 4 个 runtime 文件落盘 -> Sub-Store 契约输出"""
- rows = [
- ["172.64.147.31", "4", "4", "0.00", "22.14", "15.30", "HKG"],
- ["198.41.209.213", "4", "4", "0.00", "21.87", "12.80", "HKG"],
- ["104.19.38.34", "4", "4", "0.00", "22.82", "8.50", "HKG"],
- ["104.16.254.215", "4", "4", "0.00", "23.29", "5.20", "HKG"],
- ]
- config_file, runtime_dir = self._create_mock_cfst_env(rows)
- rc = run_updater(str(config_file))
- self.assertEqual(rc, 0)
- # 验证 4 份落盘文件
- txt_path = runtime_dir / "current_ip.txt"
- json_path = runtime_dir / "current_ip.json"
- vars_path = runtime_dir / "substore_vars.json"
- state_path = runtime_dir / "state.json"
- self.assertTrue(txt_path.exists())
- self.assertTrue(json_path.exists())
- self.assertTrue(vars_path.exists())
- self.assertTrue(state_path.exists())
- ip_json = json.loads(json_path.read_text())
- self.assertEqual(ip_json["status"], "ok")
- self.assertEqual(ip_json["ip"], "172.64.147.31")
- self.assertEqual(ip_json["source_type"], "cfst_local")
- self.assertEqual(ip_json["source_count"], 4)
- self.assertEqual(txt_path.read_text().strip(), "172.64.147.31")
- ip_json = json.loads(json_path.read_text())
- self.assertEqual(ip_json["ip"], "172.64.147.31")
- self.assertEqual(ip_json["status"], "ok")
- self.assertEqual(len(ip_json["top_candidates"]), 3)
- self.assertEqual(ip_json["top_candidates"][0]["download_speed"], "15.30")
- self.assertEqual(ip_json["top_candidates"][0]["region"], "HKG")
- vars_json = json.loads(vars_path.read_text())
- self.assertEqual(vars_json["AUTO_CFIP"], "172.64.147.31")
- self.assertEqual(vars_json["STATUS"], "ok")
- state_json = json.loads(state_path.read_text())
- self.assertEqual(state_json["last_good_ip"], "172.64.147.31")
- self.assertEqual(state_json["status"], "ok")
- def test_cfst_local_filters_zero_download_speed_and_prefers_positive(self):
- """[边界 2] 0 测速过滤:前两个节点测速为 0.00,自动跳过并选出首个 download_speed > 0 的健康节点"""
- rows = [
- ["1.1.1.1", "4", "4", "0.00", "15.00", "0.00", "N/A"],
- ["2.2.2.2", "4", "4", "0.00", "18.00", "0.00", "N/A"],
- ["3.3.3.3", "4", "4", "0.00", "25.00", "9.80", "HKG"],
- ["4.4.4.4", "4", "4", "0.00", "28.00", "6.20", "SJC"],
- ]
- config_file, runtime_dir = self._create_mock_cfst_env(rows)
- rc = run_updater(str(config_file))
- self.assertEqual(rc, 0)
- res = json.loads((runtime_dir / "current_ip.json").read_text())
- self.assertEqual(res["status"], "ok")
- self.assertEqual(res["ip"], "3.3.3.3", "必须自动过滤 0 速度死节点,挑选 3.3.3.3")
- candidates = [c["ip"] for c in res["top_candidates"]]
- self.assertNotIn("1.1.1.1", candidates, "0 测速节点不应出现在候选列表中")
- self.assertNotIn("2.2.2.2", candidates, "0 测速节点不应出现在候选列表中")
- self.assertIn("3.3.3.3", candidates)
- self.assertIn("4.4.4.4", candidates)
- def test_cfst_local_all_zero_speed_triggers_last_good_fallback(self):
- """[容灾 3] 全员测速 0.00 容灾回退:所有节点均不可用时,平滑回退至 state.json 中的 last_good_ip"""
- rows = [
- ["1.1.1.1", "4", "4", "0.00", "15.00", "0.00", "N/A"],
- ["2.2.2.2", "4", "4", "0.00", "18.00", "0.00", "N/A"],
- ]
- config_file, runtime_dir = self._create_mock_cfst_env(rows, last_good="198.51.100.88")
- rc = run_updater(str(config_file))
- self.assertEqual(rc, 0)
- res = json.loads((runtime_dir / "current_ip.json").read_text())
- self.assertEqual(res["status"], "fallback_last_good")
- self.assertEqual(res["ip"], "198.51.100.88")
- # 检查落盘文件
- txt_path = runtime_dir / "current_ip.txt"
- self.assertEqual(txt_path.read_text().strip(), "198.51.100.88")
- vars_path = runtime_dir / "substore_vars.json"
- vars_json = json.loads(vars_path.read_text())
- self.assertEqual(vars_json["AUTO_CFIP"], "198.51.100.88")
- self.assertEqual(vars_json["STATUS"], "fallback_last_good")
- def test_cfst_local_disable_download_flag_dd(self):
- """[参数 4] -dd 禁用下载测速模式:配置 -dd 时,按延迟排序,不因 download_speed == 0.00 误杀"""
- rows = [
- ["10.0.0.1", "4", "4", "0.00", "12.00", "0.00", "N/A"],
- ["10.0.0.2", "4", "4", "0.00", "15.00", "0.00", "N/A"],
- ]
- # 在 run_args 中注入 -dd
- config_file, runtime_dir = self._create_mock_cfst_env(rows, run_args=["-f", "ip.txt", "-dd", "-o", "result.csv"])
- rc = run_updater(str(config_file))
- self.assertEqual(rc, 0)
- res = json.loads((runtime_dir / "current_ip.json").read_text())
- self.assertEqual(res["status"], "ok")
- self.assertEqual(res["ip"], "10.0.0.1", "禁用下载测速模式下,按延迟排序取第一名")
- def test_cfst_local_filter_exclude_regex(self):
- """[过滤 5] 正则过滤排除指定 IP 段"""
- rows = [
- ["172.64.10.1", "4", "4", "0.00", "20.00", "20.00", "HKG"],
- ["104.16.10.1", "4", "4", "0.00", "22.00", "18.00", "HKG"],
- ]
- config_file, runtime_dir = self._create_mock_cfst_env(rows)
- # 增加正则过滤:排除 172.64.*
- with open(config_file, "r", encoding="utf-8") as f:
- cfg = json.load(f)
- cfg["domain_filter"]["exclude_regex"] = [r"^172\.64\."]
- with open(config_file, "w", encoding="utf-8") as f:
- json.dump(cfg, f, indent=2)
- rc = run_updater(str(config_file))
- self.assertEqual(rc, 0)
- res = json.loads((runtime_dir / "current_ip.json").read_text())
- self.assertEqual(res["status"], "ok")
- self.assertEqual(res["ip"], "104.16.10.1", "172.64.* 被排除,选取 104.16.10.1")
- def test_cfst_local_actual_repo_config_and_output_paths(self):
- """[契约 6] 验证仓库实际 configs/config.local.json 的输出路径解析与落盘契约"""
- real_cfg_path = PROJECT_ROOT / "configs" / "config.local.json"
- self.assertTrue(real_cfg_path.exists())
- cmd = [sys.executable, str(PROJECT_ROOT / "scripts" / "domain_updater.py"), "--config", str(real_cfg_path), "--print-output-settings"]
- out = subprocess.check_output(cmd, text=True).strip()
- settings = json.loads(out)
- # 必须锚定在项目根目录 /runtime,严禁出现 /configs/runtime
- expected_runtime_dir = str(PROJECT_ROOT / "runtime")
- self.assertEqual(settings["runtime_dir"], expected_runtime_dir)
- self.assertEqual(settings["selected_text_path"], str(PROJECT_ROOT / "runtime" / "current_ip.txt"))
- self.assertEqual(settings["selected_json_path"], str(PROJECT_ROOT / "runtime" / "current_ip.json"))
- self.assertEqual(settings["state_path"], str(PROJECT_ROOT / "runtime" / "state.json"))
- self.assertEqual(settings["vars_path"], str(PROJECT_ROOT / "runtime" / "substore_vars.json"))
- self.assertEqual(settings["selected_json_key"], "ip")
- self.assertEqual(settings["state_last_good_key"], "last_good_ip")
- self.assertEqual(settings["vars_value_key"], "AUTO_CFIP")
- if __name__ == "__main__":
- unittest.main()
|