• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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                "'part' 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_partial_compatible_string(self) -> None:
77        """
78        Check that missing 'org' generates correct keys and empty entries are
79        removed.
80        """
81        metadata: dict = {
82            "compatible": {"part": "pigweed"},
83            "supported-buses": ["i2c"],
84        }
85        result = Validator().validate(metadata=metadata)
86        self.assertIn("pigweed", result["sensors"])
87        self.assertDictEqual(
88            {"part": "pigweed"},
89            result["sensors"]["pigweed"]["compatible"],
90        )
91
92        metadata["compatible"]["org"] = " "
93        result = Validator().validate(metadata=metadata)
94        self.assertIn("pigweed", result["sensors"])
95        self.assertDictEqual(
96            {"part": "pigweed"},
97            result["sensors"]["pigweed"]["compatible"],
98        )
99
100    def test_compatible_string_to_lower(self) -> None:
101        """
102        Check that compatible components are converted to lowercase and
103        stripped.
104        """
105        metadata = {
106            "compatible": {"org": "Google", "part": "Pigweed"},
107            "supported-buses": ["i2c"],
108        }
109        result = Validator().validate(metadata=metadata)
110        self.assertIn("google,pigweed", result["sensors"])
111        self.assertDictEqual(
112            {"org": "google", "part": "pigweed"},
113            result["sensors"]["google,pigweed"]["compatible"],
114        )
115
116    def test_invalid_supported_buses(self) -> None:
117        """
118        Check that invalid or missing supported-buses cause an error
119        """
120        self._check_with_exception(
121            metadata={"compatible": {"org": "Google", "part": "Pigweed"}},
122            exception_string=(
123                "ERROR: Malformed sensor metadata YAML:\ncompatible:\n"
124                + "  org: Google\n  part: Pigweed"
125            ),
126            cause_substrings=[],
127        )
128
129        self._check_with_exception(
130            metadata={
131                "compatible": {"org": "Google", "part": "Pigweed"},
132                "supported-buses": [],
133            },
134            exception_string=(
135                "ERROR: Malformed sensor metadata YAML:\ncompatible:\n"
136                + "  org: Google\n  part: Pigweed\nsupported-buses: []"
137            ),
138            cause_substrings=[],
139        )
140
141    def test_unique_bus_names(self) -> None:
142        """
143        Check that resulting bus names are unique and are converted to lowercase
144        """
145        self._check_with_exception(
146            metadata={
147                "compatible": {"org": "google", "part": "foo"},
148                "supported-buses": ["i2c", "I2C", "SPI"],
149                "deps": [],
150            },
151            exception_string=(
152                "ERROR: bus list contains duplicates when converted to "
153                "lowercase and concatenated with '_': "
154                "['I2C', 'SPI', 'i2c'] -> ['i2c', 'spi']"
155            ),
156            cause_substrings=[],
157        )
158        self._check_with_exception(
159            metadata={
160                "compatible": {"org": "google", "part": "foo"},
161                "supported-buses": ["i 2 c", "i  2_c", "i\t2-c"],
162                "deps": [],
163            },
164            exception_string=(
165                "ERROR: bus list contains duplicates when converted to "
166                "lowercase and concatenated with '_': "
167                "['i\\t2-c', 'i  2_c', 'i 2 c'] -> ['i_2_c']"
168            ),
169            cause_substrings=[],
170        )
171
172    def test_invalid_sensor_attribute(self) -> None:
173        attribute = {
174            "attribute": "sample_rate",
175            "channel": "laundry",
176            "trigger": "data_ready",
177            "units": "rate",
178        }
179        dep_filename = self._generate_dependency_file()
180        self._check_with_exception(
181            metadata={
182                "compatible": {"part": "foo"},
183                "supported-buses": ["i2c"],
184                "deps": [str(dep_filename.resolve())],
185                "attributes": [attribute],
186            },
187            exception_string=(
188                "Attribute instances cannot specify both channel AND trigger:\n"
189                + yaml.safe_dump(attribute, indent=2)
190            ),
191            cause_substrings=[],
192        )
193
194    def test_empty_dependency_list(self) -> None:
195        """
196        Check that an empty or missing 'deps' resolves to one with an empty
197        'deps' list
198        """
199        expected = {
200            "sensors": {
201                "google,foo": {
202                    "compatible": {"org": "google", "part": "foo"},
203                    "supported-buses": ["i2c"],
204                    "description": "",
205                    "channels": {},
206                    "attributes": [],
207                    "triggers": [],
208                    "extras": {},
209                },
210            },
211            "channels": {},
212            "attributes": {},
213            "triggers": {},
214            "units": {},
215        }
216        metadata = {
217            "compatible": {"org": "google", "part": "foo"},
218            "supported-buses": ["i2c"],
219            "deps": [],
220        }
221        result = Validator().validate(metadata=metadata)
222        self.assertEqual(result, expected)
223
224        metadata = {
225            "compatible": {"org": "google", "part": "foo"},
226            "supported-buses": ["i2c"],
227        }
228        result = Validator().validate(metadata=metadata)
229        self.assertEqual(result, expected)
230
231    def test_invalid_dependency_file(self) -> None:
232        """
233        Check that if an invalid dependency file is listed, we throw an error.
234        We know this will not be a valid file, because we have no files in the
235        include path so we have nowhere to look for the file.
236        """
237        self._check_with_exception(
238            metadata={
239                "compatible": {"org": "google", "part": "foo"},
240                "supported-buses": ["i2c"],
241                "deps": ["test.yaml"],
242            },
243            exception_string="Failed to find test.yaml using search paths:",
244            cause_substrings=[],
245            exception_type=FileNotFoundError,
246        )
247
248    def test_invalid_channel_name_raises_exception(self) -> None:
249        """
250        Check that if given a channel name that's not defined, we raise an Error
251        """
252        self._check_with_exception(
253            metadata={
254                "compatible": {"org": "google", "part": "foo"},
255                "supported-buses": ["i2c"],
256                "channels": {"bar": []},
257            },
258            exception_string="Failed to find a definition for 'bar', did"
259            " you forget a dependency?",
260            cause_substrings=[],
261        )
262
263    @staticmethod
264    def _generate_dependency_file() -> Path:
265        with tempfile.NamedTemporaryFile(
266            mode="w", suffix=".yaml", encoding="utf-8", delete=False
267        ) as dep:
268            dep_filename = Path(dep.name)
269            dep.write(
270                yaml.safe_dump(
271                    {
272                        "units": {
273                            "rate": {
274                                "symbol": "Hz",
275                            },
276                            "sandwiches": {
277                                "symbol": "sandwiches",
278                            },
279                            "squeaks": {"symbol": "squeaks"},
280                            "items": {
281                                "symbol": "items",
282                            },
283                        },
284                        "attributes": {
285                            "sample_rate": {},
286                        },
287                        "channels": {
288                            "bar": {
289                                "units": "sandwiches",
290                            },
291                            "soap": {
292                                "name": "The soap",
293                                "description": (
294                                    "Measurement of how clean something is"
295                                ),
296                                "units": "squeaks",
297                            },
298                            "laundry": {
299                                "description": "Clean clothes count",
300                                "units": "items",
301                            },
302                        },
303                        "triggers": {
304                            "data_ready": {
305                                "description": "notify when new data is ready",
306                            },
307                        },
308                    },
309                )
310            )
311        return dep_filename
312
313    def test_channel_info_from_deps(self) -> None:
314        """
315        End to end test resolving a dependency file and setting the right
316        default attribute values.
317        """
318        dep_filename = self._generate_dependency_file()
319
320        metadata = Validator(include_paths=[dep_filename.parent]).validate(
321            metadata={
322                "compatible": {"org": "google", "part": "foo"},
323                "supported-buses": ["i2c"],
324                "deps": [dep_filename.name],
325                "attributes": [
326                    # Attribute applied to a channel
327                    {
328                        "attribute": "sample_rate",
329                        "channel": "laundry",
330                        "units": "rate",
331                    },
332                    # Attribute applied to the entire device
333                    {
334                        "attribute": "sample_rate",
335                        "units": "rate",
336                    },
337                    # Attribute applied to a trigger
338                    {
339                        "attribute": "sample_rate",
340                        "trigger": "data_ready",
341                        "units": "rate",
342                    },
343                ],
344                "channels": {
345                    "bar": [],
346                    "soap": [],
347                    "laundry": [
348                        {"name": "kids' laundry"},
349                        {"name": "adults' laundry"},
350                    ],
351                },
352                "triggers": [
353                    "data_ready",
354                ],
355            },
356        )
357
358        # Check attributes
359        self.assertEqual(
360            metadata,
361            {
362                "attributes": {
363                    "sample_rate": {
364                        "name": "sample_rate",
365                        "description": "",
366                    },
367                },
368                "channels": {
369                    "bar": {
370                        "name": "bar",
371                        "description": "",
372                        "units": "sandwiches",
373                    },
374                    "soap": {
375                        "name": "The soap",
376                        "description": "Measurement of how clean something is",
377                        "units": "squeaks",
378                    },
379                    "laundry": {
380                        "name": "laundry",
381                        "description": "Clean clothes count",
382                        "units": "items",
383                    },
384                },
385                "triggers": {
386                    "data_ready": {
387                        "name": "data_ready",
388                        "description": "notify when new data is ready",
389                    },
390                },
391                "units": {
392                    "rate": {
393                        "name": "Hz",
394                        "symbol": "Hz",
395                        "description": "",
396                    },
397                    "sandwiches": {
398                        "name": "sandwiches",
399                        "symbol": "sandwiches",
400                        "description": "",
401                    },
402                    "squeaks": {
403                        "name": "squeaks",
404                        "symbol": "squeaks",
405                        "description": "",
406                    },
407                    "items": {
408                        "name": "items",
409                        "symbol": "items",
410                        "description": "",
411                    },
412                },
413                "sensors": {
414                    "google,foo": {
415                        "description": "",
416                        "compatible": {
417                            "org": "google",
418                            "part": "foo",
419                        },
420                        "supported-buses": ["i2c"],
421                        "attributes": [
422                            {
423                                "attribute": "sample_rate",
424                                "channel": "laundry",
425                                "units": "rate",
426                            },
427                            {
428                                "attribute": "sample_rate",
429                                "units": "rate",
430                            },
431                            {
432                                "attribute": "sample_rate",
433                                "trigger": "data_ready",
434                                "units": "rate",
435                            },
436                        ],
437                        "channels": {
438                            "bar": [
439                                {
440                                    "name": "bar",
441                                    "description": "",
442                                    "units": "sandwiches",
443                                },
444                            ],
445                            "soap": [
446                                {
447                                    "name": "The soap",
448                                    "description": (
449                                        "Measurement of how clean something is"
450                                    ),
451                                    "units": "squeaks",
452                                },
453                            ],
454                            "laundry": [
455                                {
456                                    "name": "kids' laundry",
457                                    "description": "Clean clothes count",
458                                    "units": "items",
459                                },
460                                {
461                                    "name": "adults' laundry",
462                                    "description": "Clean clothes count",
463                                    "units": "items",
464                                },
465                            ],
466                        },
467                        "triggers": ["data_ready"],
468                        "extras": {},
469                    },
470                },
471            },
472        )
473
474    def _check_with_exception(
475        self,
476        metadata: dict,
477        exception_string: str,
478        cause_substrings: list[str],
479        exception_type: type[BaseException] = RuntimeError,
480    ) -> None:
481        with self.assertRaises(exception_type) as context:
482            Validator().validate(metadata=metadata)
483
484        self.assertEqual(
485            str(context.exception).rstrip(), str(exception_string).rstrip()
486        )
487        for cause_substring in cause_substrings:
488            self.assertTrue(
489                cause_substring in str(context.exception.__cause__),
490                f"Actual cause: {str(context.exception.__cause__)}",
491            )
492
493
494if __name__ == "__main__":
495    unittest.main()
496