forked from ChelseaKR/ctdl-validate
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_domain_range.py
More file actions
332 lines (287 loc) · 14.6 KB
/
Copy pathtest_domain_range.py
File metadata and controls
332 lines (287 loc) · 14.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
"""Domain/range checks from the schema's own declarations."""
from __future__ import annotations
from ctdl_validate import Severity, validate_document
from ctdl_validate.rules import concept_range_conflict_rule
from ctdl_validate.schema import ALIGNMENT_RANGE_TERM, CONCEPT_RANGE_TERM, load_schema
from .conftest import load_fixture
def test_property_on_class_outside_declared_domain_is_an_error() -> None:
findings = validate_document(load_fixture("domain_violation.json"))
(finding,) = [f for f in findings if f.code == "DOMAIN_VIOLATION"]
assert finding.severity is Severity.ERROR
assert finding.prop == "ceterms:isPartOf"
assert "domainIncludes" in finding.rule.citation
assert finding.rule.url == "https://credreg.net/ctdl/schema/encoding/json"
def test_reference_to_wrong_class_is_a_range_error_with_declaration_cited() -> None:
findings = validate_document(load_fixture("bug_class_252_wrong_framework_identifier.json"))
(finding,) = [f for f in findings if f.code == "RANGE_VIOLATION"]
assert "rangeIncludes" in finding.rule.citation
assert "ceasn:CompetencyFramework" in finding.rule.citation
assert finding.rule.url == "https://credreg.net/ctdlasn/schema/encoding/json"
def test_subclasses_satisfy_domain_and_range() -> None:
# ceterms:ownedBy range includes ceterms:Organization;
# ceterms:CredentialOrganization is its subclass and must satisfy it.
payload = {
"@graph": [
{
"@id": "https://credentialengineregistry.org/resources/ce-59e8d15f-7895-4346-a5a8-7a0739a3d344",
"@type": "ceterms:Certification",
"ceterms:ctid": "ce-59e8d15f-7895-4346-a5a8-7a0739a3d344",
"ceterms:ownedBy": [
"https://credentialengineregistry.org/resources/ce-79298677-d0e4-4799-853a-a633d9071826"
],
},
{
"@id": "https://credentialengineregistry.org/resources/ce-79298677-d0e4-4799-853a-a633d9071826",
"@type": "ceterms:CredentialOrganization",
"ceterms:ctid": "ce-79298677-d0e4-4799-853a-a633d9071826",
"ceterms:name": {"en-US": "A Credential Organization"},
},
]
}
assert validate_document(payload) == []
def test_unknown_ctdl_property_is_a_warning_not_an_error() -> None:
payload = {
"@type": "ceterms:Certification",
"ceterms:notARealProperty": "x",
}
findings = validate_document(payload)
(finding,) = findings
assert finding.code == "UNKNOWN_PROPERTY"
assert finding.severity is Severity.WARNING
assert "snapshot" in finding.rule.citation
def test_unknown_ctdl_class_is_a_warning_not_an_error() -> None:
payload = {"@type": "ceterms:NotARealClass"}
findings = validate_document(payload)
(finding,) = findings
assert finding.code == "UNKNOWN_CLASS"
assert finding.severity is Severity.WARNING
def test_foreign_namespace_terms_are_not_judged() -> None:
payload = {
"@type": "ceterms:Certification",
"schema:thisIsNotOurs": "x",
"dct:alsoNotOurs": "y",
}
assert validate_document(payload) == []
def test_ischildof_pointing_at_framework_is_info_due_to_documented_conflict() -> None:
# The CTDL-ASN schema does not include CompetencyFramework in
# ceasn:isChildOf's range, but Credential Engine's own handbook examples
# and the isPartOf usage note use exactly that pattern for top-level
# competencies. Conflicting sources: INFO, not ERROR.
payload = {
"@graph": [
{
"@id": "https://credentialengineregistry.org/resources/ce-177f4c85-4efe-401d-acdd-1ea4adeeaf37",
"@type": "ceasn:CompetencyFramework",
"ceterms:ctid": "ce-177f4c85-4efe-401d-acdd-1ea4adeeaf37",
"ceasn:name": {"en-US": "Framework"},
},
{
"@id": "https://credentialengineregistry.org/resources/ce-5e3de882-3b49-421b-b623-695c63587f4f",
"@type": "ceasn:Competency",
"ceterms:ctid": "ce-5e3de882-3b49-421b-b623-695c63587f4f",
"ceasn:competencyText": {"en-US": "Top-level competency"},
"ceasn:isPartOf": "https://credentialengineregistry.org/resources/ce-177f4c85-4efe-401d-acdd-1ea4adeeaf37",
"ceasn:isChildOf": "https://credentialengineregistry.org/resources/ce-177f4c85-4efe-401d-acdd-1ea4adeeaf37",
},
]
}
findings = validate_document(payload)
conflict = [f for f in findings if f.code == "RANGE_DOCS_CONFLICT"]
assert len(conflict) == 1
assert conflict[0].severity is Severity.INFO
assert not [f for f in findings if f.severity is Severity.ERROR]
# -- the concept-range conflict --------------------------------------------
#
# CTDL declares a reference to a term from one of its own concept schemes two
# incompatible ways: across the vendored snapshot 46 properties range on
# ceterms:CredentialAlignmentObject and 45 range on skos:Concept, with nothing
# about the value distinguishing the families. Three concept schemes are named
# by different properties in each family, and a fourth is named by a single
# property declaring both ranges at once. The published Registry corpus encodes
# both families as CredentialAlignmentObject. Erroring on that reports
# Credential Engine's own dominant encoding as a defect, so it is an INFO
# disposition. These tests pin it to exactly the properties the evidence
# covers, in both directions.
def _credit_unit_payload(unit_type: object) -> dict[str, object]:
"""A course whose credit value names a term from the CreditUnit scheme."""
return {
"@graph": [
{
"@id": "https://credentialengineregistry.org/resources/ce-59e8d15f-7895-4346-a5a8-7a0739a3d344",
"@type": "ceterms:Course",
"ceterms:ctid": "ce-59e8d15f-7895-4346-a5a8-7a0739a3d344",
"ceterms:creditValue": [
{
"@type": "ceterms:ValueProfile",
"ceterms:creditUnitType": [unit_type],
"schema:value": 3.0,
}
],
}
]
}
ALIGNMENT_VALUE = {
"@type": "ceterms:CredentialAlignmentObject",
"ceterms:framework": "https://credreg.net/ctdl/terms/CreditUnit",
"ceterms:targetNode": "creditUnit:SemesterHour",
}
def test_alignment_object_on_a_scheme_bound_concept_property_is_info_not_error() -> None:
findings = validate_document(_credit_unit_payload(ALIGNMENT_VALUE))
(finding,) = [f for f in findings if f.code == "CONCEPT_RANGE_CONFLICT"]
assert finding.severity is Severity.INFO
assert finding.prop == "ceterms:creditUnitType"
assert not [f for f in findings if f.severity is Severity.ERROR]
def test_the_conflict_message_names_the_property_the_scheme_and_the_declarations() -> None:
(finding,) = [
f
for f in validate_document(_credit_unit_payload(ALIGNMENT_VALUE))
if f.code == "CONCEPT_RANGE_CONFLICT"
]
assert "ceterms:creditUnitType" in finding.message
assert "ceterms:CreditUnit" in finding.message # the meta:targetScheme
assert "ceterms:CredentialAlignmentObject" in finding.message
assert "skos:Concept" in finding.rule.citation
assert "meta:targetScheme" in finding.rule.citation
assert finding.rule.url == "https://credreg.net/ctdl/schema/encoding/json"
def test_a_sibling_property_over_the_same_scheme_is_cited_where_one_exists() -> None:
# ceterms:creditLevelType (skos:Concept) and ceterms:audienceLevelType
# (CredentialAlignmentObject) both declare meta:targetScheme
# ceterms:AudienceLevel. That pair is the sharpest evidence in the
# snapshot, so the citation names it rather than arguing in the abstract.
payload = {
"@graph": [
{
"@id": "https://credentialengineregistry.org/resources/ce-59e8d15f-7895-4346-a5a8-7a0739a3d344",
"@type": "ceterms:Course",
"ceterms:ctid": "ce-59e8d15f-7895-4346-a5a8-7a0739a3d344",
"ceterms:creditValue": [
{
"@type": "ceterms:ValueProfile",
"ceterms:creditLevelType": [
{
"@type": "ceterms:CredentialAlignmentObject",
"ceterms:framework": "https://credreg.net/ctdl/terms/AudienceLevel",
"ceterms:targetNode": "audLevel:BeginnerLevel",
}
],
"schema:value": 3.0,
}
],
}
]
}
(finding,) = [f for f in validate_document(payload) if f.code == "CONCEPT_RANGE_CONFLICT"]
assert "ceterms:audienceLevelType" in finding.rule.citation
def test_an_actual_skos_concept_satisfies_the_declared_range_with_no_finding() -> None:
payload = _credit_unit_payload(
"https://credentialengineregistry.org/resources/ce-1f0b7ff2-9e4c-4a19-9d02-6c1b0b3f3a11"
)
payload["@graph"].append( # type: ignore[attr-defined]
{
"@id": "https://credentialengineregistry.org/resources/ce-1f0b7ff2-9e4c-4a19-9d02-6c1b0b3f3a11",
"@type": "skos:Concept",
"ceterms:ctid": "ce-1f0b7ff2-9e4c-4a19-9d02-6c1b0b3f3a11",
}
)
findings = validate_document(payload)
assert not [f for f in findings if f.code in {"CONCEPT_RANGE_CONFLICT", "RANGE_VIOLATION"}]
def test_a_wrong_class_on_a_scheme_bound_property_is_still_an_error() -> None:
# The disposition covers CredentialAlignmentObject and nothing else. An
# Organization standing where a concept belongs is still a range error.
payload = _credit_unit_payload(
"https://credentialengineregistry.org/resources/ce-79298677-d0e4-4799-853a-a633d9071826"
)
payload["@graph"].append( # type: ignore[attr-defined]
{
"@id": "https://credentialengineregistry.org/resources/ce-79298677-d0e4-4799-853a-a633d9071826",
"@type": "ceterms:CredentialOrganization",
"ceterms:ctid": "ce-79298677-d0e4-4799-853a-a633d9071826",
}
)
findings = validate_document(payload)
(finding,) = [f for f in findings if f.code == "RANGE_VIOLATION"]
assert finding.severity is Severity.ERROR
assert finding.prop == "ceterms:creditUnitType"
assert not [f for f in findings if f.code == "CONCEPT_RANGE_CONFLICT"]
def test_a_skos_ranged_property_with_no_target_scheme_is_still_an_error() -> None:
# ceterms:classification ranges on skos:Concept but declares no
# meta:targetScheme, so it is not one of the properties the evidence
# covers and an alignment object there stays an ERROR. This is the guard
# against the disposition quietly widening to every skos:Concept range.
payload = {
"@graph": [
{
"@id": "https://credentialengineregistry.org/resources/ce-59e8d15f-7895-4346-a5a8-7a0739a3d344",
"@type": "ceterms:Course",
"ceterms:ctid": "ce-59e8d15f-7895-4346-a5a8-7a0739a3d344",
"ceterms:classification": [ALIGNMENT_VALUE],
}
]
}
findings = validate_document(payload)
(finding,) = [f for f in findings if f.code == "RANGE_VIOLATION"]
assert finding.severity is Severity.ERROR
assert finding.prop == "ceterms:classification"
def test_the_conflict_the_disposition_rests_on_is_still_in_the_snapshot() -> None:
"""If Credential Engine fixes the encoding, this test says so.
The INFO disposition above is only defensible while CTDL really does
declare one kind of value two incompatible ways. Refreshing the vendored
snapshot could settle that, at which point a `CONCEPT_RANGE_CONFLICT` would
be hiding a real error and this disposition should be removed rather than
left running on a premise that has expired.
"""
schema = load_schema()
skos_only: dict[str, set[str]] = {}
alignment_only: dict[str, set[str]] = {}
both_at_once: dict[str, set[str]] = {}
for prop in schema.properties.values():
ranges_concept = CONCEPT_RANGE_TERM in prop.range
ranges_alignment = ALIGNMENT_RANGE_TERM in prop.range
for scheme in prop.target_scheme:
if ranges_concept and ranges_alignment:
both_at_once.setdefault(scheme, set()).add(prop.term)
elif ranges_concept:
skos_only.setdefault(scheme, set()).add(prop.term)
elif ranges_alignment:
alignment_only.setdefault(scheme, set()).add(prop.term)
# Evidence 1: schemes where two *different* properties disagree on range.
contested = sorted(set(skos_only) & set(alignment_only))
assert contested == [
"ceterms:AudienceLevel",
"ceterms:CostType",
"ceterms:ScheduleFrequency",
], "the two concept ranges no longer disagree: revisit CONCEPT_RANGE_CONFLICT"
assert skos_only["ceterms:AudienceLevel"] == {
"ceasn:educationLevelType",
"ceterms:creditLevelType",
}
assert alignment_only["ceterms:AudienceLevel"] == {"ceterms:audienceLevelType"}
# Evidence 2: CTDL declares one property both ways at once, which is the
# encoding itself saying the two ranges describe one kind of value.
assert both_at_once == {
"ceterms:InstructionalProgramClassification": {"ceterms:instructionalProgramType"}
}
# And the disposition stays narrow: it reaches scheme-bound properties only.
covered = schema.scheme_bound_concept_properties()
assert len(covered) == 20
assert "ceterms:creditUnitType" in covered
assert "ceterms:classification" not in covered # no meta:targetScheme
assert "skos:broader" not in covered
def test_the_sibling_citation_truncates_and_says_how_many_it_left_out() -> None:
# No property in the current snapshot has more than two siblings, so the
# truncation is exercised directly rather than left as an untested branch
# waiting on a schema release to reach it.
rule = concept_range_conflict_rule(
"ceterms:creditUnitType",
frozenset({"ceterms:CreditUnit"}),
("ceterms:a", "ceterms:b", "ceterms:c", "ceterms:d"),
)
assert "ceterms:a, ceterms:b, ceterms:c, ... (4 properties total)" in rule.citation
assert "ceterms:d" not in rule.citation
def test_siblings_of_a_property_with_no_target_scheme_is_empty() -> None:
schema = load_schema()
assert schema.alignment_ranged_siblings("ceterms:classification") == ()
assert schema.alignment_ranged_siblings("ceterms:notARealProperty") == ()
assert schema.alignment_ranged_siblings("ceterms:creditLevelType") == (
"ceterms:audienceLevelType",
)