Skip to content

Commit 0c6ab3a

Browse files
bryamzxzclaude
andauthored
feat: increase coverage threshold to 70% and add dedicated tests (#27)
* feat: increase coverage threshold to 70% and add dedicated tests - Update fail_under from 50 to 70 in pyproject.toml - Add comprehensive test suite for serial_reader.py (23 tests) - Add comprehensive test suite for telegram_notifier.py (29 tests) - Achieved 84.48% total coverage * style: fix black formatting in test_serial.py * style: remove unused import in test_serial.py --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0e0ef8a commit 0c6ab3a

File tree

3 files changed

+731
-1
lines changed

3 files changed

+731
-1
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ branch = true
9191
omit = ["*/tests/*", "*/__main__.py"]
9292

9393
[tool.coverage.report]
94-
fail_under = 50
94+
fail_under = 70
9595
show_missing = true
9696
exclude_lines = [
9797
"pragma: no cover",

tests/test_serial.py

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
"""Tests for sensor_app.serial_reader module."""
2+
3+
from __future__ import annotations
4+
5+
from unittest.mock import MagicMock, patch
6+
7+
import pytest
8+
import serial
9+
10+
from sensor_app.config import Settings
11+
from sensor_app.serial_reader import ArduinoConnection
12+
13+
14+
@pytest.fixture
15+
def settings() -> Settings:
16+
"""Create test settings."""
17+
return Settings(
18+
_env_file=None,
19+
telegram_token="test_token",
20+
telegram_chat_id="123456",
21+
arduino_vid=0x2341,
22+
arduino_pid=0x1002,
23+
max_serial_retries=3,
24+
serial_retry_delay=1,
25+
)
26+
27+
28+
@pytest.fixture
29+
def arduino(settings: Settings) -> ArduinoConnection:
30+
"""Create ArduinoConnection instance."""
31+
return ArduinoConnection(settings)
32+
33+
34+
class TestArduinoConnectionInit:
35+
"""Tests for ArduinoConnection initialization."""
36+
37+
def test_init_sets_settings(self, settings: Settings) -> None:
38+
"""Test that settings are stored on initialization."""
39+
conn = ArduinoConnection(settings)
40+
assert conn.settings is settings
41+
42+
def test_init_serial_is_none(self, arduino: ArduinoConnection) -> None:
43+
"""Test that serial connection starts as None."""
44+
assert arduino._serial is None
45+
46+
47+
class TestArduinoConnectionIsConnected:
48+
"""Tests for is_connected property."""
49+
50+
def test_is_connected_false_when_none(self, arduino: ArduinoConnection) -> None:
51+
"""Test is_connected returns False when serial is None."""
52+
assert arduino.is_connected is False
53+
54+
def test_is_connected_false_when_closed(self, arduino: ArduinoConnection) -> None:
55+
"""Test is_connected returns False when serial is closed."""
56+
mock_serial = MagicMock()
57+
mock_serial.is_open = False
58+
arduino._serial = mock_serial
59+
assert arduino.is_connected is False
60+
61+
def test_is_connected_true_when_open(self, arduino: ArduinoConnection) -> None:
62+
"""Test is_connected returns True when serial is open."""
63+
mock_serial = MagicMock()
64+
mock_serial.is_open = True
65+
arduino._serial = mock_serial
66+
assert arduino.is_connected is True
67+
68+
69+
class TestArduinoConnectionDetectPort:
70+
"""Tests for detect_port method."""
71+
72+
def test_detect_port_found(self, arduino: ArduinoConnection) -> None:
73+
"""Test detect_port returns device when Arduino found."""
74+
mock_port = MagicMock()
75+
mock_port.vid = 0x2341
76+
mock_port.pid = 0x1002
77+
mock_port.device = "/dev/ttyACM0"
78+
79+
with patch("serial.tools.list_ports.comports", return_value=[mock_port]):
80+
result = arduino.detect_port()
81+
assert result == "/dev/ttyACM0"
82+
83+
def test_detect_port_not_found(self, arduino: ArduinoConnection) -> None:
84+
"""Test detect_port returns None when Arduino not found."""
85+
mock_port = MagicMock()
86+
mock_port.vid = 0x1111
87+
mock_port.pid = 0x2222
88+
mock_port.device = "/dev/ttyUSB0"
89+
90+
with patch("serial.tools.list_ports.comports", return_value=[mock_port]):
91+
result = arduino.detect_port()
92+
assert result is None
93+
94+
def test_detect_port_empty_list(self, arduino: ArduinoConnection) -> None:
95+
"""Test detect_port returns None when no ports found."""
96+
with patch("serial.tools.list_ports.comports", return_value=[]):
97+
result = arduino.detect_port()
98+
assert result is None
99+
100+
101+
class TestArduinoConnectionConnect:
102+
"""Tests for connect method."""
103+
104+
def test_connect_success(self, arduino: ArduinoConnection) -> None:
105+
"""Test successful connection to Arduino."""
106+
mock_serial_instance = MagicMock()
107+
108+
with (
109+
patch.object(arduino, "detect_port", return_value="/dev/ttyACM0"),
110+
patch("serial.Serial", return_value=mock_serial_instance),
111+
):
112+
result = arduino.connect()
113+
assert result is mock_serial_instance
114+
assert arduino._serial is mock_serial_instance
115+
mock_serial_instance.reset_input_buffer.assert_called_once()
116+
117+
def test_connect_retries_on_no_port(self, arduino: ArduinoConnection) -> None:
118+
"""Test connect retries when port not detected."""
119+
mock_serial_instance = MagicMock()
120+
call_count = 0
121+
122+
def detect_port_side_effect() -> str | None:
123+
nonlocal call_count
124+
call_count += 1
125+
if call_count < 2:
126+
return None
127+
return "/dev/ttyACM0"
128+
129+
with (
130+
patch.object(arduino, "detect_port", side_effect=detect_port_side_effect),
131+
patch("serial.Serial", return_value=mock_serial_instance),
132+
patch("time.sleep"),
133+
):
134+
result = arduino.connect()
135+
assert result is mock_serial_instance
136+
137+
def test_connect_fails_after_max_retries(self, arduino: ArduinoConnection) -> None:
138+
"""Test connect raises exception after max retries."""
139+
with (
140+
patch.object(arduino, "detect_port", return_value=None),
141+
patch("time.sleep"),
142+
):
143+
with pytest.raises(serial.SerialException, match="Failed to connect"):
144+
arduino.connect(max_retries=2)
145+
146+
def test_connect_handles_serial_exception(self, arduino: ArduinoConnection) -> None:
147+
"""Test connect handles SerialException during open."""
148+
with (
149+
patch.object(arduino, "detect_port", return_value="/dev/ttyACM0"),
150+
patch(
151+
"serial.Serial",
152+
side_effect=serial.SerialException("Port busy"),
153+
),
154+
patch("time.sleep"),
155+
):
156+
with pytest.raises(serial.SerialException, match="Failed to connect"):
157+
arduino.connect(max_retries=2)
158+
159+
160+
class TestArduinoConnectionClose:
161+
"""Tests for close method."""
162+
163+
def test_close_when_open(self, arduino: ArduinoConnection) -> None:
164+
"""Test close properly closes open connection."""
165+
mock_serial = MagicMock()
166+
mock_serial.is_open = True
167+
arduino._serial = mock_serial
168+
169+
arduino.close()
170+
171+
mock_serial.close.assert_called_once()
172+
assert arduino._serial is None
173+
174+
def test_close_when_already_closed(self, arduino: ArduinoConnection) -> None:
175+
"""Test close handles already closed connection."""
176+
mock_serial = MagicMock()
177+
mock_serial.is_open = False
178+
arduino._serial = mock_serial
179+
180+
arduino.close()
181+
182+
mock_serial.close.assert_not_called()
183+
assert arduino._serial is None
184+
185+
def test_close_when_none(self, arduino: ArduinoConnection) -> None:
186+
"""Test close handles None serial gracefully."""
187+
arduino._serial = None
188+
arduino.close()
189+
assert arduino._serial is None
190+
191+
def test_close_handles_exception(self, arduino: ArduinoConnection) -> None:
192+
"""Test close handles exception during close."""
193+
mock_serial = MagicMock()
194+
mock_serial.is_open = True
195+
mock_serial.close.side_effect = Exception("Close error")
196+
arduino._serial = mock_serial
197+
198+
arduino.close()
199+
200+
assert arduino._serial is None
201+
202+
203+
class TestArduinoConnectionReconnect:
204+
"""Tests for reconnect method."""
205+
206+
def test_reconnect_closes_and_connects(self, arduino: ArduinoConnection) -> None:
207+
"""Test reconnect closes existing and creates new connection."""
208+
mock_serial = MagicMock()
209+
mock_serial.is_open = True
210+
arduino._serial = mock_serial
211+
212+
new_serial = MagicMock()
213+
214+
with (
215+
patch.object(arduino, "connect", return_value=new_serial),
216+
patch("time.sleep"),
217+
):
218+
result = arduino.reconnect()
219+
220+
mock_serial.close.assert_called_once()
221+
assert result is new_serial
222+
223+
224+
class TestArduinoConnectionReadLine:
225+
"""Tests for read_line method."""
226+
227+
def test_read_line_success(self, arduino: ArduinoConnection) -> None:
228+
"""Test successful line read."""
229+
mock_serial = MagicMock()
230+
mock_serial.is_open = True
231+
mock_serial.readline.return_value = b"Temperature: 25.5\r\n"
232+
arduino._serial = mock_serial
233+
234+
result = arduino.read_line()
235+
236+
assert result == "Temperature: 25.5"
237+
238+
def test_read_line_not_connected(self, arduino: ArduinoConnection) -> None:
239+
"""Test read_line returns None when not connected."""
240+
arduino._serial = None
241+
result = arduino.read_line()
242+
assert result is None
243+
244+
def test_read_line_handles_exception(self, arduino: ArduinoConnection) -> None:
245+
"""Test read_line handles SerialException."""
246+
mock_serial = MagicMock()
247+
mock_serial.is_open = True
248+
mock_serial.readline.side_effect = serial.SerialException("Read error")
249+
arduino._serial = mock_serial
250+
251+
result = arduino.read_line()
252+
253+
assert result is None
254+
255+
256+
class TestArduinoConnectionReadSensorBlock:
257+
"""Tests for read_sensor_block method."""
258+
259+
def test_read_sensor_block_success(self, arduino: ArduinoConnection) -> None:
260+
"""Test successful block read."""
261+
mock_serial = MagicMock()
262+
mock_serial.is_open = True
263+
mock_serial.timeout = 2
264+
mock_serial.readline.side_effect = [
265+
b"TMP117: 25.5\r\n",
266+
b"BME680: 26.0\r\n",
267+
b"Humidity: 45.0\r\n",
268+
]
269+
arduino._serial = mock_serial
270+
271+
result = arduino.read_sensor_block(num_lines=3)
272+
273+
assert result == ["TMP117: 25.5", "BME680: 26.0", "Humidity: 45.0"]
274+
assert mock_serial.timeout == 2
275+
276+
def test_read_sensor_block_not_connected(self, arduino: ArduinoConnection) -> None:
277+
"""Test read_sensor_block returns None when not connected."""
278+
arduino._serial = None
279+
result = arduino.read_sensor_block()
280+
assert result is None
281+
282+
def test_read_sensor_block_handles_exception(
283+
self, arduino: ArduinoConnection
284+
) -> None:
285+
"""Test read_sensor_block handles SerialException."""
286+
mock_serial = MagicMock()
287+
mock_serial.is_open = True
288+
mock_serial.timeout = 2
289+
mock_serial.readline.side_effect = serial.SerialException("Read error")
290+
arduino._serial = mock_serial
291+
292+
result = arduino.read_sensor_block()
293+
294+
assert result is None
295+
assert mock_serial.timeout == 2

0 commit comments

Comments
 (0)