File: dpt_test.py

package info (click to toggle)
python-xknx 3.6.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 4,012 kB
  • sloc: python: 39,710; javascript: 8,556; makefile: 27; sh: 12
file content (267 lines) | stat: -rw-r--r-- 10,644 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
"""Unit test for KNX binary/integer objects."""

from inspect import isabstract
from typing import Any

import pytest

from xknx.dpt import (
    DPT2ByteFloat,
    DPT2ByteUnsigned,
    DPTActiveEnergy,
    DPTArray,
    DPTBase,
    DPTBinary,
    DPTColorRGBW,
    DPTComplex,
    DPTConsumerProducer,
    DPTEnum,
    DPTHVACContrMode,
    DPTNumeric,
    DPTScaling,
    DPTString,
    DPTTemperature,
)
from xknx.exceptions import CouldNotParseTelegram


class TestDPTBase:
    """Test class for transcoder base object."""

    def test_dpt_abstract_subclasses_ignored(self) -> None:
        """Test if abstract base classes are ignored by dpt_class_tree and __recursive_subclasses__."""
        for dpt in DPTBase.dpt_class_tree():
            assert dpt not in (DPTBase, DPTNumeric, DPTEnum, DPTComplex)
            assert not isabstract(dpt)
            assert dpt()  # test for abstract class - to be removed if instantiation is not allowed anymore

    def test_dpt_concrete_subclasses_included(self) -> None:
        """Test if concrete subclasses are included by dpt_class_tree."""
        for dpt in (
            DPT2ByteFloat,
            DPTString,
            DPTTemperature,
            DPTScaling,
            DPTHVACContrMode,
            DPTColorRGBW,
            DPTConsumerProducer,
        ):
            assert dpt in DPTBase.dpt_class_tree()

    @pytest.mark.parametrize("dpt_class", [DPTString, DPT2ByteFloat])
    def test_dpt_non_abstract_baseclass_included(
        self, dpt_class: type[DPTBase]
    ) -> None:
        """Test if non-abstract base classes is included by dpt_class_tree."""
        assert dpt_class in dpt_class.dpt_class_tree()

    def test_dpt_subclasses_definition_types(self) -> None:
        """Test value_type and dpt_*_number values for correct type in subclasses of DPTBase."""
        for dpt in DPTBase.dpt_class_tree():
            assert isinstance(dpt.value_type, str), (
                f"Wrong type for value_type in {dpt} : {type(dpt.value_type)} - str expected"
            )
            assert dpt.value_type, f"Empty string for value_type in {dpt} not allowed"

            assert isinstance(dpt.dpt_main_number, int), (
                f"Wrong type for dpt_main_number in {dpt} : {type(dpt.dpt_main_number)} - int expected"
            )
            assert dpt.dpt_main_number, (
                f"Zero value for dpt_main_number in {dpt} not allowed"
            )

            assert isinstance(dpt.dpt_sub_number, int | type(None)), (
                f"Wrong type for dpt_sub_number in {dpt} : {type(dpt.dpt_sub_number)} - int or `None` expected"
            )

    def test_dpt_subclasses_no_duplicate_value_types(self) -> None:
        """Test for duplicate value_type values in subclasses of DPTBase."""
        value_types = [
            dpt.value_type
            for dpt in DPTBase.dpt_class_tree()
            if dpt.value_type is not None
        ]
        assert len(value_types) == len(set(value_types)), (
            f"Duplicate DPT value_types found: { {item for item in value_types if value_types.count(item) > 1} }"
        )

    def test_dpt_subclasses_no_duplicate_dpt_number(self) -> None:
        """Test for duplicate value_type values in subclasses of DPTBase."""
        dpt_tuples = [
            (dpt.dpt_main_number, dpt.dpt_sub_number)
            for dpt in DPTBase.dpt_class_tree()
        ]
        assert len(dpt_tuples) == len(set(dpt_tuples)), (
            f"Duplicate DPT numbers found: { {item for item in dpt_tuples if dpt_tuples.count(item) > 1} }"
        )

    @pytest.mark.parametrize(
        "equal_dpts",
        [
            # strings in dictionaries would fail type checking, but should work nevertheless
            ["2byte_unsigned", 7, "DPT-7", {"main": 7}, {"main": "7", "sub": None}],
            ["temperature", "9.001", {"main": 9, "sub": 1}, {"main": "9", "sub": "1"}],
            ["active_energy", "13.010", {"main": 13, "sub": 10}],
            ["consumer_producer", "1.1200", {"main": 1, "sub": 1200}],
        ],
    )
    def test_dpt_alternative_notations(self, equal_dpts: list[Any]) -> None:
        """Test the parser for accepting alternative notations for the same DPT class."""
        parsed = [DPTBase.parse_transcoder(dpt) for dpt in equal_dpts]
        assert issubclass(parsed[0], DPTBase)
        assert all(parsed[0] == dpt for dpt in parsed)

    @pytest.mark.parametrize(
        "equal_dpts",
        [
            # strings in dictionaries would fail type checking, but should work nevertheless
            [
                "2byte_unsigned",
                7,
                "DPT-7",
                {"main": 7},
                {"main": "7", "sub": None},
                DPT2ByteUnsigned,
            ],
            [
                "temperature",
                "9.001",
                {"main": 9, "sub": 1},
                {"main": "9", "sub": "1"},
                DPTTemperature,
            ],
            ["active_energy", "13.010", {"main": 13, "sub": 10}, DPTActiveEnergy],
        ],
    )
    def test_get_dpt_alternative_notations(self, equal_dpts: list[Any]) -> None:
        """Test the parser for accepting alternative notations for the same DPT class."""
        parsed = [DPTBase.get_dpt(dpt) for dpt in equal_dpts]
        assert issubclass(parsed[0], DPTBase)
        assert all(parsed[0] == dpt for dpt in parsed)

    INVALID_DPT_IDENTIFIERS = [
        None,
        0,
        999999999,
        9.001,  # float is not valid
        "invalid_string",
        {"sub": 1},
        {"main": None, "sub": None},
        {"main": "invalid"},
        {"main": 9, "sub": "invalid"},
        [9, 1],
        (9,),
    ]

    @pytest.mark.parametrize("value", INVALID_DPT_IDENTIFIERS)
    def test_parse_transcoder_invalid_data(self, value: Any) -> None:
        """Test parsing invalid data."""
        assert DPTBase.parse_transcoder(value) is None

    @pytest.mark.parametrize("value", INVALID_DPT_IDENTIFIERS)
    def test_get_dpt_invalid_data(self, value: Any) -> None:
        """Test parsing invalid data."""
        with pytest.raises(ValueError):
            DPTBase.get_dpt(value)

    def test_parse_transcoder_from_subclass(self) -> None:
        """Test parsing only subclasses of a DPT class."""
        assert DPTBase.parse_transcoder("string") == DPTString
        assert DPTNumeric.parse_transcoder("string") is None
        assert DPT2ByteFloat.parse_transcoder("string") is None

        assert DPTBase.parse_transcoder("percent") == DPTScaling
        assert DPTNumeric.parse_transcoder("percent") == DPTScaling
        assert DPT2ByteFloat.parse_transcoder("percent") is None

        assert DPTBase.parse_transcoder("temperature") == DPTTemperature
        assert DPTNumeric.parse_transcoder("temperature") == DPTTemperature
        assert DPT2ByteFloat.parse_transcoder("temperature") == DPTTemperature

    def test_get_dpt_from_subclass(self) -> None:
        """Test parsing only subclasses of a DPT class."""
        assert DPTBase.get_dpt("string") == DPTString
        with pytest.raises(ValueError):
            DPTNumeric.get_dpt("string")

        assert DPTBase.get_dpt("percent") == DPTScaling
        assert DPTNumeric.get_dpt("percent") == DPTScaling

        assert DPTBase.get_dpt("temperature") == DPTTemperature
        assert DPTNumeric.get_dpt("temperature") == DPTTemperature
        assert DPT2ByteFloat.get_dpt("temperature") == DPTTemperature

    def test_dpt_name(self) -> None:
        """Test DPT name."""
        assert DPTBase.dpt_name() == "DPTBase (abstract)"
        assert DPTNumeric.dpt_name() == "DPTNumeric (abstract)"
        assert DPT2ByteFloat.dpt_name() == "DPT2ByteFloat (9)"
        assert DPTString.dpt_name() == "DPTString (16.000)"
        assert DPTColorRGBW.dpt_name() == "DPTColorRGBW (251.600)"
        assert DPTConsumerProducer.dpt_name() == "DPTConsumerProducer (1.1200)"


class TestDPTBaseSubclass:
    """Test subclass of transcoder base object."""

    @pytest.mark.parametrize("dpt_class", DPTBase.dpt_class_tree())
    def test_required_values(self, dpt_class: type[DPTBase]) -> None:
        """Test required class variables are set for definitions."""
        assert dpt_class.payload_type in (DPTArray, DPTBinary)
        assert isinstance(dpt_class.payload_length, int)
        assert dpt_class.payload_length, "Payload_length 0 is invalid"
        assert isinstance(dpt_class.dpt_main_number, int)
        assert dpt_class.dpt_main_number, "DPT main number 0 is invalid"
        assert isinstance(dpt_class.dpt_sub_number, int | type(None))
        assert isinstance(dpt_class.value_type, str)
        assert dpt_class.value_type, "Empty string for value_type is invalid"

    def test_validate_payload_array(self) -> None:
        """Test validate_payload method."""

        class DPTArrayTest(DPTBase):
            """Mock class for testing array payloads."""

            payload_type = DPTArray
            payload_length = 2

        with pytest.raises(CouldNotParseTelegram):
            DPTArrayTest.validate_payload(DPTArray((1,)))
        with pytest.raises(CouldNotParseTelegram):
            DPTArrayTest.validate_payload(DPTArray((1, 1, 1)))
        with pytest.raises(CouldNotParseTelegram):
            DPTArrayTest.validate_payload(DPTBinary(1))
        with pytest.raises(CouldNotParseTelegram):
            DPTArrayTest.validate_payload("why?")

        assert DPTArrayTest.validate_payload(DPTArray((1, 1))) == (1, 1)

    def test_validate_payload_binary(self) -> None:
        """Test validate_payload method."""

        class DPTBinaryTest(DPTBase):
            """Mock class for testing binary payloads."""

            payload_type = DPTBinary
            payload_length = 1

        with pytest.raises(CouldNotParseTelegram):
            DPTBinaryTest.validate_payload(DPTArray(1))
        with pytest.raises(CouldNotParseTelegram):
            DPTBinaryTest.validate_payload(DPTArray((1, 1)))
        with pytest.raises(CouldNotParseTelegram):
            DPTBinaryTest.validate_payload("why?")

        assert DPTBinaryTest.validate_payload(DPTBinary(1)) == (1,)


class TestDPTNumeric:
    """Test class for numeric transcoder base object."""

    @pytest.mark.parametrize("dpt_class", DPTNumeric.dpt_class_tree())
    def test_values(self, dpt_class: type[DPTNumeric]) -> None:
        """Test boundary values are set for numeric definitions (because mypy doesn't)."""

        assert isinstance(dpt_class.value_min, int | float)
        assert isinstance(dpt_class.value_max, int | float)
        assert isinstance(dpt_class.resolution, int | float)