|
8 | 8 | from pathlib import Path |
9 | 9 | from unittest import mock |
10 | 10 |
|
| 11 | +from rdflib import Literal, Namespace, URIRef |
| 12 | + |
11 | 13 | from tools.lint import routine_semantics |
12 | 14 |
|
13 | 15 |
|
|
23 | 25 | for name in routine_semantics.FIXTURE_SCHEMAS |
24 | 26 | ) |
25 | 27 | PROFILE_PATH, MANIFEST_PATH = FIXTURE_FILES |
| 28 | +OCL = Namespace(routine_semantics.LOCAL_NAMESPACE) |
| 29 | +QUDT_UNIT = Namespace("http://qudt.org/vocab/unit/") |
26 | 30 |
|
27 | 31 |
|
28 | 32 | class RoutineSemanticTests(unittest.TestCase): |
@@ -57,6 +61,20 @@ def mutate(self, relative_path, change): |
57 | 61 | change(value) |
58 | 62 | self.write_json(relative_path, value) |
59 | 63 |
|
| 64 | + def parse_jsonld(self, relative_path): |
| 65 | + document = self.read_json(relative_path) |
| 66 | + errors = [] |
| 67 | + safe = routine_semantics._check_jsonld_safety( |
| 68 | + document, relative_path, errors |
| 69 | + ) |
| 70 | + graph = routine_semantics._parse_jsonld( |
| 71 | + document, Path(relative_path), safe, errors |
| 72 | + ) |
| 73 | + self.assertEqual(errors, []) |
| 74 | + if graph is None: |
| 75 | + self.fail(f"{relative_path} did not produce an RDF graph") |
| 76 | + return document, graph |
| 77 | + |
60 | 78 | def assert_error(self, expected): |
61 | 79 | errors = routine_semantics.validate(self.root) |
62 | 80 | self.assertTrue( |
@@ -192,6 +210,83 @@ def test_contexts_and_imports_are_rejected_before_jsonld_parsing(self): |
192 | 210 | self.assert_error(expected) |
193 | 211 | (self.root / PROFILE_PATH).write_bytes(original) |
194 | 212 |
|
| 213 | + def test_context_coverage_rejects_an_unmapped_schema_property(self): |
| 214 | + schema_path = "routines/schemas/routine-semantic-profile.schema.json" |
| 215 | + self.mutate( |
| 216 | + schema_path, |
| 217 | + lambda value: value["$defs"]["semanticContext"]["const"].pop("id"), |
| 218 | + ) |
| 219 | + for fixture_path in (PROFILE_PATH, MANIFEST_PATH): |
| 220 | + self.mutate(fixture_path, lambda value: value["@context"].pop("id")) |
| 221 | + self.assert_error("semanticContext has no JSON-LD mapping for schema property 'id'") |
| 222 | + |
| 223 | + def test_derivation_jsonld_preserves_ids_and_policy_values(self): |
| 224 | + manifest, graph = self.parse_jsonld(MANIFEST_PATH) |
| 225 | + |
| 226 | + expected_ids = { |
| 227 | + "zone_air_temperature_max", |
| 228 | + "north_zone_temperature", |
| 229 | + "south_zone_temperature", |
| 230 | + "service_zone_temperature", |
| 231 | + "north-zone", |
| 232 | + "south-zone", |
| 233 | + "service-zone", |
| 234 | + "exclude_service_zone", |
| 235 | + } |
| 236 | + self.assertEqual( |
| 237 | + {str(value) for value in graph.objects(None, OCL.localId)}, expected_ids |
| 238 | + ) |
| 239 | + |
| 240 | + manifest_id = URIRef(manifest["@id"]) |
| 241 | + data_quality = graph.value(manifest_id, OCL.dataQualityPolicy) |
| 242 | + self.assertIsNotNone(data_quality) |
| 243 | + self.assertIn((data_quality, OCL.acceptedStatus, Literal("good")), graph) |
| 244 | + self.assertIn( |
| 245 | + (data_quality, OCL.rejectedInputPolicy, Literal("exclude-member")), |
| 246 | + graph, |
| 247 | + ) |
| 248 | + self.assertIn((data_quality, OCL.minimumValidMembers, Literal(2)), graph) |
| 249 | + |
| 250 | + ready = graph.value(manifest_id, OCL.readyCondition) |
| 251 | + self.assertIsNotNone(ready) |
| 252 | + self.assertIn((ready, OCL.minimumValidMembers, Literal(2)), graph) |
| 253 | + self.assertIn( |
| 254 | + (ready, OCL.requireAllInputsInDomain, Literal(False)), graph |
| 255 | + ) |
| 256 | + |
| 257 | + unit_policy = graph.value(manifest_id, OCL.unitPolicy) |
| 258 | + self.assertIsNotNone(unit_policy) |
| 259 | + self.assertIn((unit_policy, OCL.outputUnit, QUDT_UNIT.DEG_C), graph) |
| 260 | + self.assertIn( |
| 261 | + (unit_policy, OCL.inputUnitsPolicy, Literal("same-as-output")), graph |
| 262 | + ) |
| 263 | + self.assertIn( |
| 264 | + (unit_policy, OCL.conversionPolicy, Literal("none")), graph |
| 265 | + ) |
| 266 | + |
| 267 | + def test_software_source_signal_id_is_preserved_as_an_iri(self): |
| 268 | + signal_id = "urn:open-control-library:software-signal:north-zone-temperature" |
| 269 | + self.mutate( |
| 270 | + MANIFEST_PATH, |
| 271 | + lambda value: value["inputs"][0].update( |
| 272 | + source={ |
| 273 | + "kind": "software-signal", |
| 274 | + "signal_id": signal_id, |
| 275 | + "local_class": "ocl:SoftwareSignal", |
| 276 | + "value_kind": "real", |
| 277 | + "qudt_unit": "unit:DEG_C", |
| 278 | + "member_id": "north-zone", |
| 279 | + } |
| 280 | + ), |
| 281 | + ) |
| 282 | + self.assertEqual(routine_semantics.validate(self.root), []) |
| 283 | + |
| 284 | + manifest, graph = self.parse_jsonld(MANIFEST_PATH) |
| 285 | + input_id = URIRef(manifest["inputs"][0]["@id"]) |
| 286 | + source = graph.value(input_id, OCL.source) |
| 287 | + self.assertIsNotNone(source) |
| 288 | + self.assertIn((source, OCL.signalId, URIRef(signal_id)), graph) |
| 289 | + |
195 | 290 | def test_ontology_pin_constants_and_local_vocabulary_hash_are_enforced(self): |
196 | 291 | pin_path = "routines/ontology/ontology-pins.json" |
197 | 292 | pin_original = (self.root / pin_path).read_bytes() |
|
0 commit comments