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