test_executor_config.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. """Unit tests for scripts/executor_config.py (P5-M2).
  2. Run: python scripts/test_executor_config.py
  3. Covers happy path / boundary / abnormal / empty-value cases for the
  4. executor config loader and validator. exit 0 == PASS.
  5. NOTE: All strings in this file are ASCII only.
  6. """
  7. import json
  8. import os
  9. import sys
  10. import tempfile
  11. import unittest
  12. from unittest import mock
  13. _SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
  14. if _SCRIPTS_DIR not in sys.path:
  15. sys.path.insert(0, _SCRIPTS_DIR)
  16. import executor_config # noqa: E402
  17. class ExecutorConfigTest(unittest.TestCase):
  18. """Test config load priority, resolution and validation."""
  19. def setUp(self):
  20. self._keep = dict(os.environ)
  21. for key in ("EXECUTOR_CONFIG", "WEB_BASE_URL", "MOTORCAD_MODEL",
  22. "EXECUTOR_INSTANCES", "EXECUTOR_POLL_INTERVAL",
  23. "EXECUTOR_LOG_DIR", "EXECUTOR_LOG_LEVEL",
  24. "EXECUTOR_TOOL", "EXECUTOR_MOCK"):
  25. os.environ.pop(key, None)
  26. self._tmp = tempfile.mkdtemp(prefix="exec_cfg_test_")
  27. def tearDown(self):
  28. os.environ.clear()
  29. os.environ.update(self._keep)
  30. import shutil
  31. shutil.rmtree(self._tmp, ignore_errors=True)
  32. def _isolate(self):
  33. """Point _base_dir/repo_root at an empty temp dir so the real
  34. repo-root sidecar config cannot influence tests."""
  35. return (
  36. mock.patch.object(executor_config, "_base_dir",
  37. return_value=self._tmp),
  38. mock.patch.object(executor_config, "repo_root",
  39. return_value=self._tmp),
  40. )
  41. def _write(self, name, obj):
  42. path = os.path.join(self._tmp, name)
  43. with open(path, "w", encoding="utf-8") as fh:
  44. json.dump(obj, fh)
  45. return path
  46. # ---------- happy path ----------
  47. def test_defaults_when_no_file_and_no_env(self):
  48. with self._isolate()[0], self._isolate()[1]:
  49. cfg = executor_config.load_config()
  50. self.assertEqual(cfg["web_base_url"], "http://127.0.0.1:8000")
  51. self.assertEqual(cfg["instances"], 1)
  52. self.assertEqual(cfg["poll_interval"], 5)
  53. self.assertEqual(cfg["log_level"], "INFO")
  54. self.assertEqual(cfg["tool"], "motorcad")
  55. self.assertIs(cfg["enable_mock"], False)
  56. self.assertEqual(cfg["config_source"], "defaults")
  57. self.assertTrue(os.path.isabs(cfg["model_path"]))
  58. self.assertTrue(cfg["model_path"].startswith(self._tmp))
  59. self.assertTrue(cfg["log_dir"].startswith(self._tmp))
  60. def test_file_load_merges_and_resolves(self):
  61. path = self._write("executor_config.json", {
  62. "web_base_url": "http://192.168.1.10:9000",
  63. "model_path": "models/custom.mot",
  64. "instances": 3,
  65. "poll_interval": 2,
  66. "log_dir": "logs/exec",
  67. "log_level": "DEBUG",
  68. "tool": "motorcad",
  69. "enable_mock": True,
  70. })
  71. cfg = executor_config.load_config(cli_path=path)
  72. self.assertEqual(cfg["web_base_url"], "http://192.168.1.10:9000")
  73. self.assertEqual(cfg["instances"], 3)
  74. self.assertEqual(cfg["poll_interval"], 2)
  75. self.assertEqual(cfg["log_level"], "DEBUG")
  76. self.assertIs(cfg["enable_mock"], True)
  77. self.assertEqual(cfg["config_source"], path)
  78. root = executor_config.repo_root()
  79. expected_model = os.path.normpath(os.path.join("models", "custom.mot"))
  80. self.assertTrue(cfg["model_path"].endswith(expected_model))
  81. self.assertTrue(cfg["model_path"].startswith(root))
  82. self.assertTrue(cfg["log_dir"].startswith(root))
  83. def test_absolute_paths_kept(self):
  84. path = self._write("executor_config.json", {
  85. "model_path": "D:/models/abs.mot",
  86. "log_dir": "D:/logs",
  87. })
  88. cfg = executor_config.load_config(cli_path=path)
  89. self.assertEqual(cfg["model_path"], os.path.normpath("D:/models/abs.mot"))
  90. self.assertEqual(cfg["log_dir"], os.path.normpath("D:/logs"))
  91. # ---------- env overrides ----------
  92. def test_env_overrides_file_and_cli(self):
  93. path = self._write("executor_config.json", {
  94. "web_base_url": "http://file:8000",
  95. "instances": 2,
  96. "poll_interval": 7,
  97. })
  98. os.environ["WEB_BASE_URL"] = "http://env:9999"
  99. os.environ["MOTORCAD_MODEL"] = "models/env.mot"
  100. os.environ["EXECUTOR_INSTANCES"] = "4"
  101. os.environ["EXECUTOR_MOCK"] = "true"
  102. cfg = executor_config.load_config(cli_path=path)
  103. self.assertEqual(cfg["web_base_url"], "http://env:9999")
  104. self.assertEqual(cfg["instances"], 4)
  105. self.assertEqual(cfg["poll_interval"], 7) # file value kept
  106. self.assertIs(cfg["enable_mock"], True)
  107. def test_env_numeric_invalid(self):
  108. os.environ["EXECUTOR_INSTANCES"] = "abc"
  109. with self.assertRaises(ValueError):
  110. executor_config.load_config()
  111. # ---------- find_config_path priority ----------
  112. def test_find_config_path_cli_first(self):
  113. a = self._write("a.json", {"instances": 1})
  114. b = self._write("b.json", {"instances": 1})
  115. os.environ["EXECUTOR_CONFIG"] = b
  116. self.assertEqual(executor_config.find_config_path(cli_path=a), a)
  117. def test_find_config_path_env_fallback(self):
  118. b = self._write("b.json", {"instances": 1})
  119. os.environ["EXECUTOR_CONFIG"] = b
  120. self.assertEqual(executor_config.find_config_path(), b)
  121. def test_find_config_path_none(self):
  122. with self._isolate()[0], self._isolate()[1]:
  123. self.assertIsNone(
  124. executor_config.find_config_path("no/such/file.json"))
  125. # ---------- boundary ----------
  126. def test_minimal_instances_ok(self):
  127. cfg = executor_config.load_config()
  128. self.assertGreaterEqual(cfg["instances"], 1)
  129. def test_float_poll_interval_ok(self):
  130. path = self._write("executor_config.json", {"poll_interval": 0.5})
  131. cfg = executor_config.load_config(cli_path=path)
  132. self.assertEqual(cfg["poll_interval"], 0.5)
  133. def test_empty_model_path_allowed(self):
  134. path = self._write("executor_config.json", {"model_path": ""})
  135. cfg = executor_config.load_config(cli_path=path)
  136. self.assertIsNone(cfg["model_path"])
  137. # ---------- abnormal ----------
  138. def test_invalid_json_raises(self):
  139. path = os.path.join(self._tmp, "bad.json")
  140. with open(path, "w", encoding="utf-8") as fh:
  141. fh.write("{ not json !!!")
  142. with self.assertRaises(ValueError):
  143. executor_config.load_config(cli_path=path)
  144. def test_non_dict_json_raises(self):
  145. path = self._write("arr.json", [1, 2, 3])
  146. with self.assertRaises(ValueError):
  147. executor_config.load_config(cli_path=path)
  148. def test_validate_bad_url(self):
  149. with self.assertRaises(ValueError):
  150. executor_config.validate_config({
  151. "web_base_url": "ftp://x", "instances": 1,
  152. "poll_interval": 1, "log_level": "INFO",
  153. "tool": "motorcad", "enable_mock": False})
  154. def test_validate_zero_instances(self):
  155. with self.assertRaises(ValueError):
  156. executor_config.validate_config({
  157. "web_base_url": "http://x", "instances": 0,
  158. "poll_interval": 1, "log_level": "INFO",
  159. "tool": "motorcad", "enable_mock": False})
  160. def test_validate_bad_level(self):
  161. with self.assertRaises(ValueError):
  162. executor_config.validate_config({
  163. "web_base_url": "http://x", "instances": 1,
  164. "poll_interval": 1, "log_level": "VERBOSE",
  165. "tool": "motorcad", "enable_mock": False})
  166. def test_validate_bad_mock_type(self):
  167. with self.assertRaises(ValueError):
  168. executor_config.validate_config({
  169. "web_base_url": "http://x", "instances": 1,
  170. "poll_interval": 1, "log_level": "INFO",
  171. "tool": "motorcad", "enable_mock": "yes"})
  172. # ---------- empty values ----------
  173. def test_empty_file_object_uses_defaults(self):
  174. path = self._write("empty.json", {})
  175. cfg = executor_config.load_config(cli_path=path)
  176. self.assertEqual(cfg["web_base_url"], "http://127.0.0.1:8000")
  177. self.assertEqual(cfg["instances"], 1)
  178. def test_null_file_field_ignored(self):
  179. path = self._write("null.json", {"web_base_url": None, "instances": 2})
  180. cfg = executor_config.load_config(cli_path=path)
  181. self.assertEqual(cfg["web_base_url"], "http://127.0.0.1:8000")
  182. self.assertEqual(cfg["instances"], 2)
  183. def test_empty_env_ignored(self):
  184. os.environ["WEB_BASE_URL"] = ""
  185. os.environ["EXECUTOR_INSTANCES"] = ""
  186. cfg = executor_config.load_config()
  187. self.assertEqual(cfg["web_base_url"], "http://127.0.0.1:8000")
  188. self.assertEqual(cfg["instances"], 1)
  189. if __name__ == "__main__":
  190. unittest.main(verbosity=2)