Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 080c8dda90 | |||
| de610530d6 | |||
| d1037c370a |
@@ -32,6 +32,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
### Added
|
||||
|
||||
|
||||
- **UKO Graph Persistence Test Coverage** (#891): Added comprehensive Behave BDD scenarios, Robot Framework integration tests, and ASV benchmarks for `src/cleveragents/application/services/uko_persistence.py`. Covers JSONFilePersistenceBackend error paths (corrupted files, missing keys, permission errors), InMemoryPersistenceBackend lifecycle (save/load/clear isolation), UKOGraphPersistence edge cases (incomplete triple handling, add_triple failures, empty bindings filtering), whitespace validation on all backends, and full save/restore integration lifecycles. 50+ Behave scenarios across 16 feature file sections plus 4 Robot test cases provide end-to-end coverage of all public methods.
|
||||
- **Invariant Remove CLI Command** (#8530): Implemented `agents invariant remove <id>` command that soft-deletes an invariant by ID. The command displays a confirmation prompt before removal (bypassable with `--yes`/`-y`), outputs the removed invariant ID on success, and shows a clear error message when the invariant ID does not exist. Supports `--format` flag for JSON and YAML output. Full BDD test coverage and Robot Framework integration tests included.
|
||||
- **TDD: MCPToolAdapter.infer_resource_slots() TypeError with null properties** (#10470):
|
||||
Added a TDD issue-capture Behave scenario that reproduces the bug where
|
||||
|
||||
@@ -26,3 +26,4 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed the file edit encoding parameter fix (PR #8258 / issue #7559).
|
||||
* HAL 9000 has contributed the architecture-pool-supervisor milestone assignment feature (PR #8188 / issue #7521): added `forgejo_update_pull_request` permission and documented the PR workflow for major spec changes, enabling automatic milestone assignment for specification PRs.
|
||||
* HAL 9000 has contributed the git worktree TOCTOU race condition fix (PR #8178 / issue #7507): replaced the unsafe mkdtemp() + rmdir() pattern with a parent-directory approach to eliminate the race window in concurrent git worktree operations.
|
||||
* HAL 9000 has contributed comprehensive BDD, Robot Framework, and ASV benchmarks for UKO graph persistence (PR #10957 / issue #891): added 50+ Behave scenarios covering error paths, edge cases, and full save/restore lifecycles; 4 Robot integration test cases validating real process execution; and 6 ASV benchmark methods measuring save/restore performance at 10/100/1000 triple counts.
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""ASV benchmarks for UKO Graph Persistence service.
|
||||
|
||||
Benchmarks the save() and restore() methods under varying triple counts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from cleveragents.application.services.uko_persistence import (
|
||||
InMemoryPersistenceBackend,
|
||||
JSONFilePersistenceBackend,
|
||||
UKOGraphPersistence,
|
||||
)
|
||||
from cleveragents.domain.models.acms.index_backends import GraphIndexBackend
|
||||
|
||||
|
||||
class UKOPersistenceBenchmarks:
|
||||
"""Benchmarks for UKO Graph Persistence."""
|
||||
|
||||
params = [10, 100, 1000]
|
||||
param_names = ["triple_count"]
|
||||
|
||||
def setup(self, triple_count: int) -> None:
|
||||
"""Set up benchmark fixtures."""
|
||||
self.triple_count = triple_count
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
# Create mock graph backend
|
||||
self.graph_backend = MagicMock(spec=GraphIndexBackend)
|
||||
|
||||
# Create sample triples
|
||||
self.triples = [
|
||||
{
|
||||
"subject": f"uko://code/module/mod_{i}",
|
||||
"predicate": "uko:type",
|
||||
"object": "uko-py:Module",
|
||||
}
|
||||
for i in range(triple_count)
|
||||
]
|
||||
|
||||
# Set up graph backend to return triples
|
||||
self.graph_backend.query.return_value = [
|
||||
{
|
||||
"s": t["subject"],
|
||||
"p": t["predicate"],
|
||||
"o": t["object"],
|
||||
}
|
||||
for t in self.triples
|
||||
]
|
||||
|
||||
def teardown(self, triple_count: int) -> None:
|
||||
"""Clean up after benchmark."""
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def time_save_with_in_memory_backend(self, triple_count: int) -> None:
|
||||
"""Benchmark save() with InMemoryPersistenceBackend."""
|
||||
persistence_backend = InMemoryPersistenceBackend()
|
||||
service = UKOGraphPersistence(
|
||||
graph_backend=self.graph_backend,
|
||||
project="local/benchmark",
|
||||
persistence_backend=persistence_backend,
|
||||
)
|
||||
service.save()
|
||||
|
||||
def time_save_with_json_file_backend(self, triple_count: int) -> None:
|
||||
"""Benchmark save() with JSONFilePersistenceBackend."""
|
||||
persistence_backend = JSONFilePersistenceBackend(self.temp_dir)
|
||||
service = UKOGraphPersistence(
|
||||
graph_backend=self.graph_backend,
|
||||
project="local/benchmark",
|
||||
persistence_backend=persistence_backend,
|
||||
)
|
||||
service.save()
|
||||
|
||||
def time_restore_with_in_memory_backend(self, triple_count: int) -> None:
|
||||
"""Benchmark restore() with InMemoryPersistenceBackend."""
|
||||
persistence_backend = InMemoryPersistenceBackend()
|
||||
persistence_backend.save("local/benchmark", self.triples)
|
||||
|
||||
service = UKOGraphPersistence(
|
||||
graph_backend=self.graph_backend,
|
||||
project="local/benchmark",
|
||||
persistence_backend=persistence_backend,
|
||||
)
|
||||
service.restore()
|
||||
|
||||
def time_restore_with_json_file_backend(self, triple_count: int) -> None:
|
||||
"""Benchmark restore() with JSONFilePersistenceBackend."""
|
||||
persistence_backend = JSONFilePersistenceBackend(self.temp_dir)
|
||||
persistence_backend.save("local/benchmark", self.triples)
|
||||
|
||||
service = UKOGraphPersistence(
|
||||
graph_backend=self.graph_backend,
|
||||
project="local/benchmark",
|
||||
persistence_backend=persistence_backend,
|
||||
)
|
||||
service.restore()
|
||||
|
||||
def time_json_file_backend_load(self, triple_count: int) -> None:
|
||||
"""Benchmark JSONFilePersistenceBackend.load()."""
|
||||
persistence_backend = JSONFilePersistenceBackend(self.temp_dir)
|
||||
persistence_backend.save("local/benchmark", self.triples)
|
||||
persistence_backend.load("local/benchmark")
|
||||
|
||||
def time_in_memory_backend_load(self, triple_count: int) -> None:
|
||||
"""Benchmark InMemoryPersistenceBackend.load()."""
|
||||
persistence_backend = InMemoryPersistenceBackend()
|
||||
persistence_backend.save("local/benchmark", self.triples)
|
||||
persistence_backend.load("local/benchmark")
|
||||
@@ -0,0 +1,766 @@
|
||||
"""Step implementations for UKO Graph Persistence coverage tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import tempfile
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import structlog
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.application.services import uko_persistence as _uko_mod
|
||||
from cleveragents.application.services.uko_persistence import (
|
||||
InMemoryPersistenceBackend,
|
||||
JSONFilePersistenceBackend,
|
||||
UKOGraphPersistence,
|
||||
)
|
||||
from cleveragents.domain.models.acms.index_backends import GraphIndexBackend
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Fixtures and helpers
|
||||
# =================================================================
|
||||
|
||||
|
||||
def create_mock_graph_backend() -> MagicMock:
|
||||
"""Create a mock GraphIndexBackend."""
|
||||
backend = MagicMock(spec=GraphIndexBackend)
|
||||
return backend
|
||||
|
||||
|
||||
def create_sample_triples() -> list[dict[str, str]]:
|
||||
"""Create sample valid triples."""
|
||||
return [
|
||||
{
|
||||
"subject": "uko://code/module/foo",
|
||||
"predicate": "uko:type",
|
||||
"object": "uko-py:Module",
|
||||
},
|
||||
{
|
||||
"subject": "uko://code/class/Foo",
|
||||
"predicate": "uko:type",
|
||||
"object": "uko-py:Class",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _capture_uko_logs() -> Generator[list[dict[str, Any]]]:
|
||||
"""Capture structlog entries emitted by the uko_persistence module.
|
||||
|
||||
Uses ``structlog.testing.LogCapture`` and temporarily replaces the
|
||||
module-level logger to work around ``cache_logger_on_first_use=True``.
|
||||
This approach avoids modifying global structlog configuration.
|
||||
"""
|
||||
cap = structlog.testing.LogCapture()
|
||||
old_logger = _uko_mod.logger
|
||||
# Build a fresh bound logger that routes through LogCapture without
|
||||
# touching the global structlog configuration.
|
||||
bound = structlog.wrap_logger(
|
||||
structlog.PrintLogger(),
|
||||
processors=[cap],
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=False,
|
||||
)
|
||||
_uko_mod.logger = bound # type: ignore[assignment]
|
||||
try:
|
||||
yield cap.entries
|
||||
finally:
|
||||
_uko_mod.logger = old_logger
|
||||
|
||||
|
||||
# =================================================================
|
||||
# JSONFilePersistenceBackend.load() — corrupted JSON file handling
|
||||
# =================================================================
|
||||
|
||||
|
||||
@given("uko a JSON file persistence backend in a non-existent temp directory")
|
||||
def step_json_file_persistence_backend_nonexistent(context: Any) -> None:
|
||||
"""Create a JSON file persistence backend in a non-existent temp directory."""
|
||||
temp_dir = Path(tempfile.gettempdir()) / "uko_test_nonexistent" / "subdir"
|
||||
context.uko_temp_dir = str(temp_dir)
|
||||
context.uko_json_backend = JSONFilePersistenceBackend(temp_dir)
|
||||
# Set uko_test_triples so parameterized save steps work without a prior
|
||||
# "uko an in-memory persistence backend" step.
|
||||
context.uko_test_triples = create_sample_triples()
|
||||
|
||||
|
||||
@given("uko the JSON file contains corrupted JSON data")
|
||||
def step_json_file_corrupted(context: Any) -> None:
|
||||
"""Create a JSON file with corrupted data."""
|
||||
backend: JSONFilePersistenceBackend = context.uko_json_backend
|
||||
path = backend._path_for("local/test")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("{invalid json content", encoding="utf-8")
|
||||
|
||||
|
||||
@given('uko the JSON file contains valid JSON but missing "triples" key')
|
||||
def step_json_file_missing_triples_key(context: Any) -> None:
|
||||
"""Create a JSON file with valid JSON but missing triples key."""
|
||||
backend: JSONFilePersistenceBackend = context.uko_json_backend
|
||||
path = backend._path_for("local/test")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {"version": "1", "project": "local/test"}
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
|
||||
@given("uko the JSON file exists but is unreadable (permission denied)")
|
||||
def step_json_file_unreadable(context: Any) -> None:
|
||||
"""Create a JSON file and make it unreadable."""
|
||||
backend: JSONFilePersistenceBackend = context.uko_json_backend
|
||||
path = backend._path_for("local/test")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps({"version": "1", "project": "local/test", "triples": []}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
path.chmod(0o000)
|
||||
context.uko_unreadable_file = path
|
||||
|
||||
|
||||
@when('uko I load from the JSON file backend for project "local/test"')
|
||||
def step_load_from_json_backend(context: Any) -> None:
|
||||
"""Load from JSON file backend, capturing any log output."""
|
||||
backend: JSONFilePersistenceBackend = context.uko_json_backend
|
||||
with _capture_uko_logs() as entries:
|
||||
context.uko_load_result = backend.load("local/test")
|
||||
context.uko_captured_logs = entries
|
||||
|
||||
|
||||
@then("uko the result should be an empty list")
|
||||
def step_result_is_empty_list(context: Any) -> None:
|
||||
"""Verify result is an empty list."""
|
||||
assert context.uko_load_result == [], (
|
||||
f"Expected empty list, got {context.uko_load_result}"
|
||||
)
|
||||
|
||||
|
||||
@then("uko a warning should be logged for load failure")
|
||||
def step_warning_logged_for_load_failure(context: Any) -> None:
|
||||
"""Verify a warning was logged for load failure."""
|
||||
logs: list[dict[str, Any]] = context.uko_captured_logs
|
||||
warning_events = [
|
||||
e
|
||||
for e in logs
|
||||
if e.get("log_level") == "warning" and "load_failed" in e.get("event", "")
|
||||
]
|
||||
assert warning_events, (
|
||||
f"Expected a warning log with 'load_failed' event, got: {logs}"
|
||||
)
|
||||
|
||||
|
||||
# =================================================================
|
||||
# UKOGraphPersistence.save() — query failure path
|
||||
# =================================================================
|
||||
|
||||
|
||||
@given("uko a graph backend that raises an exception on query")
|
||||
def step_graph_backend_raises_on_query(context: Any) -> None:
|
||||
"""Create a mock graph backend that raises on query."""
|
||||
backend = create_mock_graph_backend()
|
||||
backend.query.side_effect = RuntimeError("Query failed")
|
||||
context.uko_failing_graph_backend = backend
|
||||
|
||||
|
||||
@given("uko a UKOGraphPersistence service with the failing backend")
|
||||
def step_persistence_service_with_failing_backend(context: Any) -> None:
|
||||
"""Create a UKOGraphPersistence service with a failing backend."""
|
||||
graph_backend = context.uko_failing_graph_backend
|
||||
persistence_backend = InMemoryPersistenceBackend()
|
||||
context.uko_persistence_service = UKOGraphPersistence(
|
||||
graph_backend=graph_backend,
|
||||
project="local/test",
|
||||
persistence_backend=persistence_backend,
|
||||
)
|
||||
|
||||
|
||||
@when("uko I call save on the persistence service")
|
||||
def step_call_save_on_persistence_service(context: Any) -> None:
|
||||
"""Call save on the persistence service, capturing log output."""
|
||||
service: UKOGraphPersistence = context.uko_persistence_service
|
||||
with _capture_uko_logs() as entries:
|
||||
context.uko_save_result = service.save()
|
||||
context.uko_captured_logs = entries
|
||||
|
||||
|
||||
@then("uko a warning should be logged for query failure")
|
||||
def step_warning_logged_for_query_failure(context: Any) -> None:
|
||||
"""Verify a warning was logged for query failure."""
|
||||
logs: list[dict[str, Any]] = context.uko_captured_logs
|
||||
warning_events = [
|
||||
e
|
||||
for e in logs
|
||||
if e.get("log_level") == "warning" and "query_failed" in e.get("event", "")
|
||||
]
|
||||
assert warning_events, (
|
||||
f"Expected a warning log with 'query_failed' event, got: {logs}"
|
||||
)
|
||||
|
||||
|
||||
# =================================================================
|
||||
# UKOGraphPersistence.restore() — incomplete triple handling
|
||||
# =================================================================
|
||||
|
||||
|
||||
@given('uko a persistence backend with triples missing the "subject" key')
|
||||
def step_persistence_backend_missing_subject(context: Any) -> None:
|
||||
"""Create a persistence backend with triples missing subject."""
|
||||
backend = InMemoryPersistenceBackend()
|
||||
backend.save(
|
||||
"local/test",
|
||||
[
|
||||
{
|
||||
"predicate": "uko:type",
|
||||
"object": "uko-py:Module",
|
||||
}
|
||||
],
|
||||
)
|
||||
context.uko_persistence_backend = backend
|
||||
|
||||
|
||||
@given('uko a persistence backend with triples missing the "predicate" key')
|
||||
def step_persistence_backend_missing_predicate(context: Any) -> None:
|
||||
"""Create a persistence backend with triples missing predicate."""
|
||||
backend = InMemoryPersistenceBackend()
|
||||
backend.save(
|
||||
"local/test",
|
||||
[
|
||||
{
|
||||
"subject": "uko://code/module/foo",
|
||||
"object": "uko-py:Module",
|
||||
}
|
||||
],
|
||||
)
|
||||
context.uko_persistence_backend = backend
|
||||
|
||||
|
||||
@given('uko a persistence backend with triples missing the "object" key')
|
||||
def step_persistence_backend_missing_object(context: Any) -> None:
|
||||
"""Create a persistence backend with triples missing object."""
|
||||
backend = InMemoryPersistenceBackend()
|
||||
backend.save(
|
||||
"local/test",
|
||||
[
|
||||
{
|
||||
"subject": "uko://code/module/foo",
|
||||
"predicate": "uko:type",
|
||||
}
|
||||
],
|
||||
)
|
||||
context.uko_persistence_backend = backend
|
||||
|
||||
|
||||
@given("uko a persistence backend with triples having empty subject value")
|
||||
def step_persistence_backend_empty_subject(context: Any) -> None:
|
||||
"""Create a persistence backend with triples having empty subject."""
|
||||
backend = InMemoryPersistenceBackend()
|
||||
backend.save(
|
||||
"local/test",
|
||||
[
|
||||
{
|
||||
"subject": "",
|
||||
"predicate": "uko:type",
|
||||
"object": "uko-py:Module",
|
||||
}
|
||||
],
|
||||
)
|
||||
context.uko_persistence_backend = backend
|
||||
|
||||
|
||||
@given("uko a persistence backend with triples having empty predicate value")
|
||||
def step_persistence_backend_empty_predicate(context: Any) -> None:
|
||||
"""Create a persistence backend with triples having empty predicate."""
|
||||
backend = InMemoryPersistenceBackend()
|
||||
backend.save(
|
||||
"local/test",
|
||||
[
|
||||
{
|
||||
"subject": "uko://code/module/foo",
|
||||
"predicate": "",
|
||||
"object": "uko-py:Module",
|
||||
}
|
||||
],
|
||||
)
|
||||
context.uko_persistence_backend = backend
|
||||
|
||||
|
||||
@given("uko a persistence backend with triples having empty object value")
|
||||
def step_persistence_backend_empty_object(context: Any) -> None:
|
||||
"""Create a persistence backend with triples having empty object."""
|
||||
backend = InMemoryPersistenceBackend()
|
||||
backend.save(
|
||||
"local/test",
|
||||
[
|
||||
{
|
||||
"subject": "uko://code/module/foo",
|
||||
"predicate": "uko:type",
|
||||
"object": "",
|
||||
}
|
||||
],
|
||||
)
|
||||
context.uko_persistence_backend = backend
|
||||
|
||||
|
||||
@given("uko a UKOGraphPersistence service with the persistence backend")
|
||||
def step_persistence_service_with_backend(context: Any) -> None:
|
||||
"""Create a UKOGraphPersistence service with the persistence backend."""
|
||||
graph_backend = create_mock_graph_backend()
|
||||
persistence_backend = context.uko_persistence_backend
|
||||
context.uko_persistence_service = UKOGraphPersistence(
|
||||
graph_backend=graph_backend,
|
||||
project="local/test",
|
||||
persistence_backend=persistence_backend,
|
||||
)
|
||||
|
||||
|
||||
@when("uko I call restore on the persistence service")
|
||||
def step_call_restore_on_persistence_service(context: Any) -> None:
|
||||
"""Call restore on the persistence service, capturing log output."""
|
||||
service: UKOGraphPersistence = context.uko_persistence_service
|
||||
with _capture_uko_logs() as entries:
|
||||
context.uko_restore_result = service.restore()
|
||||
context.uko_captured_logs = entries
|
||||
|
||||
|
||||
@then("uko no triples should be added to the graph backend")
|
||||
def step_no_triples_added(context: Any) -> None:
|
||||
"""Verify no triples were added to the graph backend."""
|
||||
service: UKOGraphPersistence = context.uko_persistence_service
|
||||
service._graph_backend.add_triple.assert_not_called()
|
||||
|
||||
|
||||
# =================================================================
|
||||
# UKOGraphPersistence.restore() — add_triple failure path
|
||||
# =================================================================
|
||||
|
||||
|
||||
@given("uko a persistence backend with valid triples")
|
||||
def step_persistence_backend_with_valid_triples(context: Any) -> None:
|
||||
"""Create a persistence backend with valid triples."""
|
||||
backend = InMemoryPersistenceBackend()
|
||||
backend.save("local/test", create_sample_triples())
|
||||
context.uko_persistence_backend = backend
|
||||
|
||||
|
||||
@given("uko a graph backend that raises an exception on add_triple")
|
||||
def step_graph_backend_raises_on_add_triple(context: Any) -> None:
|
||||
"""Create a mock graph backend that raises on add_triple."""
|
||||
backend = create_mock_graph_backend()
|
||||
backend.add_triple.side_effect = RuntimeError("Add triple failed")
|
||||
context.uko_failing_graph_backend = backend
|
||||
|
||||
|
||||
@then("uko a warning should be logged for triple failure")
|
||||
def step_warning_logged_for_triple_failure(context: Any) -> None:
|
||||
"""Verify a warning was logged for triple failure."""
|
||||
logs: list[dict[str, Any]] = context.uko_captured_logs
|
||||
warning_events = [
|
||||
e
|
||||
for e in logs
|
||||
if e.get("log_level") == "warning" and "triple_failed" in e.get("event", "")
|
||||
]
|
||||
assert warning_events, (
|
||||
f"Expected a warning log with 'triple_failed' event, got: {logs}"
|
||||
)
|
||||
|
||||
|
||||
@given("uko a persistence backend with mixed valid and invalid triples")
|
||||
def step_persistence_backend_with_mixed_triples(context: Any) -> None:
|
||||
"""Create a persistence backend with mixed valid and invalid triples."""
|
||||
backend = InMemoryPersistenceBackend()
|
||||
triples = [
|
||||
*create_sample_triples(),
|
||||
{
|
||||
"subject": "",
|
||||
"predicate": "uko:type",
|
||||
"object": "uko-py:Module",
|
||||
},
|
||||
]
|
||||
backend.save("local/test", triples)
|
||||
context.uko_persistence_backend = backend
|
||||
|
||||
|
||||
@given("uko a graph backend that raises an exception only for specific triples")
|
||||
def step_graph_backend_selective_failure(context: Any) -> None:
|
||||
"""Create a mock graph backend that fails only for specific triples."""
|
||||
backend = create_mock_graph_backend()
|
||||
|
||||
def add_triple_side_effect(
|
||||
project: str, subject: str, predicate: str, obj: str
|
||||
) -> None:
|
||||
if subject == "uko://code/module/foo":
|
||||
raise RuntimeError("Add triple failed for foo")
|
||||
|
||||
backend.add_triple.side_effect = add_triple_side_effect
|
||||
context.uko_failing_graph_backend = backend
|
||||
|
||||
|
||||
@given("uko a UKOGraphPersistence service with the mixed backend")
|
||||
def step_persistence_service_with_mixed_backend(context: Any) -> None:
|
||||
"""Create a UKOGraphPersistence service with mixed backend."""
|
||||
graph_backend = context.uko_failing_graph_backend
|
||||
persistence_backend = context.uko_persistence_backend
|
||||
context.uko_persistence_service = UKOGraphPersistence(
|
||||
graph_backend=graph_backend,
|
||||
project="local/test",
|
||||
persistence_backend=persistence_backend,
|
||||
)
|
||||
|
||||
|
||||
@then("uko the restore result should be greater than 0")
|
||||
def step_restore_result_greater_than_zero(context: Any) -> None:
|
||||
"""Verify restore result is greater than 0."""
|
||||
assert context.uko_restore_result > 0, (
|
||||
f"Expected > 0, got {context.uko_restore_result}"
|
||||
)
|
||||
|
||||
|
||||
@then("uko warnings should be logged for failed triples")
|
||||
def step_warnings_logged_for_failed_triples(context: Any) -> None:
|
||||
"""Verify warnings were logged for failed triples."""
|
||||
logs: list[dict[str, Any]] = context.uko_captured_logs
|
||||
warning_events = [
|
||||
e
|
||||
for e in logs
|
||||
if e.get("log_level") == "warning" and "triple_failed" in e.get("event", "")
|
||||
]
|
||||
assert warning_events, (
|
||||
f"Expected warning logs with 'triple_failed' event, got: {logs}"
|
||||
)
|
||||
|
||||
|
||||
# =================================================================
|
||||
# InMemoryPersistenceBackend.clear() method
|
||||
# =================================================================
|
||||
|
||||
|
||||
@when('uko I clear the persistence backend for project "local/test"')
|
||||
def step_clear_persistence_backend(context: Any) -> None:
|
||||
"""Clear the persistence backend for a project."""
|
||||
backend: InMemoryPersistenceBackend = context.uko_persistence_backend
|
||||
backend.clear("local/test")
|
||||
|
||||
|
||||
@then(
|
||||
'uko loading from the persistence backend for project "local/test" should return empty list'
|
||||
)
|
||||
def step_load_after_clear_returns_empty(context: Any) -> None:
|
||||
"""Verify loading after clear returns empty list."""
|
||||
backend: InMemoryPersistenceBackend = context.uko_persistence_backend
|
||||
result = backend.load("local/test")
|
||||
assert result == [], f"Expected empty list, got {result}"
|
||||
|
||||
|
||||
@when('uko I clear the persistence backend for project "local/test1"')
|
||||
def step_clear_persistence_backend_test1(context: Any) -> None:
|
||||
"""Clear the persistence backend for project test1."""
|
||||
backend: InMemoryPersistenceBackend = context.uko_persistence_backend
|
||||
backend.clear("local/test1")
|
||||
|
||||
|
||||
@then(
|
||||
'uko loading from the persistence backend for project "local/test1" should return empty list'
|
||||
)
|
||||
def step_load_test1_returns_empty(context: Any) -> None:
|
||||
"""Verify loading test1 after clear returns empty list."""
|
||||
backend: InMemoryPersistenceBackend = context.uko_persistence_backend
|
||||
result = backend.load("local/test1")
|
||||
assert result == [], f"Expected empty list, got {result}"
|
||||
|
||||
|
||||
@then(
|
||||
'uko loading from the persistence backend for project "local/test2" should return the saved triples'
|
||||
)
|
||||
def step_load_test2_returns_saved(context: Any) -> None:
|
||||
"""Verify loading test2 returns the saved triples."""
|
||||
backend: InMemoryPersistenceBackend = context.uko_persistence_backend
|
||||
result = backend.load("local/test2")
|
||||
assert len(result) == 2, f"Expected 2 triples, got {len(result)}"
|
||||
|
||||
|
||||
@when('uko I clear the persistence backend for project "local/nonexistent"')
|
||||
def step_clear_nonexistent_project(context: Any) -> None:
|
||||
"""Clear a non-existent project."""
|
||||
backend: InMemoryPersistenceBackend = context.uko_persistence_backend
|
||||
try:
|
||||
backend.clear("local/nonexistent")
|
||||
context.uko_clear_error = None
|
||||
except Exception as e:
|
||||
context.uko_clear_error = e
|
||||
|
||||
|
||||
@then("uko no error should be raised")
|
||||
def step_no_error_raised(context: Any) -> None:
|
||||
"""Verify no error was raised."""
|
||||
assert context.uko_clear_error is None, (
|
||||
f"Expected no error, got {context.uko_clear_error}"
|
||||
)
|
||||
|
||||
|
||||
# =================================================================
|
||||
# UKOGraphPersistence.project property
|
||||
# =================================================================
|
||||
|
||||
|
||||
@given('uko a UKOGraphPersistence service with project "local/my-app"')
|
||||
def step_persistence_service_with_project(context: Any) -> None:
|
||||
"""Create a UKOGraphPersistence service with a specific project."""
|
||||
graph_backend = context.uko_graph_backend
|
||||
persistence_backend = InMemoryPersistenceBackend()
|
||||
context.uko_persistence_service = UKOGraphPersistence(
|
||||
graph_backend=graph_backend,
|
||||
project="local/my-app",
|
||||
persistence_backend=persistence_backend,
|
||||
)
|
||||
|
||||
|
||||
@when("uko I access the project property")
|
||||
def step_access_project_property(context: Any) -> None:
|
||||
"""Access the project property."""
|
||||
service: UKOGraphPersistence = context.uko_persistence_service
|
||||
context.uko_project_value = service.project
|
||||
|
||||
|
||||
@then('uko the project property should return "local/my-app"')
|
||||
def step_project_property_returns_value(context: Any) -> None:
|
||||
"""Verify the project property returns the correct value."""
|
||||
assert context.uko_project_value == "local/my-app", (
|
||||
f"Expected 'local/my-app', got {context.uko_project_value}"
|
||||
)
|
||||
|
||||
|
||||
@then("uko attempting to set the project property should raise AttributeError")
|
||||
def step_project_property_is_readonly(context: Any) -> None:
|
||||
"""Verify the project property is read-only."""
|
||||
service: UKOGraphPersistence = context.uko_persistence_service
|
||||
try:
|
||||
service.project = "local/other" # type: ignore[misc]
|
||||
raise AssertionError("Expected AttributeError")
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Integration: Full save/restore lifecycle with JSONFilePersistenceBackend
|
||||
# =================================================================
|
||||
|
||||
|
||||
@given("uko a mock graph backend with two sample triples")
|
||||
def step_graph_backend_with_triples_020(context: Any) -> None:
|
||||
"""Create a mock graph backend with two sample triples for lifecycle tests."""
|
||||
backend = create_mock_graph_backend()
|
||||
backend.query.return_value = [
|
||||
{
|
||||
"s": "uko://code/module/foo",
|
||||
"p": "uko:type",
|
||||
"o": "uko-py:Module",
|
||||
},
|
||||
{
|
||||
"s": "uko://code/class/Foo",
|
||||
"p": "uko:type",
|
||||
"o": "uko-py:Class",
|
||||
},
|
||||
]
|
||||
context.uko_graph_backend = backend
|
||||
|
||||
|
||||
@given("uko a UKOGraphPersistence service with the JSON file backend")
|
||||
def step_persistence_service_with_json_backend(context: Any) -> None:
|
||||
"""Create a UKOGraphPersistence service with the JSON file backend."""
|
||||
graph_backend = context.uko_graph_backend
|
||||
persistence_backend = context.uko_json_backend
|
||||
context.uko_persistence_service = UKOGraphPersistence(
|
||||
graph_backend=graph_backend,
|
||||
project="local/test",
|
||||
persistence_backend=persistence_backend,
|
||||
)
|
||||
|
||||
|
||||
@when("uko I save the graph state via the persistence service")
|
||||
def step_save_graph_state(context: Any) -> None:
|
||||
"""Save the graph state via the pre-configured persistence service."""
|
||||
service: UKOGraphPersistence = context.uko_persistence_service
|
||||
context.uko_save_result = service.save()
|
||||
|
||||
|
||||
@when(
|
||||
"uko I restore the graph state into a fresh backend with the same JSON file backend"
|
||||
)
|
||||
def step_restore_graph_state_with_json_backend(context: Any) -> None:
|
||||
"""Restore the graph state into a fresh backend."""
|
||||
fresh_backend = create_mock_graph_backend()
|
||||
persistence_backend = context.uko_json_backend
|
||||
service = UKOGraphPersistence(
|
||||
graph_backend=fresh_backend,
|
||||
project="local/test",
|
||||
persistence_backend=persistence_backend,
|
||||
)
|
||||
context.uko_restore_result = service.restore()
|
||||
context.uko_fresh_backend = fresh_backend
|
||||
|
||||
|
||||
@then("uko the restored backend should have the same triples")
|
||||
def step_restored_backend_has_same_triples(context: Any) -> None:
|
||||
"""Verify the restored backend has the same triples."""
|
||||
fresh_backend = context.uko_fresh_backend
|
||||
assert fresh_backend.add_triple.call_count == 2, (
|
||||
f"Expected 2 add_triple calls, got {fresh_backend.add_triple.call_count}"
|
||||
)
|
||||
|
||||
|
||||
@then("uko the base directory should be created")
|
||||
def step_base_directory_created(context: Any) -> None:
|
||||
"""Verify the base directory was created."""
|
||||
temp_dir = Path(context.uko_temp_dir)
|
||||
assert temp_dir.exists(), f"Expected directory to exist: {temp_dir}"
|
||||
|
||||
|
||||
@then("uko the JSON file should exist")
|
||||
def step_json_file_exists(context: Any) -> None:
|
||||
"""Verify the JSON file exists."""
|
||||
backend: JSONFilePersistenceBackend = context.uko_json_backend
|
||||
path = backend._path_for("local/test")
|
||||
assert path.exists(), f"Expected file to exist: {path}"
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Edge cases and validation
|
||||
# =================================================================
|
||||
|
||||
|
||||
@then(
|
||||
"uko creating UKOGraphPersistence with whitespace-only project should raise ValueError"
|
||||
)
|
||||
def step_persistence_service_whitespace_project(context: Any) -> None:
|
||||
"""Verify creating service with whitespace-only project raises ValueError."""
|
||||
graph_backend = context.uko_graph_backend
|
||||
try:
|
||||
UKOGraphPersistence(
|
||||
graph_backend=graph_backend,
|
||||
project=" ",
|
||||
persistence_backend=InMemoryPersistenceBackend(),
|
||||
)
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@then(
|
||||
"uko saving to JSON file backend with whitespace-only project should raise ValueError"
|
||||
)
|
||||
def step_save_json_whitespace_project(context: Any) -> None:
|
||||
"""Verify saving with whitespace-only project raises ValueError."""
|
||||
backend: JSONFilePersistenceBackend = context.uko_json_backend
|
||||
try:
|
||||
backend.save(" ", create_sample_triples())
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@then(
|
||||
"uko loading from JSON file backend with whitespace-only project should raise ValueError"
|
||||
)
|
||||
def step_load_json_whitespace_project(context: Any) -> None:
|
||||
"""Verify loading with whitespace-only project raises ValueError."""
|
||||
backend: JSONFilePersistenceBackend = context.uko_json_backend
|
||||
try:
|
||||
backend.load(" ")
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@then(
|
||||
"uko saving to persistence backend with whitespace-only project should raise ValueError"
|
||||
)
|
||||
def step_save_memory_whitespace_project(context: Any) -> None:
|
||||
"""Verify saving with whitespace-only project raises ValueError."""
|
||||
backend: InMemoryPersistenceBackend = context.uko_persistence_backend
|
||||
try:
|
||||
backend.save(" ", create_sample_triples())
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@then(
|
||||
"uko loading from persistence backend with whitespace-only project should raise ValueError"
|
||||
)
|
||||
def step_load_memory_whitespace_project(context: Any) -> None:
|
||||
"""Verify loading with whitespace-only project raises ValueError."""
|
||||
backend: InMemoryPersistenceBackend = context.uko_persistence_backend
|
||||
try:
|
||||
backend.load(" ")
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@given("uko a graph backend that returns empty bindings on query")
|
||||
def step_graph_backend_empty_bindings(context: Any) -> None:
|
||||
"""Create a graph backend that returns empty bindings."""
|
||||
backend = create_mock_graph_backend()
|
||||
backend.query.return_value = []
|
||||
context.uko_graph_backend = backend
|
||||
|
||||
|
||||
@given("uko a UKOGraphPersistence service with the backend")
|
||||
def step_persistence_service_with_graph_backend(context: Any) -> None:
|
||||
"""Create a UKOGraphPersistence service with the graph backend."""
|
||||
graph_backend = context.uko_graph_backend
|
||||
persistence_backend = InMemoryPersistenceBackend()
|
||||
context.uko_persistence_service = UKOGraphPersistence(
|
||||
graph_backend=graph_backend,
|
||||
project="local/test",
|
||||
persistence_backend=persistence_backend,
|
||||
)
|
||||
|
||||
|
||||
@given("uko a graph backend that returns bindings with missing keys")
|
||||
def step_graph_backend_missing_keys(context: Any) -> None:
|
||||
"""Create a graph backend that returns bindings with missing keys."""
|
||||
backend = create_mock_graph_backend()
|
||||
backend.query.return_value = [
|
||||
{
|
||||
"s": "uko://code/module/foo",
|
||||
# missing "p" and "o"
|
||||
},
|
||||
{
|
||||
"p": "uko:type",
|
||||
# missing "s" and "o"
|
||||
},
|
||||
]
|
||||
context.uko_graph_backend = backend
|
||||
|
||||
|
||||
@then("uko only complete triples should be persisted")
|
||||
def step_only_complete_triples_persisted(context: Any) -> None:
|
||||
"""Verify only complete triples were persisted."""
|
||||
service: UKOGraphPersistence = context.uko_persistence_service
|
||||
persistence_backend = service._persistence_backend
|
||||
triples = persistence_backend.load("local/test")
|
||||
assert len(triples) == 0, f"Expected 0 triples, got {len(triples)}"
|
||||
|
||||
|
||||
@given("uko a persistence backend with no data")
|
||||
def step_persistence_backend_no_data(context: Any) -> None:
|
||||
"""Create a persistence backend with no data."""
|
||||
context.uko_persistence_backend = InMemoryPersistenceBackend()
|
||||
|
||||
|
||||
@then("uko an info log should be recorded for no data")
|
||||
def step_info_log_no_data(context: Any) -> None:
|
||||
"""Verify an info log was recorded for no data."""
|
||||
logs: list[dict[str, Any]] = context.uko_captured_logs
|
||||
info_events = [
|
||||
e
|
||||
for e in logs
|
||||
if e.get("log_level") == "info" and "no_data" in e.get("event", "")
|
||||
]
|
||||
assert info_events, f"Expected an info log with 'no_data' event, got: {logs}"
|
||||
@@ -0,0 +1,206 @@
|
||||
Feature: UKO Graph Persistence — Full Coverage for Error Paths and Edge Cases
|
||||
As a test infrastructure maintainer
|
||||
I need comprehensive test coverage for all branches in uko_persistence.py
|
||||
So that the module meets the 97% coverage threshold and all error paths are validated
|
||||
|
||||
# =================================================================
|
||||
# JSONFilePersistenceBackend.load() — corrupted JSON file handling
|
||||
# =================================================================
|
||||
|
||||
Scenario: JSONFilePersistenceBackend.load() returns empty list when JSON file is corrupted
|
||||
Given uko a JSON file persistence backend in a temp directory
|
||||
And uko the JSON file contains corrupted JSON data
|
||||
When uko I load from the JSON file backend for project "local/test"
|
||||
Then uko the result should be an empty list
|
||||
And uko a warning should be logged for load failure
|
||||
|
||||
Scenario: JSONFilePersistenceBackend.load() returns empty list when JSON file is missing required keys
|
||||
Given uko a JSON file persistence backend in a temp directory
|
||||
And uko the JSON file contains valid JSON but missing "triples" key
|
||||
When uko I load from the JSON file backend for project "local/test"
|
||||
Then uko the result should be an empty list
|
||||
And uko a warning should be logged for load failure
|
||||
|
||||
Scenario: JSONFilePersistenceBackend.load() returns empty list when file is unreadable
|
||||
Given uko a JSON file persistence backend in a temp directory
|
||||
And uko the JSON file exists but is unreadable (permission denied)
|
||||
When uko I load from the JSON file backend for project "local/test"
|
||||
Then uko the result should be an empty list
|
||||
And uko a warning should be logged for load failure
|
||||
|
||||
# =================================================================
|
||||
# UKOGraphPersistence.save() — query failure path
|
||||
# =================================================================
|
||||
|
||||
Scenario: UKOGraphPersistence.save() returns 0 when graph_backend.query() raises an exception
|
||||
Given uko a graph backend that raises an exception on query
|
||||
And uko a UKOGraphPersistence service with the failing backend
|
||||
When uko I call save on the persistence service
|
||||
Then uko the save result should be 0
|
||||
And uko a warning should be logged for query failure
|
||||
|
||||
# =================================================================
|
||||
# UKOGraphPersistence.restore() — incomplete triple handling
|
||||
# =================================================================
|
||||
|
||||
Scenario: UKOGraphPersistence.restore() skips triples with missing subject key
|
||||
Given uko a persistence backend with triples missing the "subject" key
|
||||
And uko a UKOGraphPersistence service with the persistence backend
|
||||
When uko I call restore on the persistence service
|
||||
Then uko the restore result should be 0
|
||||
And uko no triples should be added to the graph backend
|
||||
|
||||
Scenario: UKOGraphPersistence.restore() skips triples with missing predicate key
|
||||
Given uko a persistence backend with triples missing the "predicate" key
|
||||
And uko a UKOGraphPersistence service with the persistence backend
|
||||
When uko I call restore on the persistence service
|
||||
Then uko the restore result should be 0
|
||||
And uko no triples should be added to the graph backend
|
||||
|
||||
Scenario: UKOGraphPersistence.restore() skips triples with missing object key
|
||||
Given uko a persistence backend with triples missing the "object" key
|
||||
And uko a UKOGraphPersistence service with the persistence backend
|
||||
When uko I call restore on the persistence service
|
||||
Then uko the restore result should be 0
|
||||
And uko no triples should be added to the graph backend
|
||||
|
||||
Scenario: UKOGraphPersistence.restore() skips triples with empty subject value
|
||||
Given uko a persistence backend with triples having empty subject value
|
||||
And uko a UKOGraphPersistence service with the persistence backend
|
||||
When uko I call restore on the persistence service
|
||||
Then uko the restore result should be 0
|
||||
And uko no triples should be added to the graph backend
|
||||
|
||||
Scenario: UKOGraphPersistence.restore() skips triples with empty predicate value
|
||||
Given uko a persistence backend with triples having empty predicate value
|
||||
And uko a UKOGraphPersistence service with the persistence backend
|
||||
When uko I call restore on the persistence service
|
||||
Then uko the restore result should be 0
|
||||
And uko no triples should be added to the graph backend
|
||||
|
||||
Scenario: UKOGraphPersistence.restore() skips triples with empty object value
|
||||
Given uko a persistence backend with triples having empty object value
|
||||
And uko a UKOGraphPersistence service with the persistence backend
|
||||
When uko I call restore on the persistence service
|
||||
Then uko the restore result should be 0
|
||||
And uko no triples should be added to the graph backend
|
||||
|
||||
# =================================================================
|
||||
# UKOGraphPersistence.restore() — add_triple failure path
|
||||
# =================================================================
|
||||
|
||||
Scenario: UKOGraphPersistence.restore() continues and logs warning when add_triple() raises an exception
|
||||
Given uko a persistence backend with valid triples
|
||||
And uko a graph backend that raises an exception on add_triple
|
||||
And uko a UKOGraphPersistence service with the failing backend
|
||||
When uko I call restore on the persistence service
|
||||
Then uko the restore result should be 0
|
||||
And uko a warning should be logged for triple failure
|
||||
|
||||
Scenario: UKOGraphPersistence.restore() restores valid triples even when some fail
|
||||
Given uko a persistence backend with mixed valid and invalid triples
|
||||
And uko a graph backend that raises an exception only for specific triples
|
||||
And uko a UKOGraphPersistence service with the mixed backend
|
||||
When uko I call restore on the persistence service
|
||||
Then uko the restore result should be greater than 0
|
||||
And uko warnings should be logged for failed triples
|
||||
|
||||
# =================================================================
|
||||
# InMemoryPersistenceBackend.clear() method
|
||||
# =================================================================
|
||||
|
||||
Scenario: InMemoryPersistenceBackend.clear() removes stored triples for a project
|
||||
Given uko an in-memory persistence backend
|
||||
And uko I save triples to the persistence backend for project "local/test"
|
||||
When uko I clear the persistence backend for project "local/test"
|
||||
Then uko loading from the persistence backend for project "local/test" should return empty list
|
||||
|
||||
Scenario: InMemoryPersistenceBackend.clear() does not affect other projects
|
||||
Given uko an in-memory persistence backend
|
||||
And uko I save triples to the persistence backend for project "local/test1"
|
||||
And uko I save triples to the persistence backend for project "local/test2"
|
||||
When uko I clear the persistence backend for project "local/test1"
|
||||
Then uko loading from the persistence backend for project "local/test1" should return empty list
|
||||
And uko loading from the persistence backend for project "local/test2" should return the saved triples
|
||||
|
||||
Scenario: InMemoryPersistenceBackend.clear() on non-existent project does not raise error
|
||||
Given uko an in-memory persistence backend
|
||||
When uko I clear the persistence backend for project "local/nonexistent"
|
||||
Then uko no error should be raised
|
||||
|
||||
# =================================================================
|
||||
# UKOGraphPersistence.project property
|
||||
# =================================================================
|
||||
|
||||
Scenario: UKOGraphPersistence.project property returns the correct project name
|
||||
Given uko a graph backend with no triples
|
||||
And uko a UKOGraphPersistence service with project "local/my-app"
|
||||
When uko I access the project property
|
||||
Then uko the project property should return "local/my-app"
|
||||
|
||||
Scenario: UKOGraphPersistence.project property is read-only
|
||||
Given uko a graph backend with no triples
|
||||
And uko a UKOGraphPersistence service with project "local/my-app"
|
||||
Then uko attempting to set the project property should raise AttributeError
|
||||
|
||||
# =================================================================
|
||||
# Integration: Full save/restore lifecycle with JSONFilePersistenceBackend
|
||||
# =================================================================
|
||||
|
||||
Scenario: Full save/restore lifecycle with JSONFilePersistenceBackend
|
||||
Given uko a mock graph backend with two sample triples
|
||||
And uko a JSON file persistence backend in a temp directory
|
||||
And uko a UKOGraphPersistence service with the JSON file backend
|
||||
When uko I save the graph state via the persistence service
|
||||
And uko I restore the graph state into a fresh backend with the same JSON file backend
|
||||
Then uko the restored backend should have the same triples
|
||||
|
||||
Scenario: JSONFilePersistenceBackend creates base directory if it does not exist
|
||||
Given uko a JSON file persistence backend in a non-existent temp directory
|
||||
When uko I save triples to the JSON file backend for project "local/test"
|
||||
Then uko the base directory should be created
|
||||
And uko the JSON file should exist
|
||||
|
||||
# =================================================================
|
||||
# Edge cases and validation
|
||||
# =================================================================
|
||||
|
||||
Scenario: UKOGraphPersistence rejects whitespace-only project name
|
||||
Given uko a graph backend with no triples
|
||||
Then uko creating UKOGraphPersistence with whitespace-only project should raise ValueError
|
||||
|
||||
Scenario: JSONFilePersistenceBackend rejects whitespace-only project on save
|
||||
Given uko a JSON file persistence backend in a temp directory
|
||||
Then uko saving to JSON file backend with whitespace-only project should raise ValueError
|
||||
|
||||
Scenario: JSONFilePersistenceBackend rejects whitespace-only project on load
|
||||
Given uko a JSON file persistence backend in a temp directory
|
||||
Then uko loading from JSON file backend with whitespace-only project should raise ValueError
|
||||
|
||||
Scenario: InMemoryPersistenceBackend rejects whitespace-only project on save
|
||||
Given uko an in-memory persistence backend
|
||||
Then uko saving to persistence backend with whitespace-only project should raise ValueError
|
||||
|
||||
Scenario: InMemoryPersistenceBackend rejects whitespace-only project on load
|
||||
Given uko an in-memory persistence backend
|
||||
Then uko loading from persistence backend with whitespace-only project should raise ValueError
|
||||
|
||||
Scenario: UKOGraphPersistence.save() handles empty bindings from query
|
||||
Given uko a graph backend that returns empty bindings on query
|
||||
And uko a UKOGraphPersistence service with the backend
|
||||
When uko I call save on the persistence service
|
||||
Then uko the save result should be 0
|
||||
|
||||
Scenario: UKOGraphPersistence.save() filters out incomplete bindings
|
||||
Given uko a graph backend that returns bindings with missing keys
|
||||
And uko a UKOGraphPersistence service with the backend
|
||||
When uko I call save on the persistence service
|
||||
Then uko the save result should be 0
|
||||
And uko only complete triples should be persisted
|
||||
|
||||
Scenario: UKOGraphPersistence.restore() handles empty persistence backend
|
||||
Given uko a persistence backend with no data
|
||||
And uko a UKOGraphPersistence service with the persistence backend
|
||||
When uko I call restore on the persistence service
|
||||
Then uko the restore result should be 0
|
||||
And uko an info log should be recorded for no data
|
||||
@@ -133,6 +133,8 @@ ignore = []
|
||||
"features/steps/*.py" = ["F811", "E501", "B010", "I001"]
|
||||
"features/mocks/*.py" = ["E501"]
|
||||
"features/environment.py" = ["E501"]
|
||||
# ASV benchmarks use mutable class-level params (standard ASV convention) and don't need Path import from pathlib
|
||||
"benchmarks/*.py" = ["RUF012"]
|
||||
# retry_patterns.py re-exports symbols from retry_service_patterns at module bottom
|
||||
"src/cleveragents/core/retry_patterns.py" = ["E402"]
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Robot Framework helper for UKO Graph Persistence integration tests.
|
||||
|
||||
Provides a CLI-style interface for Robot to invoke persistence operations
|
||||
and verify outcomes. Exit code 0 = success, 1 = failure.
|
||||
|
||||
Usage:
|
||||
python robot/helper_uko_persistence.py <command>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Ensure the src directory is on the import path.
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from cleveragents.application.services.uko_persistence import ( # noqa: E402
|
||||
InMemoryPersistenceBackend,
|
||||
JSONFilePersistenceBackend,
|
||||
UKOGraphPersistence,
|
||||
)
|
||||
from cleveragents.domain.models.acms.index_backends import ( # noqa: E402
|
||||
GraphIndexBackend,
|
||||
)
|
||||
|
||||
|
||||
def _make_mock_graph_backend(
|
||||
triples: list[dict[str, str]] | None = None,
|
||||
) -> MagicMock:
|
||||
"""Create a mock GraphIndexBackend."""
|
||||
backend = MagicMock(spec=GraphIndexBackend)
|
||||
if triples is not None:
|
||||
backend.query.return_value = triples
|
||||
return backend
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Entry point called by Robot Framework ``Run Process``."""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: helper_uko_persistence.py <command>")
|
||||
return 1
|
||||
|
||||
command: str = sys.argv[1]
|
||||
|
||||
if command == "save-restore-lifecycle":
|
||||
try:
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
persistence_backend = JSONFilePersistenceBackend(temp_dir)
|
||||
|
||||
graph_backend = _make_mock_graph_backend(
|
||||
triples=[
|
||||
{
|
||||
"s": "uko://code/module/foo",
|
||||
"p": "uko:type",
|
||||
"o": "uko-py:Module",
|
||||
},
|
||||
{
|
||||
"s": "uko://code/class/Foo",
|
||||
"p": "uko:type",
|
||||
"o": "uko-py:Class",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
service = UKOGraphPersistence(
|
||||
graph_backend=graph_backend,
|
||||
project="local/test",
|
||||
persistence_backend=persistence_backend,
|
||||
)
|
||||
saved_count = service.save()
|
||||
assert saved_count == 2, f"Expected 2 triples saved, got {saved_count}"
|
||||
|
||||
json_path = persistence_backend._path_for("local/test")
|
||||
assert json_path.exists(), f"Expected JSON file at {json_path}"
|
||||
|
||||
fresh_backend = _make_mock_graph_backend()
|
||||
restore_service = UKOGraphPersistence(
|
||||
graph_backend=fresh_backend,
|
||||
project="local/test",
|
||||
persistence_backend=persistence_backend,
|
||||
)
|
||||
restored_count = restore_service.restore()
|
||||
assert restored_count == 2, (
|
||||
f"Expected 2 triples restored, got {restored_count}"
|
||||
)
|
||||
assert fresh_backend.add_triple.call_count == 2, (
|
||||
"Expected 2 add_triple calls, "
|
||||
f"got {fresh_backend.add_triple.call_count}"
|
||||
)
|
||||
|
||||
print("uko-persistence-save-restore-ok")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"uko-persistence-save-restore-fail: {exc}")
|
||||
return 1
|
||||
|
||||
if command == "directory-creation":
|
||||
try:
|
||||
temp_base = Path(tempfile.gettempdir()) / "uko_robot_test_nonexistent"
|
||||
temp_dir = temp_base / "subdir"
|
||||
if temp_dir.exists():
|
||||
shutil.rmtree(str(temp_dir))
|
||||
|
||||
persistence_backend = JSONFilePersistenceBackend(temp_dir)
|
||||
persistence_backend.save(
|
||||
"local/test",
|
||||
[
|
||||
{
|
||||
"subject": "uko://code/module/foo",
|
||||
"predicate": "uko:type",
|
||||
"object": "uko-py:Module",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
assert temp_dir.exists(), f"Expected directory to be created: {temp_dir}"
|
||||
json_path = persistence_backend._path_for("local/test")
|
||||
assert json_path.exists(), f"Expected JSON file at {json_path}"
|
||||
|
||||
shutil.rmtree(str(temp_base))
|
||||
|
||||
print("uko-persistence-directory-creation-ok")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"uko-persistence-directory-creation-fail: {exc}")
|
||||
return 1
|
||||
|
||||
if command == "corrupted-file-handling":
|
||||
try:
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
persistence_backend = JSONFilePersistenceBackend(temp_dir)
|
||||
|
||||
json_path = persistence_backend._path_for("local/test")
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_path.write_text("{invalid json content", encoding="utf-8")
|
||||
|
||||
result = persistence_backend.load("local/test")
|
||||
assert result == [], f"Expected empty list for corrupted file, got {result}"
|
||||
|
||||
print("uko-persistence-corrupted-file-ok")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"uko-persistence-corrupted-file-fail: {exc}")
|
||||
return 1
|
||||
|
||||
if command == "in-memory-clear":
|
||||
try:
|
||||
backend = InMemoryPersistenceBackend()
|
||||
|
||||
triples = [
|
||||
{
|
||||
"subject": "uko://code/module/foo",
|
||||
"predicate": "uko:type",
|
||||
"object": "uko-py:Module",
|
||||
},
|
||||
]
|
||||
backend.save("local/project-a", triples)
|
||||
backend.save("local/project-b", triples)
|
||||
|
||||
backend.clear("local/project-a")
|
||||
|
||||
result_a = backend.load("local/project-a")
|
||||
assert result_a == [], (
|
||||
f"Expected empty list for cleared project, got {result_a}"
|
||||
)
|
||||
|
||||
result_b = backend.load("local/project-b")
|
||||
assert len(result_b) == 1, (
|
||||
f"Expected 1 triple for project-b, got {len(result_b)}"
|
||||
)
|
||||
|
||||
backend.clear("local/nonexistent")
|
||||
|
||||
print("uko-persistence-in-memory-clear-ok")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"uko-persistence-in-memory-clear-fail: {exc}")
|
||||
return 1
|
||||
|
||||
if command == "restore-skips-incomplete":
|
||||
try:
|
||||
backend = InMemoryPersistenceBackend()
|
||||
backend.save(
|
||||
"local/test",
|
||||
[
|
||||
# missing subject
|
||||
{"predicate": "uko:type", "object": "uko-py:Module"},
|
||||
# missing predicate
|
||||
{
|
||||
"subject": "uko://code/module/foo",
|
||||
"object": "uko-py:Module",
|
||||
},
|
||||
# missing object
|
||||
{
|
||||
"subject": "uko://code/module/foo",
|
||||
"predicate": "uko:type",
|
||||
},
|
||||
# empty subject
|
||||
{
|
||||
"subject": "",
|
||||
"predicate": "uko:type",
|
||||
"object": "uko-py:Module",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
graph_backend = _make_mock_graph_backend()
|
||||
service = UKOGraphPersistence(
|
||||
graph_backend=graph_backend,
|
||||
project="local/test",
|
||||
persistence_backend=backend,
|
||||
)
|
||||
restored = service.restore()
|
||||
assert restored == 0, f"Expected 0 triples restored, got {restored}"
|
||||
graph_backend.add_triple.assert_not_called()
|
||||
|
||||
print("uko-persistence-restore-skips-incomplete-ok")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"uko-persistence-restore-skips-incomplete-fail: {exc}")
|
||||
return 1
|
||||
|
||||
print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,54 @@
|
||||
*** Settings ***
|
||||
Documentation Robot Framework integration tests for UKO Graph Persistence
|
||||
... Tests the full save/restore lifecycle with real temporary directories
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_uko_persistence.py
|
||||
|
||||
*** Test Cases ***
|
||||
Full Save And Restore Lifecycle With JSON File Backend
|
||||
[Documentation] Test the complete save/restore cycle with JSONFilePersistenceBackend
|
||||
[Tags] integration persistence json-backend
|
||||
${result}= Run Process ${PYTHON} ${HELPER} save-restore-lifecycle cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} uko-persistence-save-restore-ok
|
||||
|
||||
JSON File Backend Creates Directory If Missing
|
||||
[Documentation] Test that JSONFilePersistenceBackend creates the base directory
|
||||
[Tags] integration persistence json-backend
|
||||
${result}= Run Process ${PYTHON} ${HELPER} directory-creation cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} uko-persistence-directory-creation-ok
|
||||
|
||||
JSON File Backend Handles Corrupted Files Gracefully
|
||||
[Documentation] Test that JSONFilePersistenceBackend handles corrupted JSON files
|
||||
[Tags] integration persistence json-backend error-handling
|
||||
${result}= Run Process ${PYTHON} ${HELPER} corrupted-file-handling cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} uko-persistence-corrupted-file-ok
|
||||
|
||||
InMemoryPersistenceBackend Clear Method
|
||||
[Documentation] Test InMemoryPersistenceBackend.clear() isolates projects correctly
|
||||
[Tags] integration persistence in-memory
|
||||
${result}= Run Process ${PYTHON} ${HELPER} in-memory-clear cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} uko-persistence-in-memory-clear-ok
|
||||
|
||||
Restore Skips Incomplete Triples
|
||||
[Documentation] Test that restore() skips triples with missing or empty keys
|
||||
[Tags] integration persistence restore
|
||||
${result}= Run Process ${PYTHON} ${HELPER} restore-skips-incomplete cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} uko-persistence-restore-skips-incomplete-ok
|
||||
Reference in New Issue
Block a user