test_cfst_local_lifecycle.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. import csv
  2. import json
  3. import os
  4. import pathlib
  5. import shutil
  6. import subprocess
  7. import sys
  8. import tempfile
  9. import unittest
  10. PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent
  11. sys.path.insert(0, str(PROJECT_ROOT / "scripts"))
  12. from rotator.main import run_updater
  13. class TestCfstLocalLifecycle(unittest.TestCase):
  14. """Local Python cfst_local 模式生命周期与选优逻辑测试套件"""
  15. def setUp(self):
  16. self.tmp_dir = tempfile.TemporaryDirectory()
  17. self.work_dir = pathlib.Path(self.tmp_dir.name)
  18. def tearDown(self):
  19. self.tmp_dir.cleanup()
  20. def _create_mock_cfst_env(self, csv_rows: list, run_args=None, skip_run=True, last_good=None) -> tuple[pathlib.Path, pathlib.Path]:
  21. """创建隔离的 cfst_local 运行目录、配置文件与模拟 CSV 数据"""
  22. cfst_dir = self.work_dir / "cfst_test"
  23. cfst_dir.mkdir(parents=True, exist_ok=True)
  24. csv_path = cfst_dir / "result.csv"
  25. with open(csv_path, "w", encoding="utf-8", newline="") as f:
  26. writer = csv.writer(f)
  27. writer.writerow(["IP 地址", "已发送", "已接收", "丢包率", "平均延迟", "下载速度 (MB/s)", "地区码"])
  28. for row in csv_rows:
  29. writer.writerow(row)
  30. runtime_dir = self.work_dir / "runtime"
  31. runtime_dir.mkdir(parents=True, exist_ok=True)
  32. if last_good:
  33. (runtime_dir / "state.json").write_text(json.dumps({
  34. "last_good_ip": last_good,
  35. "updated_at": "2026-06-01T00:00:00Z"
  36. }))
  37. config = {
  38. "source": {"type": "cfst_local"},
  39. "cfst_local": {
  40. "work_dir": str(cfst_dir),
  41. "binary": "./cfst",
  42. "run_args": run_args or ["-f", "ip.txt", "-o", "result.csv", "-p", "10"],
  43. "result_file": "result.csv",
  44. "run_timeout_sec": 60,
  45. "skip_run": skip_run,
  46. "header_rows": 1,
  47. "columns": {
  48. "ip": 0,
  49. "sent": 1,
  50. "received": 2,
  51. "loss_rate": 3,
  52. "avg_latency": 4,
  53. "download_speed": 5,
  54. "region": 6,
  55. },
  56. },
  57. "domain_filter": {
  58. "include_suffixes": [],
  59. "exclude_regex": [],
  60. },
  61. "selection": {"top_n": 3},
  62. "output": {
  63. "runtime_dir": str(runtime_dir),
  64. "selected_value_file": "current_ip.txt",
  65. "selected_value_json": "current_ip.json",
  66. "selected_value_json_key": "ip",
  67. "state_file": "state.json",
  68. "state_last_good_key": "last_good_ip",
  69. "export_vars_file": "substore_vars.json",
  70. "substore_value_key": "AUTO_CFIP",
  71. },
  72. "notify": {"command": ""},
  73. }
  74. config_file = self.work_dir / "test_local.json"
  75. config_file.write_text(json.dumps(config, indent=2))
  76. return config_file, runtime_dir
  77. def test_cfst_local_standard_run_and_output_artifacts(self):
  78. """[全流程 1] 标准 cfst_local 流程:解析 CSV -> 过滤选优 -> 4 个 runtime 文件落盘 -> Sub-Store 契约输出"""
  79. rows = [
  80. ["172.64.147.31", "4", "4", "0.00", "22.14", "15.30", "HKG"],
  81. ["198.41.209.213", "4", "4", "0.00", "21.87", "12.80", "HKG"],
  82. ["104.19.38.34", "4", "4", "0.00", "22.82", "8.50", "HKG"],
  83. ["104.16.254.215", "4", "4", "0.00", "23.29", "5.20", "HKG"],
  84. ]
  85. config_file, runtime_dir = self._create_mock_cfst_env(rows)
  86. rc = run_updater(str(config_file))
  87. self.assertEqual(rc, 0)
  88. # 验证 4 份落盘文件
  89. txt_path = runtime_dir / "current_ip.txt"
  90. json_path = runtime_dir / "current_ip.json"
  91. vars_path = runtime_dir / "substore_vars.json"
  92. state_path = runtime_dir / "state.json"
  93. self.assertTrue(txt_path.exists())
  94. self.assertTrue(json_path.exists())
  95. self.assertTrue(vars_path.exists())
  96. self.assertTrue(state_path.exists())
  97. ip_json = json.loads(json_path.read_text())
  98. self.assertEqual(ip_json["status"], "ok")
  99. self.assertEqual(ip_json["ip"], "172.64.147.31")
  100. self.assertEqual(ip_json["source_type"], "cfst_local")
  101. self.assertEqual(ip_json["source_count"], 4)
  102. self.assertEqual(txt_path.read_text().strip(), "172.64.147.31")
  103. ip_json = json.loads(json_path.read_text())
  104. self.assertEqual(ip_json["ip"], "172.64.147.31")
  105. self.assertEqual(ip_json["status"], "ok")
  106. self.assertEqual(len(ip_json["top_candidates"]), 3)
  107. self.assertEqual(ip_json["top_candidates"][0]["download_speed"], "15.30")
  108. self.assertEqual(ip_json["top_candidates"][0]["region"], "HKG")
  109. vars_json = json.loads(vars_path.read_text())
  110. self.assertEqual(vars_json["AUTO_CFIP"], "172.64.147.31")
  111. self.assertEqual(vars_json["STATUS"], "ok")
  112. state_json = json.loads(state_path.read_text())
  113. self.assertEqual(state_json["last_good_ip"], "172.64.147.31")
  114. self.assertEqual(state_json["status"], "ok")
  115. def test_cfst_local_filters_zero_download_speed_and_prefers_positive(self):
  116. """[边界 2] 0 测速过滤:前两个节点测速为 0.00,自动跳过并选出首个 download_speed > 0 的健康节点"""
  117. rows = [
  118. ["1.1.1.1", "4", "4", "0.00", "15.00", "0.00", "N/A"],
  119. ["2.2.2.2", "4", "4", "0.00", "18.00", "0.00", "N/A"],
  120. ["3.3.3.3", "4", "4", "0.00", "25.00", "9.80", "HKG"],
  121. ["4.4.4.4", "4", "4", "0.00", "28.00", "6.20", "SJC"],
  122. ]
  123. config_file, runtime_dir = self._create_mock_cfst_env(rows)
  124. rc = run_updater(str(config_file))
  125. self.assertEqual(rc, 0)
  126. res = json.loads((runtime_dir / "current_ip.json").read_text())
  127. self.assertEqual(res["status"], "ok")
  128. self.assertEqual(res["ip"], "3.3.3.3", "必须自动过滤 0 速度死节点,挑选 3.3.3.3")
  129. candidates = [c["ip"] for c in res["top_candidates"]]
  130. self.assertNotIn("1.1.1.1", candidates, "0 测速节点不应出现在候选列表中")
  131. self.assertNotIn("2.2.2.2", candidates, "0 测速节点不应出现在候选列表中")
  132. self.assertIn("3.3.3.3", candidates)
  133. self.assertIn("4.4.4.4", candidates)
  134. def test_cfst_local_all_zero_speed_triggers_last_good_fallback(self):
  135. """[容灾 3] 全员测速 0.00 容灾回退:所有节点均不可用时,平滑回退至 state.json 中的 last_good_ip"""
  136. rows = [
  137. ["1.1.1.1", "4", "4", "0.00", "15.00", "0.00", "N/A"],
  138. ["2.2.2.2", "4", "4", "0.00", "18.00", "0.00", "N/A"],
  139. ]
  140. config_file, runtime_dir = self._create_mock_cfst_env(rows, last_good="198.51.100.88")
  141. rc = run_updater(str(config_file))
  142. self.assertEqual(rc, 0)
  143. res = json.loads((runtime_dir / "current_ip.json").read_text())
  144. self.assertEqual(res["status"], "fallback_last_good")
  145. self.assertEqual(res["ip"], "198.51.100.88")
  146. # 检查落盘文件
  147. txt_path = runtime_dir / "current_ip.txt"
  148. self.assertEqual(txt_path.read_text().strip(), "198.51.100.88")
  149. vars_path = runtime_dir / "substore_vars.json"
  150. vars_json = json.loads(vars_path.read_text())
  151. self.assertEqual(vars_json["AUTO_CFIP"], "198.51.100.88")
  152. self.assertEqual(vars_json["STATUS"], "fallback_last_good")
  153. def test_cfst_local_disable_download_flag_dd(self):
  154. """[参数 4] -dd 禁用下载测速模式:配置 -dd 时,按延迟排序,不因 download_speed == 0.00 误杀"""
  155. rows = [
  156. ["10.0.0.1", "4", "4", "0.00", "12.00", "0.00", "N/A"],
  157. ["10.0.0.2", "4", "4", "0.00", "15.00", "0.00", "N/A"],
  158. ]
  159. # 在 run_args 中注入 -dd
  160. config_file, runtime_dir = self._create_mock_cfst_env(rows, run_args=["-f", "ip.txt", "-dd", "-o", "result.csv"])
  161. rc = run_updater(str(config_file))
  162. self.assertEqual(rc, 0)
  163. res = json.loads((runtime_dir / "current_ip.json").read_text())
  164. self.assertEqual(res["status"], "ok")
  165. self.assertEqual(res["ip"], "10.0.0.1", "禁用下载测速模式下,按延迟排序取第一名")
  166. def test_cfst_local_filter_exclude_regex(self):
  167. """[过滤 5] 正则过滤排除指定 IP 段"""
  168. rows = [
  169. ["172.64.10.1", "4", "4", "0.00", "20.00", "20.00", "HKG"],
  170. ["104.16.10.1", "4", "4", "0.00", "22.00", "18.00", "HKG"],
  171. ]
  172. config_file, runtime_dir = self._create_mock_cfst_env(rows)
  173. # 增加正则过滤:排除 172.64.*
  174. with open(config_file, "r", encoding="utf-8") as f:
  175. cfg = json.load(f)
  176. cfg["domain_filter"]["exclude_regex"] = [r"^172\.64\."]
  177. with open(config_file, "w", encoding="utf-8") as f:
  178. json.dump(cfg, f, indent=2)
  179. rc = run_updater(str(config_file))
  180. self.assertEqual(rc, 0)
  181. res = json.loads((runtime_dir / "current_ip.json").read_text())
  182. self.assertEqual(res["status"], "ok")
  183. self.assertEqual(res["ip"], "104.16.10.1", "172.64.* 被排除,选取 104.16.10.1")
  184. def test_cfst_local_actual_repo_config_and_output_paths(self):
  185. """[契约 6] 验证仓库实际 configs/config.local.json 的输出路径解析与落盘契约"""
  186. real_cfg_path = PROJECT_ROOT / "configs" / "config.local.json"
  187. self.assertTrue(real_cfg_path.exists())
  188. cmd = [sys.executable, str(PROJECT_ROOT / "scripts" / "domain_updater.py"), "--config", str(real_cfg_path), "--print-output-settings"]
  189. out = subprocess.check_output(cmd, text=True).strip()
  190. settings = json.loads(out)
  191. # 必须锚定在项目根目录 /runtime,严禁出现 /configs/runtime
  192. expected_runtime_dir = str(PROJECT_ROOT / "runtime")
  193. self.assertEqual(settings["runtime_dir"], expected_runtime_dir)
  194. self.assertEqual(settings["selected_text_path"], str(PROJECT_ROOT / "runtime" / "current_ip.txt"))
  195. self.assertEqual(settings["selected_json_path"], str(PROJECT_ROOT / "runtime" / "current_ip.json"))
  196. self.assertEqual(settings["state_path"], str(PROJECT_ROOT / "runtime" / "state.json"))
  197. self.assertEqual(settings["vars_path"], str(PROJECT_ROOT / "runtime" / "substore_vars.json"))
  198. self.assertEqual(settings["selected_json_key"], "ip")
  199. self.assertEqual(settings["state_last_good_key"], "last_good_ip")
  200. self.assertEqual(settings["vars_value_key"], "AUTO_CFIP")
  201. if __name__ == "__main__":
  202. unittest.main()