|
1 | 1 | from collections.abc import Generator |
2 | 2 | from contextlib import contextmanager |
| 3 | +import threading |
3 | 4 | import time |
4 | 5 | from unittest import mock |
5 | 6 |
|
6 | 7 | from mock_serial import MockSerial # type: ignore[import] |
7 | 8 | import pytest |
| 9 | +import serial |
8 | 10 |
|
9 | 11 | from ynca.connection import YncaConnection, YncaProtocolStatus |
10 | 12 | from ynca.errors import YncaConnectionError, YncaConnectionFailed |
@@ -82,21 +84,69 @@ def test_connect_runtime_error(mock_serial: MockSerial) -> None: |
82 | 84 |
|
83 | 85 |
|
84 | 86 | def test_disconnect() -> None: |
85 | | - mock_serial = MockSerial() |
86 | | - mock_serial.open() |
| 87 | + disconnect_event = threading.Event() |
| 88 | + disconnect_callback = mock.MagicMock(side_effect=lambda: disconnect_event.set()) |
87 | 89 |
|
88 | | - mock_serial.stub( |
89 | | - receive_bytes=b"@SYS:MODELNAME=?\r\n", |
90 | | - send_bytes=b"@SYS:MODELNAME=TESTMODEL\r\n", |
91 | | - ) # Keep alive |
| 90 | + read_should_fail = threading.Event() |
92 | 91 |
|
93 | | - disconnect_callback = mock.MagicMock() |
| 92 | + mock_ser = mock.MagicMock() |
| 93 | + mock_ser.is_open = True |
| 94 | + mock_ser.in_waiting = 0 |
94 | 95 |
|
95 | | - connection = YncaConnection(mock_serial.port) |
96 | | - connection.connect(disconnect_callback=disconnect_callback) |
| 96 | + def controlled_read(_size: int) -> bytes: |
| 97 | + # Block until the test triggers a simulated disconnect, just like a real |
| 98 | + # serial port would block until data arrives or the connection drops. |
| 99 | + read_should_fail.wait(timeout=5.0) |
| 100 | + msg = "Simulated unexpected disconnect" |
| 101 | + raise serial.SerialException(msg) |
97 | 102 |
|
98 | | - mock_serial.close() |
99 | | - time.sleep(SHORT_DELAY) |
| 103 | + mock_ser.read.side_effect = controlled_read |
| 104 | + |
| 105 | + with mock.patch("serial.serial_for_url", return_value=mock_ser): |
| 106 | + connection = YncaConnection("dummy_port") |
| 107 | + connection.connect(disconnect_callback=disconnect_callback) |
| 108 | + |
| 109 | + # Trigger the simulated disconnect; controlled_read raises SerialException |
| 110 | + # which the ReaderThread detects and routes to connection_lost(). |
| 111 | + read_should_fail.set() |
| 112 | + |
| 113 | + assert disconnect_event.wait( |
| 114 | + timeout=2.0 |
| 115 | + ), "Disconnect callback not called within timeout" |
| 116 | + |
| 117 | + assert disconnect_callback.call_count == 1 |
| 118 | + |
| 119 | + |
| 120 | +def test_disconnect_oserror() -> None: |
| 121 | + """OSError from in_waiting (e.g. EIO on PTY/USB disconnect) must still fire the callback. |
| 122 | +
|
| 123 | + pyserial's ReaderThread only catches serial.SerialException inside its read |
| 124 | + loop, so an OSError raised by in_waiting escapes without calling |
| 125 | + connection_lost(). _ReaderThread wraps this case. |
| 126 | + """ |
| 127 | + disconnect_event = threading.Event() |
| 128 | + disconnect_callback = mock.MagicMock(side_effect=lambda: disconnect_event.set()) |
| 129 | + |
| 130 | + in_waiting_should_fail = threading.Event() |
| 131 | + |
| 132 | + mock_ser = mock.MagicMock() |
| 133 | + mock_ser.is_open = True |
| 134 | + |
| 135 | + def controlled_in_waiting() -> int: |
| 136 | + in_waiting_should_fail.wait(timeout=5.0) |
| 137 | + raise OSError(5, "Input/output error") |
| 138 | + |
| 139 | + type(mock_ser).in_waiting = mock.PropertyMock(side_effect=controlled_in_waiting) |
| 140 | + |
| 141 | + with mock.patch("serial.serial_for_url", return_value=mock_ser): |
| 142 | + connection = YncaConnection("dummy_port") |
| 143 | + connection.connect(disconnect_callback=disconnect_callback) |
| 144 | + |
| 145 | + in_waiting_should_fail.set() |
| 146 | + |
| 147 | + assert disconnect_event.wait( |
| 148 | + timeout=2.0 |
| 149 | + ), "Disconnect callback not called within timeout" |
100 | 150 |
|
101 | 151 | assert disconnect_callback.call_count == 1 |
102 | 152 |
|
|
0 commit comments