test_collector_api.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. from __future__ import annotations
  2. import unittest
  3. from unittest.mock import patch
  4. from data_collector_mcp import collector_api
  5. class CollectorApiTests(unittest.TestCase):
  6. def _patch_project(self):
  7. patches = [
  8. patch(
  9. "data_collector_mcp.collector_api.find_project_config",
  10. return_value={
  11. "project_key": "dev-01",
  12. "data_collector_base_url": "http://collector.test",
  13. },
  14. ),
  15. patch(
  16. "data_collector_mcp.collector_api.resolve_project_token",
  17. return_value="token",
  18. ),
  19. ]
  20. for item in patches:
  21. item.start()
  22. self.addCleanup(item.stop)
  23. def test_create_modbus_device_merges_defaults_and_posts_to_collector(self) -> None:
  24. self._patch_project()
  25. response = {"state": 0, "state_info": "成功"}
  26. with patch(
  27. "data_collector_mcp.collector_api.request_json",
  28. return_value=response,
  29. ) as request_json:
  30. result = collector_api.create_modbus_device(
  31. "dev-01",
  32. {
  33. "name": "modbus_tcp_1",
  34. "device_type": 1,
  35. "ip": "127.0.0.1",
  36. "port": 5502,
  37. "slave_id": 1,
  38. "byte_order": 2,
  39. "word_order": 2,
  40. "address_base": 1,
  41. },
  42. )
  43. self.assertEqual(result, response)
  44. request_json.assert_called_once()
  45. self.assertEqual(request_json.call_args.args[:3], ("POST", "http://collector.test/api/collector/device", "token"))
  46. payload = request_json.call_args.kwargs["json_payload"]
  47. self.assertEqual(payload["type"], "modbus")
  48. self.assertEqual(payload["device_type"], 1)
  49. self.assertEqual(payload["timeout"], 3)
  50. self.assertEqual(payload["alarm_interval"], 90)
  51. self.assertEqual(payload["collect_interval"], 5)
  52. self.assertEqual(payload["byte_order"], 2)
  53. self.assertEqual(payload["word_order"], 2)
  54. self.assertEqual(payload["address_offset"], 1)
  55. self.assertNotIn("address_base", payload)
  56. self.assertEqual(payload["name"], "modbus_tcp_1")
  57. def test_create_modbus_device_requires_required_fields(self) -> None:
  58. self._patch_project()
  59. required_fields = [
  60. "name",
  61. "device_type",
  62. "ip",
  63. "port",
  64. "slave_id",
  65. "word_order",
  66. "byte_order",
  67. "address_base",
  68. ]
  69. base_payload = {
  70. "name": "modbus_tcp_1",
  71. "device_type": 1,
  72. "ip": "127.0.0.1",
  73. "port": 5502,
  74. "slave_id": 1,
  75. "byte_order": 1,
  76. "word_order": 1,
  77. "address_base": 0,
  78. }
  79. for field_name in required_fields:
  80. with self.subTest(field_name=field_name):
  81. payload = dict(base_payload)
  82. payload.pop(field_name)
  83. with self.assertRaisesRegex(ValueError, f"payload.{field_name} is required"):
  84. collector_api.create_modbus_device("dev-01", payload)
  85. def test_edit_modbus_device_maps_aliases_and_posts_to_legacy_endpoint(self) -> None:
  86. self._patch_project()
  87. response = {"state": 0, "state_info": "操作成功", "data": None}
  88. with patch(
  89. "data_collector_mcp.collector_api.request_json",
  90. return_value=response,
  91. ) as request_json:
  92. result = collector_api.edit_modbus_device(
  93. "dev-01",
  94. {
  95. "ori_id": 1,
  96. "name": "modbus_tcp_edited",
  97. "device_type": 1,
  98. "ip": "127.0.0.1",
  99. "port": 5502,
  100. "slave_id": 1,
  101. "byte_order": 2,
  102. "word_order": 2,
  103. "address_base": 1,
  104. "group_id": 10,
  105. },
  106. )
  107. self.assertEqual(result, response)
  108. self.assertEqual(
  109. request_json.call_args.args[:3],
  110. (
  111. "POST",
  112. "http://collector.test/api/collector/modbus/device/edit",
  113. "token",
  114. ),
  115. )
  116. payload = request_json.call_args.kwargs["json_payload"]
  117. self.assertEqual(payload["ori_id"], 1)
  118. self.assertEqual(payload["type"], 1)
  119. self.assertEqual(payload["address_offset"], 1)
  120. self.assertEqual(payload["device_group_id"], 10)
  121. self.assertEqual(payload["timeout"], 3)
  122. self.assertEqual(payload["alarm_interval"], 90)
  123. self.assertEqual(payload["collect_interval"], 5)
  124. self.assertEqual(payload["retry_times"], 0)
  125. self.assertEqual(payload["mode"], 0)
  126. self.assertNotIn("device_type", payload)
  127. self.assertNotIn("address_base", payload)
  128. self.assertNotIn("group_id", payload)
  129. def test_edit_modbus_device_supports_rtu_serial_port(self) -> None:
  130. self._patch_project()
  131. with patch(
  132. "data_collector_mcp.collector_api.request_json",
  133. return_value={"state": 0},
  134. ) as request_json:
  135. collector_api.edit_modbus_device(
  136. "dev-01",
  137. {
  138. "ori_id": 1,
  139. "name": "modbus_rtu_edited",
  140. "type": 2,
  141. "serial_port": "COM3",
  142. "slave_id": 1,
  143. "byte_order": 1,
  144. "word_order": 1,
  145. },
  146. )
  147. payload = request_json.call_args.kwargs["json_payload"]
  148. self.assertEqual(payload["type"], 2)
  149. self.assertEqual(payload["serial_port"], "COM3")
  150. def test_edit_modbus_device_requires_required_fields(self) -> None:
  151. self._patch_project()
  152. base_payload = {
  153. "ori_id": 1,
  154. "name": "modbus_tcp_edited",
  155. "device_type": 1,
  156. "ip": "127.0.0.1",
  157. "port": 5502,
  158. "slave_id": 1,
  159. "byte_order": 1,
  160. "word_order": 1,
  161. }
  162. required_fields = ["ori_id", "name", "device_type", "ip", "port", "slave_id", "byte_order", "word_order"]
  163. for field_name in required_fields:
  164. with self.subTest(field_name=field_name):
  165. payload = dict(base_payload)
  166. payload.pop(field_name)
  167. with self.assertRaisesRegex(ValueError, f"payload.{field_name} is required"):
  168. collector_api.edit_modbus_device("dev-01", payload)
  169. def test_edit_modbus_device_requires_serial_port_for_rtu(self) -> None:
  170. self._patch_project()
  171. with self.assertRaisesRegex(ValueError, "payload.serial_port is required"):
  172. collector_api.edit_modbus_device(
  173. "dev-01",
  174. {
  175. "ori_id": 1,
  176. "name": "modbus_rtu_edited",
  177. "type": 2,
  178. "slave_id": 1,
  179. "byte_order": 1,
  180. "word_order": 1,
  181. },
  182. )
  183. def test_edit_modbus_device_rejects_invalid_connection_type(self) -> None:
  184. self._patch_project()
  185. with self.assertRaisesRegex(ValueError, "payload.type must be one of 1, 2, 3, 4, 5"):
  186. collector_api.edit_modbus_device(
  187. "dev-01",
  188. {
  189. "ori_id": 1,
  190. "name": "modbus_tcp_edited",
  191. "type": 0,
  192. "ip": "127.0.0.1",
  193. "port": 5502,
  194. "slave_id": 1,
  195. "byte_order": 1,
  196. "word_order": 1,
  197. },
  198. )
  199. def test_create_modbus_point_merges_defaults_and_posts_to_collector(self) -> None:
  200. self._patch_project()
  201. response = {"state": 0, "state_info": "成功", "data": None}
  202. with patch(
  203. "data_collector_mcp.collector_api.request_json",
  204. return_value=response,
  205. ) as request_json:
  206. result = collector_api.create_modbus_point(
  207. "dev-01",
  208. {
  209. "device_id": 1,
  210. "name": "holding_register_uint16",
  211. "point_id": "HR_UINT16",
  212. "func_code": 3,
  213. "address": 10,
  214. "type": "uint16",
  215. },
  216. )
  217. self.assertEqual(result, response)
  218. self.assertEqual(
  219. request_json.call_args.args[:3],
  220. (
  221. "POST",
  222. "http://collector.test/api/collector/modbus/point/add_collect_point",
  223. "token",
  224. ),
  225. )
  226. payload = request_json.call_args.kwargs["json_payload"]
  227. self.assertEqual(payload["point_id"], "HR_UINT16")
  228. self.assertEqual(payload["scale_ratio"], 1)
  229. self.assertEqual(payload["value_offset"], 0)
  230. self.assertEqual(payload["group_id"], 0)
  231. self.assertEqual(payload["invalid_values"], "")
  232. self.assertIsNone(payload["valid_range_start"])
  233. self.assertIsNone(payload["valid_range_end"])
  234. self.assertEqual(payload["bit"], 0)
  235. self.assertEqual(payload["func_code"], 3)
  236. def test_create_modbus_point_defaults_point_id_to_empty_string(self) -> None:
  237. self._patch_project()
  238. with patch(
  239. "data_collector_mcp.collector_api.request_json",
  240. return_value={"state": 0},
  241. ) as request_json:
  242. collector_api.create_modbus_point(
  243. "dev-01",
  244. {
  245. "device_id": 1,
  246. "name": "holding_register_uint16",
  247. "func_code": 3,
  248. "address": 10,
  249. "type": "uint16",
  250. },
  251. )
  252. payload = request_json.call_args.kwargs["json_payload"]
  253. self.assertEqual(payload["point_id"], "")
  254. def test_create_modbus_point_normalizes_type_alias_and_register_type(self) -> None:
  255. self._patch_project()
  256. with patch(
  257. "data_collector_mcp.collector_api.request_json",
  258. return_value={"state": 0},
  259. ) as request_json:
  260. collector_api.create_modbus_point(
  261. "dev-01",
  262. {
  263. "device_id": 1,
  264. "name": "temperature",
  265. "point_id": "TEMP",
  266. "register_type": "holding_register",
  267. "address": 10,
  268. "type": "SHORT",
  269. },
  270. )
  271. payload = request_json.call_args.kwargs["json_payload"]
  272. self.assertEqual(payload["func_code"], 3)
  273. self.assertEqual(payload["type"], "int16")
  274. self.assertNotIn("register_type", payload)
  275. def test_create_modbus_point_requires_name(self) -> None:
  276. self._patch_project()
  277. with self.assertRaisesRegex(ValueError, "payload.name is required"):
  278. collector_api.create_modbus_point(
  279. "dev-01",
  280. {
  281. "device_id": 1,
  282. "func_code": 3,
  283. "address": 10,
  284. "type": "int16",
  285. },
  286. )
  287. def test_create_modbus_point_requires_register_type_or_func_code(self) -> None:
  288. self._patch_project()
  289. with self.assertRaisesRegex(ValueError, "payload.register_type is required"):
  290. collector_api.create_modbus_point(
  291. "dev-01",
  292. {
  293. "device_id": 1,
  294. "name": "temperature",
  295. "address": 10,
  296. "type": "int16",
  297. },
  298. )
  299. def test_create_modbus_point_requires_address(self) -> None:
  300. self._patch_project()
  301. with self.assertRaisesRegex(ValueError, "payload.address is required"):
  302. collector_api.create_modbus_point(
  303. "dev-01",
  304. {
  305. "device_id": 1,
  306. "name": "temperature",
  307. "func_code": 3,
  308. "type": "int16",
  309. },
  310. )
  311. def test_create_modbus_point_rejects_unknown_type(self) -> None:
  312. self._patch_project()
  313. with self.assertRaisesRegex(ValueError, "payload.type is invalid"):
  314. collector_api.create_modbus_point(
  315. "dev-01",
  316. {
  317. "device_id": 1,
  318. "name": "temperature",
  319. "func_code": 3,
  320. "address": 10,
  321. "type": "SHORT_REAL",
  322. },
  323. )
  324. def test_edit_modbus_point_merges_defaults_and_posts_to_collector(self) -> None:
  325. self._patch_project()
  326. response = {"state": 0, "state_info": "成功", "data": None}
  327. with patch(
  328. "data_collector_mcp.collector_api.request_json",
  329. return_value=response,
  330. ) as request_json:
  331. result = collector_api.edit_modbus_point(
  332. "dev-01",
  333. {
  334. "ori_id": 101,
  335. "name": "holding_register_uint16_edited",
  336. "point_id": "HR_UINT16_EDITED",
  337. "register_type": "holding_register",
  338. "address": 10,
  339. "type": "WORD",
  340. },
  341. )
  342. self.assertEqual(result, response)
  343. self.assertEqual(
  344. request_json.call_args.args[:3],
  345. (
  346. "POST",
  347. "http://collector.test/api/collector/modbus/point/edit_collect_point",
  348. "token",
  349. ),
  350. )
  351. payload = request_json.call_args.kwargs["json_payload"]
  352. self.assertEqual(payload["ori_id"], 101)
  353. self.assertEqual(payload["point_id"], "HR_UINT16_EDITED")
  354. self.assertEqual(payload["func_code"], 3)
  355. self.assertEqual(payload["type"], "uint16")
  356. self.assertEqual(payload["scale_ratio"], 1)
  357. self.assertEqual(payload["value_offset"], 0)
  358. self.assertEqual(payload["group_id"], 0)
  359. self.assertEqual(payload["invalid_values"], "")
  360. self.assertIsNone(payload["valid_range_start"])
  361. self.assertIsNone(payload["valid_range_end"])
  362. self.assertEqual(payload["bit"], 0)
  363. self.assertNotIn("register_type", payload)
  364. def test_edit_modbus_point_defaults_point_id_to_empty_string(self) -> None:
  365. self._patch_project()
  366. with patch(
  367. "data_collector_mcp.collector_api.request_json",
  368. return_value={"state": 0},
  369. ) as request_json:
  370. collector_api.edit_modbus_point(
  371. "dev-01",
  372. {
  373. "ori_id": 101,
  374. "name": "holding_register_uint16_edited",
  375. "func_code": 3,
  376. "address": 10,
  377. "type": "uint16",
  378. },
  379. )
  380. payload = request_json.call_args.kwargs["json_payload"]
  381. self.assertEqual(payload["point_id"], "")
  382. def test_edit_modbus_point_requires_ori_id(self) -> None:
  383. self._patch_project()
  384. with self.assertRaisesRegex(ValueError, "payload.ori_id is required"):
  385. collector_api.edit_modbus_point(
  386. "dev-01",
  387. {
  388. "name": "holding_register_uint16_edited",
  389. "func_code": 3,
  390. "address": 10,
  391. "type": "uint16",
  392. },
  393. )
  394. def test_list_devices_defaults_num_points_false(self) -> None:
  395. self._patch_project()
  396. response = {"state": 0, "devices": []}
  397. with patch(
  398. "data_collector_mcp.collector_api.request_json",
  399. return_value=response,
  400. ) as request_json:
  401. result = collector_api.list_devices("dev-01")
  402. self.assertEqual(result, response)
  403. request_json.assert_called_once_with(
  404. "GET",
  405. "http://collector.test/api/collector/device?num_points=false",
  406. "token",
  407. json_payload=None,
  408. )
  409. def test_list_devices_can_enable_num_points(self) -> None:
  410. self._patch_project()
  411. with patch(
  412. "data_collector_mcp.collector_api.request_json",
  413. return_value={"state": 0},
  414. ) as request_json:
  415. collector_api.list_devices("dev-01", num_points=True)
  416. self.assertEqual(
  417. request_json.call_args.args[1],
  418. "http://collector.test/api/collector/device?num_points=true",
  419. )
  420. def test_connect_device_posts_connected_status(self) -> None:
  421. self._patch_project()
  422. response = {"state": 0, "data": {"status": 2, "running_status": 0}}
  423. with patch(
  424. "data_collector_mcp.collector_api.request_json",
  425. return_value=response,
  426. ) as request_json:
  427. result = collector_api.connect_device("dev-01", device_id=1, device_type="modbus")
  428. self.assertEqual(result, response)
  429. request_json.assert_called_once_with(
  430. "POST",
  431. "http://collector.test/api/collector/common/device/set_connect_status",
  432. "token",
  433. json_payload={"id": 1, "type": "modbus", "status": 2},
  434. )
  435. def test_disconnect_device_posts_disconnected_status(self) -> None:
  436. self._patch_project()
  437. response = {"state": 0, "data": {"status": 1, "running_status": 0}}
  438. with patch(
  439. "data_collector_mcp.collector_api.request_json",
  440. return_value=response,
  441. ) as request_json:
  442. result = collector_api.disconnect_device("dev-01", device_id=1, device_type="modbus")
  443. self.assertEqual(result, response)
  444. request_json.assert_called_once_with(
  445. "POST",
  446. "http://collector.test/api/collector/common/device/set_connect_status",
  447. "token",
  448. json_payload={"id": 1, "type": "modbus", "status": 1},
  449. )
  450. def test_list_device_points_posts_device_and_group(self) -> None:
  451. self._patch_project()
  452. response = {"state": 0, "data": {"point": [], "total": 0}}
  453. with patch(
  454. "data_collector_mcp.collector_api.request_json",
  455. return_value=response,
  456. ) as request_json:
  457. result = collector_api.list_device_points(
  458. "dev-01",
  459. device_id=1,
  460. device_type="modbus",
  461. group_id=100,
  462. )
  463. self.assertEqual(result, response)
  464. request_json.assert_called_once_with(
  465. "POST",
  466. "http://collector.test/api/collector/common/device/get_collect_point",
  467. "token",
  468. json_payload={"id": 1, "type": "modbus", "group_id": 100},
  469. )
  470. def test_device_common_tools_validate_ids(self) -> None:
  471. self._patch_project()
  472. with self.assertRaisesRegex(ValueError, "device_id must be a positive integer"):
  473. collector_api.connect_device("dev-01", device_id=0)
  474. with self.assertRaisesRegex(ValueError, "group_id must be a non-negative integer"):
  475. collector_api.list_device_points("dev-01", device_id=1, group_id=-1)
  476. with self.assertRaisesRegex(ValueError, "device_type is required"):
  477. collector_api.disconnect_device("dev-01", device_id=1, device_type="")
  478. if __name__ == "__main__":
  479. unittest.main()