Skip to content

Commit 439c68f

Browse files
committed
test(model): ✅ add unit tests for the pre/post internal validation hooks
1 parent e43364b commit 439c68f

1 file changed

Lines changed: 140 additions & 0 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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+
import pytest
16+
17+
from rocrate_validator import services
18+
from rocrate_validator.models import RequirementLoader, Severity, ValidationContext, ValidationSettings
19+
from tests.ro_crates import InvalidRootDataEntity
20+
21+
22+
class _RequirementTypeSpy:
23+
"""Stand-in for a Requirement subclass that records lifecycle hook calls."""
24+
25+
def __init__(self, name: str, timeline: list):
26+
self.__name__ = name
27+
self._timeline = timeline
28+
self.calls: list[tuple[str, ValidationContext]] = []
29+
30+
def initialize(self, context: ValidationContext) -> None:
31+
self.calls.append(("initialize", context))
32+
self._timeline.append(("initialize", self.__name__))
33+
34+
def finalize(self, context: ValidationContext) -> None:
35+
self.calls.append(("finalize", context))
36+
self._timeline.append(("finalize", self.__name__))
37+
38+
39+
@pytest.fixture
40+
def validation_settings():
41+
return ValidationSettings(
42+
rocrate_uri=str(InvalidRootDataEntity().invalid_root_type),
43+
requirement_severity=Severity.OPTIONAL,
44+
abort_on_first=False,
45+
)
46+
47+
48+
@pytest.fixture
49+
def lifecycle_spies(monkeypatch):
50+
"""
51+
Replace the registered requirement classes with two spy stand-ins.
52+
53+
Returns (spies, timeline) where timeline records the global ordering
54+
of hook invocations across all spies.
55+
"""
56+
timeline: list[tuple[str, str]] = []
57+
spies = [
58+
_RequirementTypeSpy("SpyTypeA", timeline),
59+
_RequirementTypeSpy("SpyTypeB", timeline),
60+
]
61+
monkeypatch.setattr(
62+
RequirementLoader,
63+
"__get_requirement_classes__",
64+
staticmethod(lambda: spies),
65+
)
66+
return spies, timeline
67+
68+
69+
def test_initialize_and_finalize_called_once_per_requirement_type(lifecycle_spies, validation_settings):
70+
"""
71+
Check that each requirement type's initialize and
72+
finalize hooks are called exactly once per validation run.
73+
"""
74+
spies, _ = lifecycle_spies
75+
76+
services.validate(validation_settings)
77+
78+
for spy in spies:
79+
events = [evt for evt, _ in spy.calls]
80+
assert events == ["initialize", "finalize"], (
81+
f"{spy.__name__} expected exactly one initialize then one finalize, got {events}"
82+
)
83+
84+
85+
def test_lifecycle_hooks_receive_the_same_validation_context(lifecycle_spies, validation_settings):
86+
"""
87+
Check that all lifecycle hooks receive the same ValidationContext instance.
88+
This ensures that the context is properly shared across all requirements.
89+
"""
90+
spies, _ = lifecycle_spies
91+
92+
services.validate(validation_settings)
93+
94+
contexts = [ctx for spy in spies for _, ctx in spy.calls]
95+
assert contexts, "No lifecycle hook was invoked"
96+
first = contexts[0]
97+
assert isinstance(first, ValidationContext)
98+
assert all(ctx is first for ctx in contexts), (
99+
"All initialize/finalize invocations must share the same ValidationContext"
100+
)
101+
102+
103+
def test_all_initialize_hooks_run_before_any_finalize_hook(lifecycle_spies, validation_settings):
104+
"""
105+
Check that all initialize hooks are called before any finalize hook is called.
106+
This ensures that the context is fully initialized before any requirement starts finalizing.
107+
"""
108+
_, timeline = lifecycle_spies
109+
110+
services.validate(validation_settings)
111+
112+
init_indices = [i for i, (evt, _) in enumerate(timeline) if evt == "initialize"]
113+
finalize_indices = [i for i, (evt, _) in enumerate(timeline) if evt == "finalize"]
114+
assert init_indices and finalize_indices, "Lifecycle hooks were not all triggered"
115+
assert max(init_indices) < min(finalize_indices), (
116+
f"Expected every initialize to precede every finalize, got timeline {timeline}"
117+
)
118+
119+
120+
def test_lifecycle_hooks_invoked_exactly_once_per_validation_run(lifecycle_spies, validation_settings):
121+
"""
122+
Run validation multiple times and check that each spy receives exactly one
123+
initialize+finalize pair per run.
124+
"""
125+
126+
# extract spies from fixture
127+
spies, _ = lifecycle_spies
128+
129+
# run validation multiple times and
130+
# check that each spy receives exactly one initialize+finalize pair per run
131+
runs = 3
132+
for _ in range(runs):
133+
services.validate(validation_settings)
134+
135+
for spy in spies:
136+
events = [evt for evt, _ in spy.calls]
137+
assert events == ["initialize", "finalize"] * runs, (
138+
f"{spy.__name__} should receive exactly one initialize+finalize "
139+
f"pair per validation run (got {events} across {runs} runs)"
140+
)

0 commit comments

Comments
 (0)