Skip to content

Commit d9ea907

Browse files
committed
write unit-test for modbus on_attribute updates
1 parent f9bc083 commit d9ea907

File tree

1 file changed

+161
-0
lines changed

1 file changed

+161
-0
lines changed
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# Copyright 2025. ThingsBoard
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from threading import Thread
16+
import asyncio
17+
from unittest.mock import patch, MagicMock, AsyncMock
18+
from tests.unit.connectors.modbus.modbus_base_test import ModbusBaseTestCase
19+
20+
21+
class ModbusOnAttributeUpdatesTestCase(ModbusBaseTestCase):
22+
23+
async def asyncSetUp(self):
24+
await super().asyncSetUp()
25+
self.slave = self.connector._AsyncModbusConnector__slaves[0]
26+
self.slave.uplink_converter_config.is_readable = False
27+
self._loop_thread = Thread(target=self.connector.loop.run_forever, daemon=True)
28+
self._loop_thread.start()
29+
self.slave.downlink_converter = MagicMock(name="downlink")
30+
self.slave.connect = AsyncMock(return_value=True)
31+
self.slave.write = AsyncMock()
32+
33+
def _create_task_on_connector_loop(coro, args, kwargs):
34+
return asyncio.run_coroutine_threadsafe(coro(*args, **kwargs), self.connector.loop)
35+
36+
self._create_task_on_connector_loop = _create_task_on_connector_loop
37+
38+
async def asyncTearDown(self):
39+
self.connector.loop.call_soon_threadsafe(self.connector.loop.stop)
40+
self._loop_thread.join(timeout=1)
41+
await super().asyncTearDown()
42+
43+
async def test_on_attribute_update_success(self):
44+
payload = {'device': 'Demo Device', 'data': {'attr16': 333}}
45+
self.slave.downlink_converter.convert.return_value = [333]
46+
with patch.object(self.connector,
47+
"_AsyncModbusConnector__create_on_attribute_update_task",
48+
side_effect=self._create_task_on_connector_loop) as create_task_mock, \
49+
self.assertLogs("Modbus test", level="DEBUG") as logcap:
50+
self.connector.on_attributes_update(payload)
51+
52+
self.assertEqual(create_task_mock.call_count, 1)
53+
func, args, kwargs = create_task_mock.call_args.args
54+
device, to_process, configuration = args
55+
self.assertIs(device, self.slave)
56+
self.assertEqual(configuration['tag'], 'attr16')
57+
self.assertEqual(configuration['functionCode'], 6)
58+
self.assertEqual(configuration['address'], 4)
59+
self.slave.write.assert_awaited_once_with(6, 4, 333)
60+
self.assertTrue(any("Attribute update processed successfully" in m for m in logcap.output))
61+
62+
async def test_on_attribute_update_multiple_parameters(self):
63+
payload = {'device': 'Demo Device',
64+
'data': {'attr16': 111, 'attrFloat32': 79.99}}
65+
self.slave.downlink_converter.convert.side_effect = [
66+
[111],
67+
[0x70A4, 0x4145]
68+
]
69+
70+
with patch.object(self.connector,
71+
"_AsyncModbusConnector__create_on_attribute_update_task",
72+
side_effect=self._create_task_on_connector_loop) as create_task_mock:
73+
self.connector.on_attributes_update(payload)
74+
self.assertEqual(create_task_mock.call_count, 2)
75+
self.slave.write.assert_any_await(6, 4, 111)
76+
self.slave.write.assert_any_await(16, 5, [0x70A4, 0x4145])
77+
self.assertEqual(self.slave.write.await_count, 2)
78+
79+
call1_configuration = self.slave.downlink_converter.convert.call_args_list[0][0][0]
80+
call2_configuration = self.slave.downlink_converter.convert.call_args_list[1][0][0]
81+
self.assertEqual(call1_configuration.function_code, 6)
82+
self.assertEqual(call1_configuration.address, 4)
83+
self.assertEqual(call2_configuration.function_code, 16)
84+
self.assertEqual(call2_configuration.address, 5)
85+
86+
async def test_on_attribute_update_no_attribute_update_mapping(self):
87+
self.slave.attributes_updates_config = []
88+
payload = {'device': 'Demo Device', 'data': {'attr16': 123}}
89+
90+
with patch.object(self.connector,
91+
"_AsyncModbusConnector__create_on_attribute_update_task") as ct_mock, \
92+
self.assertLogs("Modbus test", level="ERROR") as logcap:
93+
self.connector.on_attributes_update(payload)
94+
95+
ct_mock.assert_not_called()
96+
self.slave.write.assert_not_awaited()
97+
self.assertTrue(any("No attribute mapping found" in m for m in logcap.output))
98+
99+
async def test_on_attribute_update_no_matching_attribute_name_with_config(self):
100+
payload = {'device': 'Demo Device', 'data': {'invalid_attribute': 111}}
101+
102+
with patch.object(self.connector,
103+
"_AsyncModbusConnector__create_on_attribute_update_task") as ct_mock, \
104+
self.assertLogs("Modbus test", level="ERROR") as logcap:
105+
self.connector.on_attributes_update(payload)
106+
107+
ct_mock.assert_not_called()
108+
self.slave.write.assert_not_awaited()
109+
self.assertTrue(any("No attributes found that match attributes section" in m for m in logcap.output))
110+
111+
async def test_on_attribute_update_timeout(self):
112+
payload = {'device': 'Demo Device', 'data': {'attr16': 333}}
113+
fake_task = MagicMock()
114+
fake_task.done.return_value = False
115+
116+
with patch.object(self.connector,
117+
"_AsyncModbusConnector__create_on_attribute_update_task",
118+
return_value=fake_task) as ct_mock, \
119+
patch.object(self.connector,
120+
"_AsyncModbusConnector__wait_task_with_timeout",
121+
return_value=(False, None)) as waiter_mock, \
122+
self.assertLogs("Modbus test", level="ERROR") as logcap:
123+
self.connector.on_attributes_update(payload)
124+
125+
ct_mock.assert_called_once()
126+
waiter_mock.assert_called_once()
127+
self.slave.write.assert_not_awaited()
128+
self.assertTrue(any("timed out" in m.lower() for m in logcap.output))
129+
130+
async def test_on_attribute_update_invalid_data_type(self):
131+
payload = {'device': 'Demo Device', 'data': {'attr16': 'abba'}}
132+
self.slave.downlink_converter.convert.side_effect = ValueError("bad cast")
133+
134+
with patch.object(self.connector,
135+
"_AsyncModbusConnector__create_on_attribute_update_task",
136+
side_effect=self._create_task_on_connector_loop) as ct_mock, \
137+
self.assertLogs("Modbus test", level="ERROR") as logcap:
138+
self.connector.on_attributes_update(payload)
139+
140+
self.assertEqual(ct_mock.call_count, 1)
141+
self.slave.write.assert_not_awaited()
142+
self.assertTrue(any("Could not process attribute update" in m for m in logcap.output))
143+
144+
async def test_on_attribute_update_partial_failure_continues(self):
145+
payload = {'device': 'Demo Device',
146+
'data': {'attr16': 'rrrr', 'attrFloat32': 80.1}}
147+
148+
self.slave.downlink_converter.convert.side_effect = [
149+
ValueError("bad cast"),
150+
[0x70A4, 0x4145]
151+
]
152+
153+
with patch.object(self.connector,
154+
"_AsyncModbusConnector__create_on_attribute_update_task",
155+
side_effect=self._create_task_on_connector_loop) as ct_mock, \
156+
self.assertLogs("Modbus test", level="ERROR") as logcap:
157+
self.connector.on_attributes_update(payload)
158+
159+
self.assertEqual(ct_mock.call_count, 2)
160+
self.slave.write.assert_any_await(16, 5, [0x70A4, 0x4145])
161+
self.assertTrue(any("Could not process attribute update" in m for m in logcap.output))

0 commit comments

Comments
 (0)