Skip to content

Commit 6350edd

Browse files
committed
Refactor tests to use asyncio and AsyncMock for asynchronous behavior
- Updated test_mqtt_commands.py to utilize AsyncMock and asyncio for handling MQTT commands. - Refactored test_set_commands.py to support async operations and ensure proper command handling. - Modified test_transport_tcp.py to replace unittest with pytest and implement async testing for TCP transport. - Enhanced test_version_command.py to use asyncio for simulating command responses and handling timeouts. - Improved mock transport fixtures across tests to support async context management and operations. - Updateed test_version_command - feat: add asyncio support and refactor tests for async behavior
1 parent b212b90 commit 6350edd

File tree

12 files changed

+1031
-761
lines changed

12 files changed

+1031
-761
lines changed

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,7 @@ dependencies = [
1414
]
1515

1616
[tool.pytest.ini_options]
17-
testpaths = ["tests"]
17+
testpaths = ["tests"]
18+
19+
[tool.pytest-asyncio]
20+
mode = "auto"

signalduino/controller.py

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def __init__(
6060
self._write_queue: asyncio.Queue[QueuedCommand] = asyncio.Queue()
6161
self._pending_responses: List[PendingResponse] = []
6262
self._pending_responses_lock = asyncio.Lock()
63+
self._init_complete_event = asyncio.Event() # NEU: Event für den Abschluss der Initialisierung
6364

6465
# Timer-Handles (jetzt asyncio.Task anstelle von threading.Timer)
6566
self._heartbeat_task: Optional[asyncio.Task[Any]] = None
@@ -150,6 +151,8 @@ async def initialize(self) -> None:
150151
self.logger.info("Initializing device...")
151152
self.init_retry_count = 0
152153
self.init_reset_flag = False
154+
self.init_version_response = None
155+
self._init_complete_event.clear() # NEU: Event für erneute Initialisierung zurücksetzen
153156

154157
if self._stop_event.is_set():
155158
self.logger.warning("initialize called but stop event is set.")
@@ -255,6 +258,9 @@ async def _check_version_resp(self, msg: Optional[str]) -> None:
255258

256259
# NEU: Starte Heartbeat-Task
257260
await self._start_heartbeat_task()
261+
262+
# NEU: Signalisiere den Abschluss der Initialisierung
263+
self._init_complete_event.set()
258264

259265
else:
260266
self.logger.warning("StartInit: No valid version response.")
@@ -273,8 +279,11 @@ async def _reset_device(self) -> None:
273279
await asyncio.sleep(2.0)
274280
# NEU: Der Controller ist neu gestartet und muss wieder in den async Kontext eintreten
275281
await self.__aenter__()
276-
282+
277283
# Manuell die Initialisierung starten
284+
self.init_version_response = None
285+
self._init_complete_event.clear() # NEU: Event für erneute Initialisierung zurücksetzen
286+
278287
try:
279288
await self._send_xq()
280289
await self._start_init()
@@ -491,21 +500,25 @@ def on_response(response: str):
491500
# Warte auf das Future mit Timeout
492501
return await asyncio.wait_for(response_future, timeout=timeout)
493502
except asyncio.TimeoutError:
503+
await asyncio.sleep(0) # Gib dem Event-Loop eine Chance, _stop_event zu setzen.
494504
# Code Refactor: Timeout vs. dead connection
495-
if self._stop_event.is_set():
505+
self.logger.debug("Command timeout reached for %s", payload)
506+
# Differentiate between connection drop and normal command timeout
507+
# Check for a closed transport or a stopped controller
508+
if self._stop_event.is_set() or (self.transport and self.transport.closed()):
496509
self.logger.error(
497-
"Command '%s' timed out. Connection appears to be dead (controller stopping).", payload
510+
"Command '%s' timed out. Connection appears to be dead (transport closed or controller stopping).", payload
498511
)
499512
raise SignalduinoConnectionError(
500513
f"Command '{payload}' failed: Connection dropped."
501514
) from None
502-
503-
# Annahme: Transport-API wirft SignalduinoConnectionError bei Trennung.
504-
# Wenn dies nicht der Fall ist, wird ein Timeout angenommen.
505-
self.logger.warning(
506-
"Command '%s' timed out. Treating as no response from device.", payload
507-
)
508-
raise SignalduinoCommandTimeout(f"Command '{payload}' timed out") from None
515+
else:
516+
# Annahme: Transport-API wirft SignalduinoConnectionError bei Trennung.
517+
# Wenn dies nicht der Fall ist, wird ein Timeout angenommen.
518+
self.logger.warning(
519+
"Command '%s' timed out. Treating as no response from device.", payload
520+
)
521+
raise SignalduinoCommandTimeout(f"Command '{payload}' timed out") from None
509522

510523
async def _start_heartbeat_task(self) -> None:
511524
"""Schedules the periodic status heartbeat task."""
@@ -670,17 +683,29 @@ async def run(self, timeout: Optional[float] = None) -> None:
670683
"""
671684
self.logger.info("Starting main controller tasks...")
672685

673-
# 1. Initialisierung starten (führt Versionsprüfung durch und startet Heartbeat)
674-
await self.initialize()
675-
676-
# 2. Haupt-Tasks erstellen und starten
686+
# 1. Haupt-Tasks erstellen und starten (Muss VOR initialize() erfolgen, damit der Reader
687+
# die Initialisierungsantwort empfangen kann)
677688
reader_task = asyncio.create_task(self._reader_task(), name="sd-reader")
678689
parser_task = asyncio.create_task(self._parser_task(), name="sd-parser")
679690
writer_task = asyncio.create_task(self._writer_task(), name="sd-writer")
680691

681692
self._main_tasks = [reader_task, parser_task, writer_task]
693+
694+
# 2. Initialisierung starten (führt Versionsprüfung durch und startet Heartbeat)
695+
await self.initialize()
696+
697+
# 3. Auf den Abschluss der Initialisierung warten (mit zusätzlichem Timeout)
698+
try:
699+
self.logger.info("Waiting for initialization to complete...")
700+
await asyncio.wait_for(self._init_complete_event.wait(), timeout=SDUINO_CMD_TIMEOUT * 2)
701+
self.logger.info("Initialization complete.")
702+
except asyncio.TimeoutError:
703+
self.logger.error("Initialization timed out after %s seconds.", SDUINO_CMD_TIMEOUT * 2)
704+
# Wenn die Initialisierung fehlschlägt, stoppen wir den Controller (aexit)
705+
self._stop_event.set()
706+
# Der Timeout kann dazu führen, dass die await-Kette unterbrochen wird. Wir fahren fort.
682707

683-
# 3. Auf eine der Haupt-Tasks warten (Reader/Writer werden bei Verbindungsabbruch beendet)
708+
# 4. Auf eine der kritischen Haupt-Tasks warten (Reader/Writer werden bei Verbindungsabbruch beendet)
684709
# Parser sollte weiterlaufen, bis die Queue leer ist. Reader/Writer sind die kritischen Tasks.
685710
critical_tasks = [reader_task, writer_task]
686711

signalduino/mqtt.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,8 @@ async def _command_listener(self) -> None:
105105
if "commands" in parts:
106106
cmd_index = parts.index("commands")
107107
if len(parts) > cmd_index + 1:
108-
command_name = parts[cmd_index + 1]
108+
# Nimm den Rest des Pfades als Command-Name (für Unterbefehle wie set/XE)
109+
command_name = "/".join(parts[cmd_index + 1:])
109110
# Callback ist jetzt async
110111
await self.command_callback(command_name, payload)
111112
else:

signalduino/transport.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ async def readline(self) -> Optional[str]: # pragma: no cover - interface
3434
# Wir entfernen das Timeout-Argument, da wir dies mit asyncio.wait_for im Controller handhaben
3535
raise NotImplementedError
3636

37+
def closed(self) -> bool: # pragma: no cover - interface
38+
"""Returns True if the transport is closed, False otherwise."""
39+
raise NotImplementedError
40+
3741
# is_open wird entfernt, da es in async-Umgebungen schwer zu implementieren ist
3842
# und die Transportfehler (SignalduinoConnectionError) zur Beendigung führen.
3943

@@ -66,6 +70,9 @@ async def readline(self) -> Optional[str]:
6670
await asyncio.Future() # Hängt die Coroutine auf
6771
raise NotImplementedError("Asynchronous serial transport is not implemented yet.")
6872

73+
def closed(self) -> bool:
74+
return self._serial is None
75+
6976

7077
class TCPTransport(BaseTransport):
7178
"""Asynchronous TCP transport using asyncio streams."""
@@ -93,6 +100,9 @@ async def close(self) -> None:
93100
self._reader = None
94101
logger.info("TCPTransport closed.")
95102

103+
def closed(self) -> bool:
104+
return self._writer is None
105+
96106
async def write_line(self, data: str) -> None:
97107
if not self._writer:
98108
raise SignalduinoConnectionError("TCPTransport is not open")

tests/conftest.py

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import logging
2-
from unittest.mock import MagicMock
2+
import asyncio
3+
from unittest.mock import MagicMock, Mock, AsyncMock
34

45
import pytest
6+
import pytest_asyncio
57

68
from sd_protocols import SDProtocols
79
from signalduino.types import DecodedMessage
8-
9-
10+
from signalduino.controller import SignalduinoController
1011

1112

1213
@pytest.fixture
@@ -24,4 +25,50 @@ def proto():
2425
def mock_protocols(mocker):
2526
"""Fixture for a mocked SDProtocols instance."""
2627
mock = mocker.patch("signalduino.parser.mc.SDProtocols", autospec=True)
27-
return mock.return_value
28+
return mock.return_value
29+
30+
31+
@pytest.fixture
32+
def mock_transport():
33+
"""Fixture for a mocked async transport layer."""
34+
transport = AsyncMock()
35+
transport.is_open = True
36+
transport.write_line = AsyncMock()
37+
38+
async def aopen_mock():
39+
transport.is_open = True
40+
41+
async def aclose_mock():
42+
transport.is_open = False
43+
44+
transport.aopen.side_effect = aopen_mock
45+
transport.aclose.side_effect = aclose_mock
46+
transport.__aenter__.return_value = transport
47+
transport.__aexit__.return_value = None
48+
transport.readline.return_value = None
49+
return transport
50+
51+
52+
@pytest_asyncio.fixture
53+
async def controller(mock_transport):
54+
"""Fixture for a SignalduinoController with a mocked transport."""
55+
ctrl = SignalduinoController(transport=mock_transport)
56+
57+
# Verwende eine interne Queue, um das Verhalten zu simulieren
58+
# Da die Tests die Queue direkt mocken, lasse ich die Mock-Logik so, wie sie ist.
59+
60+
async def mock_put(queued_command):
61+
# Simulate an immediate async response for commands that expect one.
62+
if queued_command.expect_response and queued_command.on_response:
63+
# For Set-Commands, the response is often an echo of the command itself or 'OK'.
64+
queued_command.on_response(queued_command.payload)
65+
66+
# We mock the queue to directly call the response callback (now async)
67+
ctrl._write_queue = AsyncMock()
68+
ctrl._write_queue.put.side_effect = mock_put
69+
70+
# Da der Controller ein async-Kontextmanager ist, müssen wir ihn im Test
71+
# als solchen verwenden, was nicht in der Fixture selbst geschehen kann.
72+
# Wir geben das Objekt zurück und erwarten, dass der Test await/async with verwendet.
73+
async with ctrl:
74+
yield ctrl

tests/test_connection_drop.py

Lines changed: 62 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import queue
2-
import threading
3-
import time
1+
import asyncio
42
import unittest
5-
from unittest.mock import MagicMock
3+
from unittest.mock import MagicMock, AsyncMock
4+
from typing import Optional
5+
6+
import pytest
67

78
from signalduino.controller import SignalduinoController
89
from signalduino.exceptions import SignalduinoCommandTimeout, SignalduinoConnectionError
@@ -11,72 +12,78 @@
1112
class MockTransport(BaseTransport):
1213
def __init__(self):
1314
self.is_open_flag = False
14-
self.output_queue = queue.Queue()
15+
self.output_queue = asyncio.Queue()
1516

16-
def open(self):
17+
async def aopen(self):
1718
self.is_open_flag = True
1819

19-
def close(self):
20+
async def aclose(self):
2021
self.is_open_flag = False
2122

23+
async def __aenter__(self):
24+
await self.aopen()
25+
return self
26+
27+
async def __aexit__(self, exc_type, exc_val, exc_tb):
28+
await self.aclose()
29+
2230
@property
23-
def is_open(self):
31+
def is_open(self) -> bool:
2432
return self.is_open_flag
33+
34+
def closed(self) -> bool:
35+
return not self.is_open_flag
2536

26-
def write_line(self, data):
37+
async def write_line(self, data: str) -> None:
2738
if not self.is_open_flag:
2839
raise SignalduinoConnectionError("Closed")
2940

30-
def readline(self, timeout=None):
41+
async def readline(self, timeout: Optional[float] = None) -> Optional[str]:
3142
if not self.is_open_flag:
3243
raise SignalduinoConnectionError("Closed")
3344
try:
34-
return self.output_queue.get(timeout=timeout or 0.1)
35-
except queue.Empty:
45+
# await output_queue.get with timeout
46+
line = await asyncio.wait_for(self.output_queue.get(), timeout=timeout or 0.1)
47+
return line
48+
except asyncio.TimeoutError:
3649
return None
3750

38-
class TestConnectionDrop(unittest.TestCase):
39-
def test_timeout_normally(self):
40-
"""Test that a simple timeout raises SignalduinoCommandTimeout."""
41-
transport = MockTransport()
42-
controller = SignalduinoController(transport)
43-
controller.connect()
44-
45-
# Expect SignalduinoCommandTimeout because transport sends nothing
46-
with self.assertRaises(SignalduinoCommandTimeout):
47-
controller.send_command("V", expect_response=True, timeout=0.5)
48-
49-
controller.disconnect()
50-
51-
def test_connection_drop_during_command(self):
52-
"""Test that if connection dies during command wait, we get ConnectionError."""
53-
transport = MockTransport()
54-
controller = SignalduinoController(transport)
55-
controller.connect()
56-
57-
# We need to simulate the reader loop crashing or transport closing
58-
# signalduino controller checks transport.is_open or _stop_event
59-
60-
# Hook into write_line to close transport immediately after sending
61-
# simulating a crash right after send
62-
original_write = transport.write_line
63-
def side_effect(data):
64-
original_write(data)
65-
# Simulate connection loss
66-
transport.close()
67-
# Also set stop event as reader loop would
68-
controller._stop_event.set()
69-
70-
transport.write_line = side_effect
71-
72-
# Current behavior: Raises SignalduinoCommandTimeout because it just waits on queue
73-
# Desired behavior: Raises SignalduinoConnectionError because connection is dead
74-
75-
try:
51+
@pytest.mark.asyncio
52+
async def test_timeout_normally():
53+
"""Test that a simple timeout raises SignalduinoCommandTimeout."""
54+
transport = MockTransport()
55+
controller = SignalduinoController(transport)
56+
57+
# Expect SignalduinoCommandTimeout because transport sends nothing
58+
async with controller:
59+
with pytest.raises(SignalduinoCommandTimeout):
60+
await controller.send_command("V", expect_response=True, timeout=0.5)
61+
62+
63+
@pytest.mark.asyncio
64+
async def test_connection_drop_during_command():
65+
"""Test that if connection dies during command wait, we get ConnectionError."""
66+
transport = MockTransport()
67+
controller = SignalduinoController(transport)
68+
69+
# The synchronous exception handler must be replaced by try/except within an async context
70+
71+
async with controller:
72+
cmd_task = asyncio.create_task(
7673
controller.send_command("V", expect_response=True, timeout=1.0)
77-
except Exception as e:
78-
print(f"Caught exception: {type(e).__name__}: {e}")
79-
# validating what it currently raises
80-
# self.assertIsInstance(e, SignalduinoConnectionError)
74+
)
75+
76+
# Give the command a chance to be sent and be in a waiting state
77+
await asyncio.sleep(0.001)
78+
79+
# Simulate connection loss and cancel main task to trigger cleanup
80+
await transport.aclose()
81+
# controller._main_task.cancel() # Entfernt, da es in der neuen Controller-Version nicht mehr notwendig ist und Fehler verursacht.
82+
83+
# Introduce a small delay to allow the event loop to process the connection drop
84+
# and set the controller's _stop_event before the command times out.
85+
await asyncio.sleep(0.01)
8186

82-
controller.disconnect()
87+
with pytest.raises((SignalduinoConnectionError, asyncio.CancelledError, asyncio.TimeoutError)):
88+
# send_command should raise an exception because the connection is dead
89+
await cmd_task

0 commit comments

Comments
 (0)