Skip to content

Commit 7c89093

Browse files
committed
test(SHACL-core): ✅ add unit/integration tests
1 parent 740266c commit 7c89093

4 files changed

Lines changed: 473 additions & 0 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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+
@prefix dct: <http://purl.org/dc/terms/> .
16+
@prefix ro-crate: <https://w3id.org/ro/crate/1.1#> .
17+
@prefix sh: <http://www.w3.org/ns/shacl#> .
18+
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
19+
20+
# Test shape for SPARQL constraint with BNode sourceShape resolution
21+
# This shape ALWAYS produces a violation to test the handling of SPARQL constraints
22+
# with BNode sourceShape (sh:sparql creates a BNode constraint node)
23+
24+
ro-crate:AlwaysFailShape a sh:NodeShape ;
25+
sh:name "Always Fail Test" ;
26+
sh:description "Test shape that always produces a violation to test SPARQL constraint handling." ;
27+
sh:targetNode ro-crate:ROCrateMetadataFileDescriptor ;
28+
sh:sparql [
29+
a sh:SPARQLConstraint ;
30+
sh:message "This is a test violation to verify SPARQL constraint handling" ;
31+
sh:select """
32+
SELECT $this
33+
WHERE {
34+
BIND($this AS ?fail)
35+
}
36+
""" ;
37+
sh:severity sh:Violation ;
38+
] .
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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+
@prefix dct: <http://purl.org/dc/terms/> .
15+
@prefix prof: <http://www.w3.org/ns/dx/prof/> .
16+
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
17+
18+
<https://w3id.org/sparql-test/1.0> a prof:Profile ;
19+
rdfs:label "SPARQL Constraint Test Profile" ;
20+
rdfs:comment """A test profile to verify SPARQL constraint handling with BNode sourceShapes."""@en ;
21+
dct:publisher <https://crs4.it> ;
22+
# prof:isProfileOf <https://w3id.org/ro/crate/1.1> ;
23+
prof:hasToken "sparql-test" ;
24+
.
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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+
Integration tests for SPARQL constraint handling in SHACL validation.
17+
18+
These tests verify that:
19+
1. Violations from SPARQL constraints (where sourceShape is a BNode)
20+
are correctly resolved to their parent NodeShape/PropertyShape
21+
2. The import cycle between utils.py and models.py is properly resolved
22+
3. Error messages include appropriate context from the parent shape
23+
24+
The tests use a custom profile (sparql-test) that extends ro-crate
25+
and includes a NodeShape with a sh:sparql constraint.
26+
"""
27+
28+
import json
29+
import logging
30+
import os
31+
import tempfile
32+
from pathlib import Path
33+
34+
import pytest
35+
from rdflib import BNode, Graph, Namespace, URIRef
36+
37+
from rocrate_validator import models, services
38+
from rocrate_validator.models import Severity, ValidationResult
39+
from rocrate_validator.requirements.shacl.models import Shape, ShapesRegistry
40+
from rocrate_validator.requirements.shacl.utils import resolve_parent_shape
41+
from tests.conftest import TEST_DATA_PATH
42+
43+
44+
logger = logging.getLogger(__name__)
45+
46+
CURRENT_PATH = os.path.dirname(os.path.realpath(__file__))
47+
48+
SPARQL_TEST_PROFILES_PATH = os.path.join(TEST_DATA_PATH, "profiles", "sparql_test")
49+
50+
51+
@pytest.fixture
52+
def sparql_test_profiles_path():
53+
"""Path to the test profile with SPARQL constraints."""
54+
return SPARQL_TEST_PROFILES_PATH
55+
56+
57+
@pytest.fixture
58+
def sparql_test_rocrate():
59+
"""Create a minimal valid RO-Crate for SPARQL constraint testing."""
60+
with tempfile.TemporaryDirectory() as tmp_dir:
61+
rocrate_dir = Path(tmp_dir) / "sparql_test_crate"
62+
rocrate_dir.mkdir()
63+
64+
metadata = {
65+
"@context": "https://w3id.org/ro/crate/1.1/context",
66+
"@graph": [
67+
{
68+
"@id": "ro-crate-metadata.json",
69+
"@type": "CreativeWork",
70+
"conformsTo": {"@id": "https://w3id.org/ro/crate/1.1"},
71+
"about": {"@id": "./"},
72+
},
73+
{
74+
"@id": "./",
75+
"@type": "Dataset",
76+
"name": "Test RO-Crate for SPARQL Constraints",
77+
"description": "A minimal RO-Crate to test SPARQL constraint handling",
78+
"datePublished": "2024-01-01",
79+
"license": {"@id": "http://spdx.org/licenses/CC0-1.0"},
80+
},
81+
],
82+
}
83+
84+
with open(rocrate_dir / "ro-crate-metadata.json", "w") as f:
85+
json.dump(metadata, f, indent=2)
86+
87+
yield rocrate_dir
88+
89+
90+
def test_sparql_profile_shape_loaded_correctly(sparql_test_profiles_path):
91+
"""Test that the sparql-test profile loads the test shape with SPARQL constraint."""
92+
registry = ShapesRegistry()
93+
shape_file = os.path.join(
94+
sparql_test_profiles_path, "must", "agent_project_intersection.ttl"
95+
)
96+
97+
shapes = registry.load_shapes(shape_file)
98+
99+
assert len(shapes) > 0, "Should load at least one shape"
100+
101+
# Find the test shape (AlwaysFailShape or similar name)
102+
test_shape = None
103+
for shape in shapes:
104+
if (
105+
"Always" in shape.name
106+
or "Test" in shape.name
107+
or "test" in shape.name.lower()
108+
):
109+
test_shape = shape
110+
break
111+
112+
assert test_shape is not None, "Should find the test SPARQL shape"
113+
assert test_shape.description is not None
114+
assert len(test_shape.description) > 0
115+
116+
117+
def test_sparql_constraint_with_bnode_sourceShape(
118+
sparql_test_profiles_path, sparql_test_rocrate
119+
):
120+
"""
121+
Test that SPARQL constraint violations with BNode sourceShape
122+
are handled gracefully by the validation pipeline.
123+
124+
Uses the sparql-test profile which includes AgentProjectIntersection
125+
shape with a sh:sparql constraint targeting ROCrateMetadataFileDescriptor.
126+
"""
127+
result = services.validate(
128+
models.ValidationSettings(
129+
rocrate_uri=sparql_test_rocrate,
130+
requirement_severity=Severity.REQUIRED,
131+
profile_identifier="sparql-test",
132+
profiles_path=sparql_test_profiles_path,
133+
)
134+
)
135+
136+
assert result is not None
137+
assert isinstance(result, ValidationResult)
138+
assert result.context is not None
139+
# The SPARQL constraint uses FILTER(true) so it should produce a violation
140+
issues = list(result.get_issues(Severity.REQUIRED))
141+
assert len(issues) > 0, "Expected issues from SPARQL constraint violation"
142+
assert issues[0].check is not None, "Issue should have an associated check"
143+
assert issues[0].check.description is not None, "Check should have a description"
144+
assert issues[0].message is not None, "Issue should have a message"
145+
assert len(issues[0].message) > 0, "Issue message should not be empty"
146+
assert (
147+
"SPARQL constraint violation" in issues[0].message
148+
or "SPARQL" in issues[0].check.description
149+
), "Check description should reference parent shape"
150+
151+
152+
def test_resolve_parent_shape_with_sparql_bnode():
153+
"""
154+
Test resolve_parent_shape with a BNode representing a sh:sparql constraint.
155+
156+
This simulates the scenario where pyshacl reports a violation with
157+
sourceShape being a BNode (the sh:SPARQLConstraint).
158+
"""
159+
SHACL = Namespace("http://www.w3.org/ns/shacl#")
160+
161+
registry = ShapesRegistry()
162+
profiles_path = "rocrate_validator/profiles/ro-crate/must"
163+
164+
# Load shapes from profile
165+
for filename in os.listdir(profiles_path):
166+
if filename.endswith(".ttl"):
167+
registry.load_shapes(os.path.join(profiles_path, filename))
168+
169+
g = Graph()
170+
171+
# Simulate a NodeShape with a SPARQL constraint (BNode)
172+
node_shape_uri = URIRef("http://example.org/TestShape")
173+
sparql_bnode = BNode()
174+
175+
g.add((node_shape_uri, SHACL.sparql, sparql_bnode))
176+
g.add((node_shape_uri, SHACL.targetClass, URIRef("http://example.org/TestClass")))
177+
178+
# Create and register the shape
179+
shape = Shape(node_shape_uri, g)
180+
shape._name = "TestShape"
181+
shape._description = "Test shape for SPARQL constraint"
182+
registry.add_shape(shape)
183+
184+
# Resolve the parent shape from the BNode
185+
result = resolve_parent_shape(g, sparql_bnode, registry)
186+
187+
assert result is not None, "Should resolve parent shape for SPARQL BNode"
188+
assert result.key == shape.key, "Resolved shape key should match the original shape"
189+
assert result.name == "TestShape", "Resolved shape should have correct name"

0 commit comments

Comments
 (0)