Skip to content

Commit 28b271b

Browse files
gijzelaerrclaude
andcommitted
Add s7 unified client/server tests, fix mypy issues
24 new tests for s7.Client and s7.Server using the built-in server emulator (no real PLC needed): - Legacy protocol: connect, db_read, db_write, db_read_multi, list_datablocks, context manager, repr, delegated methods, diagnostic buffer, attribute errors - S7CommPlus guards: browse/explore/subscription require S7CommPlus - Server: context manager, register_db, register_raw_db, get_db, property accessors - Protocol enum values Coverage improvements: - s7/client.py: 21% -> 77% - s7/server.py: 43% -> 84% Also fixed mypy issues in test_logging.py (unused type-ignore comments, missing pytest import). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 701cdd6 commit 28b271b

2 files changed

Lines changed: 297 additions & 7 deletions

File tree

tests/test_logging.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,20 @@
33
import json
44
import logging
55

6+
import pytest
7+
68
from snap7.log import PLCLoggerAdapter, OperationLogger, JSONFormatter
79

810

911
class TestPLCLoggerAdapter:
10-
def test_prefix_added(self, caplog: logging.LogRecord) -> None: # type: ignore[type-arg]
12+
def test_prefix_added(self, caplog: pytest.LogCaptureFixture) -> None:
1113
base = logging.getLogger("test.adapter")
1214
adapter = PLCLoggerAdapter(base, plc_host="10.0.0.1", rack=0, slot=1)
1315
with caplog.at_level(logging.INFO, logger="test.adapter"):
1416
adapter.info("Connected")
1517
assert "[10.0.0.1 R0/S1] Connected" in caplog.text
1618

17-
def test_no_prefix_without_host(self, caplog: logging.LogRecord) -> None: # type: ignore[type-arg]
19+
def test_no_prefix_without_host(self, caplog: pytest.LogCaptureFixture) -> None:
1820
base = logging.getLogger("test.nohost")
1921
adapter = PLCLoggerAdapter(base)
2022
with caplog.at_level(logging.INFO, logger="test.nohost"):
@@ -40,7 +42,7 @@ def test_update_context_partial(self) -> None:
4042

4143

4244
class TestOperationLogger:
43-
def test_logs_timing(self, caplog: logging.LogRecord) -> None: # type: ignore[type-arg]
45+
def test_logs_timing(self, caplog: pytest.LogCaptureFixture) -> None:
4446
base = logging.getLogger("test.oplog")
4547
with caplog.at_level(logging.DEBUG, logger="test.oplog"):
4648
with OperationLogger(base, "db_read", db=1, start=0, size=4):
@@ -49,7 +51,7 @@ def test_logs_timing(self, caplog: logging.LogRecord) -> None: # type: ignore[t
4951
assert "db=1" in caplog.text
5052
assert "ms)" in caplog.text
5153

52-
def test_works_with_adapter(self, caplog: logging.LogRecord) -> None: # type: ignore[type-arg]
54+
def test_works_with_adapter(self, caplog: pytest.LogCaptureFixture) -> None:
5355
base = logging.getLogger("test.oplog_adapter")
5456
adapter = PLCLoggerAdapter(base, plc_host="10.0.0.1", rack=0, slot=1)
5557
with caplog.at_level(logging.DEBUG, logger="test.oplog_adapter"):
@@ -90,9 +92,9 @@ def test_plc_context_included(self) -> None:
9092
args=None,
9193
exc_info=None,
9294
)
93-
record.plc_host = "192.168.1.10" # type: ignore[attr-defined]
94-
record.plc_rack = 0 # type: ignore[attr-defined]
95-
record.plc_slot = 1 # type: ignore[attr-defined]
95+
record.plc_host = "192.168.1.10"
96+
record.plc_rack = 0
97+
record.plc_slot = 1
9698
output = formatter.format(record)
9799
data = json.loads(output)
98100
assert data["plc_host"] == "192.168.1.10"

tests/test_s7_unified.py

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
"""Tests for the unified s7.Client and s7.Server using the server emulator.
2+
3+
No real PLC is needed — these tests exercise the full s7 package using the
4+
built-in S7CommPlus and legacy server emulators.
5+
"""
6+
7+
import random
8+
import struct
9+
import time
10+
from ctypes import c_char
11+
12+
import pytest
13+
14+
from s7 import Client, Server, Protocol, SymbolTable
15+
from s7._protocol import Protocol as Proto
16+
from snap7.type import SrvArea
17+
18+
19+
# ---------------------------------------------------------------------------
20+
# Fixtures
21+
# ---------------------------------------------------------------------------
22+
23+
LEGACY_PORT = random.randint(20000, 30000)
24+
S7PLUS_PORT = random.randint(30001, 40000)
25+
26+
27+
@pytest.fixture(scope="module")
28+
def unified_server(): # type: ignore[no-untyped-def]
29+
"""Start a unified server with both legacy and S7CommPlus."""
30+
srv = Server()
31+
32+
# Register DB1 on the legacy server
33+
db1_data = bytearray(100)
34+
struct.pack_into(">f", db1_data, 0, 23.5)
35+
struct.pack_into(">h", db1_data, 4, 42)
36+
db1_data[6] = 0xFF
37+
db1_array = (c_char * 100).from_buffer(db1_data)
38+
srv.legacy_server.register_area(SrvArea.DB, 1, db1_array)
39+
40+
# Register DB2 on the legacy server (read-write)
41+
db2_data = bytearray(100)
42+
db2_array = (c_char * 100).from_buffer(db2_data)
43+
srv.legacy_server.register_area(SrvArea.DB, 2, db2_array)
44+
45+
# Register Merker area
46+
mk_data = bytearray(100)
47+
mk_array = (c_char * 100).from_buffer(mk_data)
48+
srv.legacy_server.register_area(SrvArea.MK, 0, mk_array)
49+
50+
# Register S7CommPlus DBs
51+
srv.register_raw_db(1, bytearray(db1_data))
52+
srv.register_raw_db(2, bytearray(100))
53+
54+
srv.start(tcp_port=LEGACY_PORT, s7commplus_port=S7PLUS_PORT)
55+
time.sleep(0.2)
56+
57+
yield srv
58+
59+
srv.stop()
60+
61+
62+
# ---------------------------------------------------------------------------
63+
# Legacy protocol tests
64+
# ---------------------------------------------------------------------------
65+
66+
67+
class TestUnifiedClientLegacy:
68+
"""Test s7.Client with legacy protocol via emulator."""
69+
70+
def test_connect_legacy(self, unified_server: Server) -> None:
71+
client = Client()
72+
client.connect("127.0.0.1", 0, 0, LEGACY_PORT, protocol=Protocol.LEGACY)
73+
assert client.connected
74+
assert client.protocol == Protocol.LEGACY
75+
client.disconnect()
76+
77+
def test_db_read(self, unified_server: Server) -> None:
78+
client = Client()
79+
client.connect("127.0.0.1", 0, 0, LEGACY_PORT, protocol=Protocol.LEGACY)
80+
try:
81+
data = client.db_read(1, 0, 4)
82+
value = struct.unpack(">f", data)[0]
83+
assert abs(value - 23.5) < 0.01
84+
finally:
85+
client.disconnect()
86+
87+
def test_db_write_read(self, unified_server: Server) -> None:
88+
client = Client()
89+
client.connect("127.0.0.1", 0, 0, LEGACY_PORT, protocol=Protocol.LEGACY)
90+
try:
91+
client.db_write(2, 0, bytearray(struct.pack(">f", 99.9)))
92+
data = client.db_read(2, 0, 4)
93+
value = struct.unpack(">f", data)[0]
94+
assert abs(value - 99.9) < 0.01
95+
finally:
96+
client.disconnect()
97+
98+
def test_db_read_multi(self, unified_server: Server) -> None:
99+
client = Client()
100+
client.connect("127.0.0.1", 0, 0, LEGACY_PORT, protocol=Protocol.LEGACY)
101+
try:
102+
results = client.db_read_multi([(1, 0, 4), (1, 4, 2)])
103+
assert len(results) == 2
104+
assert len(results[0]) == 4
105+
assert len(results[1]) == 2
106+
finally:
107+
client.disconnect()
108+
109+
def test_list_datablocks(self, unified_server: Server) -> None:
110+
client = Client()
111+
client.connect("127.0.0.1", 0, 0, LEGACY_PORT, protocol=Protocol.LEGACY)
112+
try:
113+
dbs = client.list_datablocks()
114+
assert isinstance(dbs, list)
115+
# Legacy fallback returns list of dicts with "name", "number"
116+
numbers = [db["number"] for db in dbs]
117+
assert 1 in numbers
118+
assert 2 in numbers
119+
finally:
120+
client.disconnect()
121+
122+
def test_context_manager(self, unified_server: Server) -> None:
123+
with Client() as client:
124+
client.connect("127.0.0.1", 0, 0, LEGACY_PORT, protocol=Protocol.LEGACY)
125+
assert client.connected
126+
data = client.db_read(1, 0, 4)
127+
assert len(data) == 4
128+
assert not client.connected
129+
130+
def test_repr(self, unified_server: Server) -> None:
131+
client = Client()
132+
assert "disconnected" in repr(client)
133+
client.connect("127.0.0.1", 0, 0, LEGACY_PORT, protocol=Protocol.LEGACY)
134+
assert "127.0.0.1" in repr(client)
135+
client.disconnect()
136+
137+
def test_delegated_methods(self, unified_server: Server) -> None:
138+
"""Methods delegated via __getattr__ to legacy client."""
139+
client = Client()
140+
client.connect("127.0.0.1", 0, 0, LEGACY_PORT, protocol=Protocol.LEGACY)
141+
try:
142+
info = client.get_cpu_info()
143+
assert info is not None
144+
state = client.get_cpu_state()
145+
assert state is not None
146+
finally:
147+
client.disconnect()
148+
149+
def test_read_diagnostic_buffer(self, unified_server: Server) -> None:
150+
client = Client()
151+
client.connect("127.0.0.1", 0, 0, LEGACY_PORT, protocol=Protocol.LEGACY)
152+
try:
153+
# Server emulator doesn't support SZL 0x00A0, so expect RuntimeError
154+
with pytest.raises(RuntimeError):
155+
client.read_diagnostic_buffer()
156+
finally:
157+
client.disconnect()
158+
159+
def test_getattr_not_connected(self) -> None:
160+
client = Client()
161+
with pytest.raises(AttributeError):
162+
client.nonexistent_method()
163+
164+
def test_getattr_private_raises(self, unified_server: Server) -> None:
165+
client = Client()
166+
client.connect("127.0.0.1", 0, 0, LEGACY_PORT, protocol=Protocol.LEGACY)
167+
try:
168+
with pytest.raises(AttributeError):
169+
client._private_method # noqa: B018
170+
finally:
171+
client.disconnect()
172+
173+
174+
# ---------------------------------------------------------------------------
175+
# S7CommPlus protocol tests (via emulator)
176+
# ---------------------------------------------------------------------------
177+
178+
179+
class TestUnifiedClientS7CommPlus:
180+
"""Test s7.Client with S7CommPlus protocol via emulator."""
181+
182+
def test_connect_s7commplus(self, unified_server: Server) -> None:
183+
client = Client()
184+
# S7CommPlus try will fail (emulator doesn't support full handshake
185+
# from unified client), so it should fallback to legacy
186+
client.connect("127.0.0.1", 0, 0, LEGACY_PORT)
187+
assert client.connected
188+
# Protocol depends on whether S7CommPlus handshake succeeds
189+
assert client.protocol in (Protocol.LEGACY, Protocol.S7COMMPLUS)
190+
client.disconnect()
191+
192+
def test_force_s7commplus_fails_without_server(self) -> None:
193+
"""Forcing S7CommPlus when no S7CommPlus server is available raises."""
194+
client = Client()
195+
port = random.randint(40001, 50000)
196+
with pytest.raises(Exception):
197+
client.connect("127.0.0.1", 0, 0, port, protocol=Protocol.S7COMMPLUS)
198+
199+
def test_browse_requires_s7commplus(self, unified_server: Server) -> None:
200+
client = Client()
201+
client.connect("127.0.0.1", 0, 0, LEGACY_PORT, protocol=Protocol.LEGACY)
202+
try:
203+
with pytest.raises(RuntimeError, match="requires S7CommPlus"):
204+
client.browse()
205+
finally:
206+
client.disconnect()
207+
208+
def test_explore_requires_s7commplus(self, unified_server: Server) -> None:
209+
client = Client()
210+
client.connect("127.0.0.1", 0, 0, LEGACY_PORT, protocol=Protocol.LEGACY)
211+
try:
212+
with pytest.raises(RuntimeError, match="requires S7CommPlus"):
213+
client.explore()
214+
finally:
215+
client.disconnect()
216+
217+
def test_subscription_requires_s7commplus(self, unified_server: Server) -> None:
218+
client = Client()
219+
client.connect("127.0.0.1", 0, 0, LEGACY_PORT, protocol=Protocol.LEGACY)
220+
try:
221+
with pytest.raises(RuntimeError, match="requires S7CommPlus"):
222+
client.create_subscription([(1, 0, 4)])
223+
with pytest.raises(RuntimeError, match="requires S7CommPlus"):
224+
client.delete_subscription(0x1234)
225+
finally:
226+
client.disconnect()
227+
228+
229+
# ---------------------------------------------------------------------------
230+
# Unified server tests
231+
# ---------------------------------------------------------------------------
232+
233+
234+
class TestUnifiedServer:
235+
"""Test s7.Server features."""
236+
237+
def test_server_context_manager(self) -> None:
238+
port = random.randint(50001, 55000)
239+
with Server() as srv:
240+
srv.legacy_server.register_area(SrvArea.DB, 1, (c_char * 10).from_buffer(bytearray(10)))
241+
srv.start(tcp_port=port)
242+
client = Client()
243+
client.connect("127.0.0.1", 0, 0, port, protocol=Protocol.LEGACY)
244+
data = client.db_read(1, 0, 4)
245+
assert len(data) == 4
246+
client.disconnect()
247+
248+
def test_register_raw_db(self) -> None:
249+
srv = Server()
250+
db = srv.register_raw_db(5, bytearray(b"\x01\x02\x03\x04"))
251+
assert db.read(0, 4) == b"\x01\x02\x03\x04"
252+
253+
def test_register_db(self) -> None:
254+
srv = Server()
255+
db = srv.register_db(3, {"temp": ("Real", 0), "count": ("Int", 4)})
256+
assert "temp" in db.variables
257+
assert "count" in db.variables
258+
259+
def test_get_db(self) -> None:
260+
srv = Server()
261+
srv.register_raw_db(7, bytearray(10))
262+
db = srv.get_db(7)
263+
assert db is not None
264+
assert db.number == 7
265+
266+
def test_get_db_missing(self) -> None:
267+
srv = Server()
268+
assert srv.get_db(999) is None
269+
270+
def test_legacy_server_property(self) -> None:
271+
srv = Server()
272+
assert srv.legacy_server is not None
273+
274+
def test_s7commplus_server_property(self) -> None:
275+
srv = Server()
276+
assert srv.s7commplus_server is not None
277+
278+
279+
# ---------------------------------------------------------------------------
280+
# Protocol enum tests
281+
# ---------------------------------------------------------------------------
282+
283+
284+
class TestProtocol:
285+
def test_protocol_values(self) -> None:
286+
assert Proto.AUTO.value == "auto"
287+
assert Proto.LEGACY.value == "legacy"
288+
assert Proto.S7COMMPLUS.value == "s7commplus"

0 commit comments

Comments
 (0)