Skip to content

Commit 4f7ec5a

Browse files
committed
test(utils): ✅ add unit tests for the get_shape_property_graph method
1 parent f1c0cfd commit 4f7ec5a

1 file changed

Lines changed: 199 additions & 0 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# Copyright (c) 2024-2026 CRS4
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://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,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Unit tests for ``ShapesList.get_shape_property_graph``.
17+
18+
The method must return a subgraph that:
19+
* contains every triple reachable from the property shape (its constraints
20+
and any RDF lists used by ``sh:and``/``sh:or``/``sh:xone``);
21+
* contains the link triple ``(shape_node, sh:property, shape_property)``;
22+
* does NOT contain triples that belong only to sibling property shapes
23+
"""
24+
25+
import pytest
26+
from rdflib import RDF, BNode, Graph, Literal, Namespace, URIRef
27+
from rdflib.collection import Collection
28+
29+
from rocrate_validator.constants import SHACL_NS
30+
from rocrate_validator.requirements.shacl.utils import load_shapes_from_graph
31+
32+
SH = Namespace(SHACL_NS)
33+
EX = Namespace("http://example.org/")
34+
35+
36+
def _build_two_property_shape() -> tuple[Graph, URIRef, URIRef, URIRef]:
37+
"""
38+
Build a NodeShape with two sibling property shapes.
39+
40+
Returns ``(graph, node_shape, prop_a, prop_b)``.
41+
42+
Each property shape is a BNode owning its own ``sh:path``,
43+
``sh:datatype``, ``sh:minCount`` constraints.
44+
"""
45+
g = Graph()
46+
g.bind("sh", SH)
47+
g.bind("ex", EX)
48+
49+
node_shape = EX.PersonShape
50+
g.add((node_shape, RDF.type, SH.NodeShape))
51+
g.add((node_shape, SH.targetClass, EX.Person))
52+
53+
prop_a = BNode("propA")
54+
g.add((node_shape, SH.property, prop_a))
55+
g.add((prop_a, SH.path, EX.name))
56+
g.add((prop_a, SH.datatype, EX.stringType))
57+
g.add((prop_a, SH.minCount, Literal(1)))
58+
59+
prop_b = BNode("propB")
60+
g.add((node_shape, SH.property, prop_b))
61+
g.add((prop_b, SH.path, EX.age))
62+
g.add((prop_b, SH.datatype, EX.intType))
63+
g.add((prop_b, SH.minCount, Literal(0)))
64+
65+
return g, node_shape, prop_a, prop_b
66+
67+
68+
def test_returns_link_triple_to_target_property():
69+
"""The link ``(node_shape, sh:property, shape_property)`` must be present."""
70+
g, node_shape, prop_a, prop_b = _build_two_property_shape()
71+
shapes_list = load_shapes_from_graph(g)
72+
73+
pg = shapes_list.get_shape_property_graph(node_shape, prop_a)
74+
75+
# The link to the prop_a shape must be present
76+
assert (node_shape, SH.property, prop_a) in pg
77+
# but not the link to the prop_b shape.
78+
assert (node_shape, SH.property, prop_b) not in pg
79+
80+
81+
def test_includes_all_constraints_of_target_property():
82+
"""All triples whose subject is the target property shape must be included."""
83+
g, node_shape, prop_a, _ = _build_two_property_shape()
84+
shapes_list = load_shapes_from_graph(g)
85+
86+
pg = shapes_list.get_shape_property_graph(node_shape, prop_a)
87+
88+
assert (prop_a, SH.path, EX.name) in pg
89+
assert (prop_a, SH.datatype, EX.stringType) in pg
90+
assert (prop_a, SH.minCount, Literal(1)) in pg
91+
92+
93+
def test_excludes_sibling_property_link_and_constraints():
94+
"""
95+
Sibling property shapes and their link triples must not appear in the
96+
returned subgraph. This is the regression the new implementation fixes.
97+
"""
98+
g, node_shape, prop_a, prop_b = _build_two_property_shape()
99+
shapes_list = load_shapes_from_graph(g)
100+
101+
pg = shapes_list.get_shape_property_graph(node_shape, prop_a)
102+
103+
# Sibling link triple must not be present.
104+
assert (node_shape, SH.property, prop_b) not in pg
105+
# Sibling constraints must not be present.
106+
assert (prop_b, SH.path, EX.age) not in pg
107+
assert (prop_b, SH.datatype, EX.intType) not in pg
108+
assert (prop_b, SH.minCount, Literal(0)) not in pg
109+
110+
111+
def test_subtraction_preserves_sibling_property_link():
112+
"""
113+
Subtracting the returned subgraph from the merged shapes graph must
114+
leave the sibling property's link to the parent NodeShape intact
115+
"""
116+
g, node_shape, prop_a, prop_b = _build_two_property_shape()
117+
shapes_list = load_shapes_from_graph(g)
118+
119+
pg = shapes_list.get_shape_property_graph(node_shape, prop_a)
120+
remaining = shapes_list.shapes_graph - pg
121+
122+
# The sibling property is still linked to the NodeShape.
123+
assert (node_shape, SH.property, prop_b) in remaining
124+
# And so are its constraints.
125+
assert (prop_b, SH.path, EX.age) in remaining
126+
127+
128+
def test_does_not_include_unrelated_node_shape_triples():
129+
"""
130+
Triples on the parent NodeShape that are not the target link must
131+
not be pulled in (e.g. ``sh:targetClass``).
132+
"""
133+
g, node_shape, prop_a, _ = _build_two_property_shape()
134+
shapes_list = load_shapes_from_graph(g)
135+
136+
pg = shapes_list.get_shape_property_graph(node_shape, prop_a)
137+
138+
assert (node_shape, SH.targetClass, EX.Person) not in pg
139+
assert (node_shape, RDF.type, SH.NodeShape) not in pg
140+
141+
142+
def test_includes_rdf_list_triples_for_sh_or():
143+
"""
144+
When the property shape uses ``sh:or`` (an RDF list), the list spine
145+
(``rdf:first``/``rdf:rest``) and every list member must be reachable
146+
in the returned subgraph.
147+
"""
148+
g = Graph()
149+
node_shape = EX.SomeShape
150+
g.add((node_shape, RDF.type, SH.NodeShape))
151+
152+
prop = BNode("prop")
153+
g.add((node_shape, SH.property, prop))
154+
g.add((prop, SH.path, EX.something))
155+
156+
member_a = BNode("memberA")
157+
g.add((member_a, SH.datatype, EX.t1))
158+
member_b = BNode("memberB")
159+
g.add((member_b, SH.datatype, EX.t2))
160+
161+
list_head = BNode("listHead")
162+
Collection(g, list_head, [member_a, member_b])
163+
g.add((prop, SH["or"], list_head))
164+
165+
shapes_list = load_shapes_from_graph(g)
166+
pg = shapes_list.get_shape_property_graph(node_shape, prop)
167+
168+
# The sh:or link is reachable from the property.
169+
assert (prop, SH["or"], list_head) in pg
170+
# Both list members and their constraints are reachable.
171+
assert (member_a, SH.datatype, EX.t1) in pg
172+
assert (member_b, SH.datatype, EX.t2) in pg
173+
# The RDF list spine is included so the list can be re-walked.
174+
list_spine_subjects = {s for s, _, _ in pg.triples((None, RDF.first, None))}
175+
assert list_head in list_spine_subjects
176+
177+
178+
def test_only_target_link_present_when_node_has_multiple_properties():
179+
"""
180+
The graph must contain exactly one ``sh:property`` triple originating
181+
from the parent NodeShape — the one pointing at the target property.
182+
"""
183+
g, node_shape, prop_a, _ = _build_two_property_shape()
184+
shapes_list = load_shapes_from_graph(g)
185+
186+
pg = shapes_list.get_shape_property_graph(node_shape, prop_a)
187+
188+
property_links = list(pg.triples((node_shape, SH.property, None)))
189+
assert len(property_links) == 1
190+
assert property_links[0] == (node_shape, SH.property, prop_a)
191+
192+
193+
def test_unknown_shape_node_raises():
194+
"""A shape node not in the registry should raise ``KeyError``."""
195+
g, _, prop_a, _ = _build_two_property_shape()
196+
shapes_list = load_shapes_from_graph(g)
197+
198+
with pytest.raises(KeyError):
199+
shapes_list.get_shape_property_graph(EX.UnknownShape, prop_a)

0 commit comments

Comments
 (0)