This commit is contained in:
2026-08-05 08:19:29 +08:00
2 changed files with 237 additions and 11 deletions
+183
View File
@@ -0,0 +1,183 @@
# -*- coding: utf-8 -*-
"""S7-1200 驱动参数化适配单元测试(issue #24)。
snap7 为现场 PLC 依赖,CI 环境不可安装,故用注入 fake client 的方式
验证:地址解析、按 dataType 解码(bool/int/float)、容错与配置项。
"""
import struct
import unittest
from unittest.mock import MagicMock
from drivers.s7_driver import S7Driver, _S7_NODE_RE
from point_dict.loader import Point
def _point(node: str, dtype: str = "float", pid: str = "S7-01.X") -> Point:
return Point(
device_id="S7-01",
point_id=pid,
name="测点",
unit="℃",
data_type=dtype,
sample_rate=1000,
quality_code=True,
opc_node=node,
protocol="s7",
row_number=2,
)
class _FakeClient:
"""模拟 snap7 client:按预置 {(db, start, size): bytes} 返回区域数据。"""
def __init__(self, area_map):
# area_map: {(db, byte_offset, size): bytes}
self.area_map = area_map
self.read_area = MagicMock(side_effect=self._read_area)
def _read_area(self, area, db, start, size):
assert area == 0x84, "S7 只读应固定 DB 区(0x84)"
return self.area_map[(db, start, size)]
def disconnect(self):
pass
class S7NodeRegexTest(unittest.TestCase):
"""opcNode 地址格式解析。"""
def test_parse_db_byte(self):
m = _S7_NODE_RE.match("DB100.4")
self.assertIsNotNone(m)
self.assertEqual((m.group(1), m.group(2), m.group(3)), ("100", "4", None))
def test_parse_db_byte_bit(self):
m = _S7_NODE_RE.match("DB100.4.3")
self.assertIsNotNone(m)
self.assertEqual((m.group(1), m.group(2), m.group(3)), ("100", "4", "3"))
def test_reject_non_s7_node(self):
self.assertIsNone(_S7_NODE_RE.match("ns=2;s=Temp"))
self.assertIsNone(_S7_NODE_RE.match("holding:40001"))
self.assertIsNone(_S7_NODE_RE.match(""))
class S7ConfigTest(unittest.TestCase):
"""配置项参数化。"""
def test_defaults(self):
d = S7Driver({})
self.assertEqual(d.port, 102)
self.assertEqual((d.rack, d.slot), (0, 1))
self.assertEqual(d.timeout, 10)
def test_custom(self):
d = S7Driver({"ip": "192.168.1.10", "port": 10102, "rack": 0, "slot": 2})
self.assertEqual(d.ip, "192.168.1.10")
self.assertEqual(d.port, 10102)
self.assertEqual(d.slot, 2)
def test_protocol_name(self):
self.assertEqual(S7Driver.protocol, "s7")
def test_connect_requires_ip(self):
d = S7Driver({})
with self.assertRaises(ConnectionError):
d.connect()
class S7ReadTest(unittest.TestCase):
"""read_points 按 dataType 解码。"""
def _driver_with_map(self, area_map):
d = S7Driver({"ip": "1.2.3.4"})
d._client = _FakeClient(area_map)
return d
def test_read_float_real(self):
# DB100.0 → 4 字节 REAL,编码 25.5
raw = struct.pack(">f", 25.5)
d = self._driver_with_map({(100, 0, 4): raw})
out = d.read_points([_point("DB100.0", "float", "S7.TEMP")])
self.assertAlmostEqual(out["S7.TEMP"], 25.5, places=4)
def test_read_int_word(self):
# DB200.2 → 2 字节有符号 INT,编码 -1234
raw = struct.pack(">h", -1234)
d = self._driver_with_map({(200, 2, 2): raw})
out = d.read_points([_point("DB200.2", "int", "S7.PRES")])
self.assertEqual(out["S7.PRES"], -1234)
def test_read_bool_bit(self):
# DB300.0.2 → 字节 0x04(第 2 位为 1)
d = self._driver_with_map({(300, 0, 1): bytes([0x04])})
out = d.read_points([_point("DB300.0.2", "bool", "S7.RUN")])
self.assertIs(out["S7.RUN"], True)
def test_read_bool_zero(self):
d = self._driver_with_map({(300, 0, 1): bytes([0x00])})
out = d.read_points([_point("DB300.0.5", "bool", "S7.IDLE")])
self.assertIs(out["S7.IDLE"], False)
def test_mixed_batch(self):
area = {
(100, 0, 4): struct.pack(">f", 100.0), # float @ DB100.0
(100, 4, 2): struct.pack(">h", 7), # int @ DB100.4
(100, 6, 1): bytes([0x80]), # bool @ DB100.6.7
}
d = self._driver_with_map(area)
pts = [
_point("DB100.0", "float", "A"),
_point("DB100.4", "int", "B"),
_point("DB100.6.7", "bool", "C"),
]
out = d.read_points(pts)
self.assertAlmostEqual(out["A"], 100.0, places=3)
self.assertEqual(out["B"], 7)
self.assertIs(out["C"], True)
def test_non_s7_node_skipped(self):
d = self._driver_with_map({})
out = d.read_points([_point("ns=2;s=x", "float", "X")])
self.assertNotIn("X", out)
def test_read_failure_yields_none_not_raise(self):
# read_area 抛异常 → 该点 None,整批不中断
d = S7Driver({"ip": "1.2.3.4"})
client = MagicMock()
client.read_area.side_effect = RuntimeError("PLC 断连")
d._client = client
out = d.read_points([_point("DB100.0", "float", "BAD")])
self.assertIsNone(out["BAD"])
def test_default_dtype_float_when_missing(self):
raw = struct.pack(">f", 1.25)
d = self._driver_with_map({(100, 0, 4): raw})
# data_type 留空 → 走 float 解码
p = _point("DB100.0", "", "Z")
out = d.read_points([p])
self.assertAlmostEqual(out["Z"], 1.25, places=4)
def test_read_before_connect_raises(self):
d = S7Driver({"ip": "1.2.3.4"})
with self.assertRaises(ConnectionError):
d.read_points([_point("DB100.0", "float", "X")])
class S7CloseTest(unittest.TestCase):
def test_close_resets_client(self):
d = S7Driver({"ip": "1.2.3.4"})
client = MagicMock()
d._client = client
d.close()
client.disconnect.assert_called_once()
self.assertIsNone(d._client)
def test_close_idempotent(self):
d = S7Driver({"ip": "1.2.3.4"})
d.close() # 未连接,不报错
self.assertIsNone(d._client)
if __name__ == "__main__":
unittest.main()