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