xref: /aosp_15_r20/external/pigweed/pw_sensor/py/validator_test.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
1# Copyright 2024 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://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, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""Unit tests for the sensor metadata validator"""
15
16from pathlib import Path
17import unittest
18import tempfile
19import yaml
20from pw_sensor.validator import Validator
21
22
23class ValidatorTest(unittest.TestCase):
24    """Tests the Validator class."""
25
26    maxDiff = None
27
28    def test_missing_compatible(self) -> None:
29        """Check that missing 'compatible' key throws exception"""
30        self._check_with_exception(
31            metadata={},
32            exception_string="ERROR: Malformed sensor metadata YAML:\n{}",
33            cause_substrings=["'compatible' is a required property"],
34        )
35
36    def test_invalid_compatible_type(self) -> None:
37        """Check that incorrect type of 'compatible' throws exception"""
38        self._check_with_exception(
39            metadata={"compatible": {}, "supported-buses": ["i2c"]},
40            exception_string=(
41                "ERROR: Malformed sensor metadata YAML:\ncompatible: {}\n"
42                + "supported-buses:\n- i2c"
43            ),
44            cause_substrings=[
45                "'org' is a required property",
46            ],
47        )
48
49        self._check_with_exception(
50            metadata={"compatible": [], "supported-buses": ["i2c"]},
51            exception_string=(
52                "ERROR: Malformed sensor metadata YAML:\ncompatible: []\n"
53                + "supported-buses:\n- i2c"
54            ),
55            cause_substrings=["[] is not of type 'object'"],
56        )
57
58        self._check_with_exception(
59            metadata={"compatible": 1, "supported-buses": ["i2c"]},
60            exception_string=(
61                "ERROR: Malformed sensor metadata YAML:\ncompatible: 1\n"
62                + "supported-buses:\n- i2c"
63            ),
64            cause_substrings=["1 is not of type 'object'"],
65        )
66
67        self._check_with_exception(
68            metadata={"compatible": "", "supported-buses": ["i2c"]},
69            exception_string=(
70                "ERROR: Malformed sensor metadata YAML:\ncompatible: ''\n"
71                + "supported-buses:\n- i2c"
72            ),
73            cause_substrings=[" is not of type 'object'"],
74        )
75
76    def test_invalid_supported_buses(self) -> None:
77        """
78        Check that invalid or missing supported-buses cause an error
79        """
80        self._check_with_exception(
81            metadata={"compatible": {"org": "Google", "part": "Pigweed"}},
82            exception_string=(
83                "ERROR: Malformed sensor metadata YAML:\ncompatible:\n"
84                + "  org: Google\n  part: Pigweed"
85            ),
86            cause_substrings=[],
87        )
88
89        self._check_with_exception(
90            metadata={
91                "compatible": {"org": "Google", "part": "Pigweed"},
92                "supported-buses": [],
93            },
94            exception_string=(
95                "ERROR: Malformed sensor metadata YAML:\ncompatible:\n"
96                + "  org: Google\n  part: Pigweed\nsupported-buses: []"
97            ),
98            cause_substrings=[],
99        )
100
101        self._check_with_exception(
102            metadata={
103                "compatible": {"org": "Google", "part": "Pigweed"},
104                "supported-buses": ["not-a-bus"],
105            },
106            exception_string=(
107                "ERROR: Malformed sensor metadata YAML:\ncompatible:\n"
108                + "  org: Google\n  part: Pigweed\nsupported-buses:\n"
109                + "- not-a-bus"
110            ),
111            cause_substrings=[],
112        )
113
114    def test_empty_dependency_list(self) -> None:
115        """
116        Check that an empty or missing 'deps' resolves to one with an empty
117        'deps' list
118        """
119        expected = {
120            "sensors": {
121                "google,foo": {
122                    "compatible": {"org": "google", "part": "foo"},
123                    "supported-buses": ["i2c"],
124                    "description": "",
125                    "channels": {},
126                    "attributes": [],
127                    "triggers": [],
128                    "extras": {},
129                },
130            },
131            "channels": {},
132            "attributes": {},
133            "triggers": {},
134            "units": {},
135        }
136        metadata = {
137            "compatible": {"org": "google", "part": "foo"},
138            "supported-buses": ["i2c"],
139            "deps": [],
140        }
141        result = Validator().validate(metadata=metadata)
142        self.assertEqual(result, expected)
143
144        metadata = {
145            "compatible": {"org": "google", "part": "foo"},
146            "supported-buses": ["i2c"],
147        }
148        result = Validator().validate(metadata=metadata)
149        self.assertEqual(result, expected)
150
151    def test_invalid_dependency_file(self) -> None:
152        """
153        Check that if an invalid dependency file is listed, we throw an error.
154        We know this will not be a valid file, because we have no files in the
155        include path so we have nowhere to look for the file.
156        """
157        self._check_with_exception(
158            metadata={
159                "compatible": {"org": "google", "part": "foo"},
160                "supported-buses": ["i2c"],
161                "deps": ["test.yaml"],
162            },
163            exception_string="Failed to find test.yaml using search paths:",
164            cause_substrings=[],
165            exception_type=FileNotFoundError,
166        )
167
168    def test_invalid_channel_name_raises_exception(self) -> None:
169        """
170        Check that if given a channel name that's not defined, we raise an Error
171        """
172        self._check_with_exception(
173            metadata={
174                "compatible": {"org": "google", "part": "foo"},
175                "supported-buses": ["i2c"],
176                "channels": {"bar": []},
177            },
178            exception_string="Failed to find a definition for 'bar', did"
179            " you forget a dependency?",
180            cause_substrings=[],
181        )
182
183    def test_channel_info_from_deps(self) -> None:
184        """
185        End to end test resolving a dependency file and setting the right
186        default attribute values.
187        """
188        with tempfile.NamedTemporaryFile(
189            mode="w", suffix=".yaml", encoding="utf-8", delete=False
190        ) as dep:
191            dep_filename = Path(dep.name)
192            dep.write(
193                yaml.safe_dump(
194                    {
195                        "units": {
196                            "rate": {
197                                "symbol": "Hz",
198                            },
199                            "sandwiches": {
200                                "symbol": "sandwiches",
201                            },
202                            "squeaks": {"symbol": "squeaks"},
203                            "items": {
204                                "symbol": "items",
205                            },
206                        },
207                        "attributes": {
208                            "sample_rate": {},
209                        },
210                        "channels": {
211                            "bar": {
212                                "units": "sandwiches",
213                            },
214                            "soap": {
215                                "name": "The soap",
216                                "description": (
217                                    "Measurement of how clean something is"
218                                ),
219                                "units": "squeaks",
220                            },
221                            "laundry": {
222                                "description": "Clean clothes count",
223                                "units": "items",
224                            },
225                        },
226                        "triggers": {
227                            "data_ready": {
228                                "description": "notify when new data is ready",
229                            },
230                        },
231                    },
232                )
233            )
234
235        metadata = Validator(include_paths=[dep_filename.parent]).validate(
236            metadata={
237                "compatible": {"org": "google", "part": "foo"},
238                "supported-buses": ["i2c"],
239                "deps": [dep_filename.name],
240                "attributes": [
241                    {
242                        "attribute": "sample_rate",
243                        "channel": "laundry",
244                        "units": "rate",
245                    },
246                ],
247                "channels": {
248                    "bar": [],
249                    "soap": [],
250                    "laundry": [
251                        {"name": "kids' laundry"},
252                        {"name": "adults' laundry"},
253                    ],
254                },
255                "triggers": [
256                    "data_ready",
257                ],
258            },
259        )
260
261        # Check attributes
262        self.assertEqual(
263            metadata,
264            {
265                "attributes": {
266                    "sample_rate": {
267                        "name": "sample_rate",
268                        "description": "",
269                    },
270                },
271                "channels": {
272                    "bar": {
273                        "name": "bar",
274                        "description": "",
275                        "units": "sandwiches",
276                    },
277                    "soap": {
278                        "name": "The soap",
279                        "description": "Measurement of how clean something is",
280                        "units": "squeaks",
281                    },
282                    "laundry": {
283                        "name": "laundry",
284                        "description": "Clean clothes count",
285                        "units": "items",
286                    },
287                },
288                "triggers": {
289                    "data_ready": {
290                        "name": "data_ready",
291                        "description": "notify when new data is ready",
292                    },
293                },
294                "units": {
295                    "rate": {
296                        "name": "Hz",
297                        "symbol": "Hz",
298                        "description": "",
299                    },
300                    "sandwiches": {
301                        "name": "sandwiches",
302                        "symbol": "sandwiches",
303                        "description": "",
304                    },
305                    "squeaks": {
306                        "name": "squeaks",
307                        "symbol": "squeaks",
308                        "description": "",
309                    },
310                    "items": {
311                        "name": "items",
312                        "symbol": "items",
313                        "description": "",
314                    },
315                },
316                "sensors": {
317                    "google,foo": {
318                        "description": "",
319                        "compatible": {
320                            "org": "google",
321                            "part": "foo",
322                        },
323                        "supported-buses": ["i2c"],
324                        "attributes": [
325                            {
326                                "attribute": "sample_rate",
327                                "channel": "laundry",
328                                "units": "rate",
329                            },
330                        ],
331                        "channels": {
332                            "bar": [
333                                {
334                                    "name": "bar",
335                                    "description": "",
336                                    "units": "sandwiches",
337                                },
338                            ],
339                            "soap": [
340                                {
341                                    "name": "The soap",
342                                    "description": (
343                                        "Measurement of how clean something is"
344                                    ),
345                                    "units": "squeaks",
346                                },
347                            ],
348                            "laundry": [
349                                {
350                                    "name": "kids' laundry",
351                                    "description": "Clean clothes count",
352                                    "units": "items",
353                                },
354                                {
355                                    "name": "adults' laundry",
356                                    "description": "Clean clothes count",
357                                    "units": "items",
358                                },
359                            ],
360                        },
361                        "triggers": ["data_ready"],
362                        "extras": {},
363                    },
364                },
365            },
366        )
367
368    def _check_with_exception(
369        self,
370        metadata: dict,
371        exception_string: str,
372        cause_substrings: list[str],
373        exception_type: type[BaseException] = RuntimeError,
374    ) -> None:
375        with self.assertRaises(exception_type) as context:
376            Validator().validate(metadata=metadata)
377
378        self.assertEqual(str(context.exception).rstrip(), exception_string)
379        for cause_substring in cause_substrings:
380            self.assertTrue(
381                cause_substring in str(context.exception.__cause__),
382                f"Actual cause: {str(context.exception.__cause__)}",
383            )
384
385
386if __name__ == "__main__":
387    unittest.main()
388