forked from HAL9000/cleveragents-core
a808c395f9
Add 53 new .feature files and corresponding step definition files targeting uncovered lines identified in build/coverage.xml. Fix AmbiguousStep conflicts in 7 pre-existing step files by disambiguating step text. New tests cover: ACP clients/facade, actor CLI/config, application container, ACMS service/strategies, async worker, automation profile CLI, autonomy guardrail, bridge, change model, config CLI/service, context service, cross-plan correction, database models, decision service, decomposition clustering/service, discovery handler, langchain chat provider, langgraph nodes, materializers, multi-project service, plan apply/CLI/lifecycle/model/ preflight/resume/service, PostgreSQL analyzer, project CLI/context CLI, provider registry, reactive application/route, repositories, resolver handler, resource registry service, resume model, retry patterns, sandbox protocol, server CLI, skill CLI/service, skills registry, subplan execution/service, system CLI, UKO loader, UoW, and YAML template engine. Closes #645
458 lines
16 KiB
Python
458 lines
16 KiB
Python
"""Step definitions for the UKO Ontology Domain Registry feature.
|
|
|
|
Covers domain lookup, DetailLevelMap resolution (all four Layer 1
|
|
domains), inheritance chain building, Turtle syntax validation, and
|
|
the Universal View Guarantee.
|
|
|
|
Steps are prefixed with ``ontology_`` where needed to avoid collision
|
|
with existing step files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.domain.models.acms.crp import DetailLevelMap
|
|
from cleveragents.domain.models.acms.ontology_registry import (
|
|
DomainDescriptor,
|
|
build_detail_map_chain,
|
|
get_domain,
|
|
get_layer1_domains,
|
|
list_domains,
|
|
validate_turtle,
|
|
validate_turtle_file,
|
|
)
|
|
|
|
__all__: list[str] = []
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
"""Root of the project worktree (two levels up from features/steps/)."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the UKO ontology registry module is available")
|
|
def step_ontology_module_available(context: Context) -> None:
|
|
# Importing the module is the availability check.
|
|
context.ontology_error = None
|
|
context.ontology_domain = None
|
|
context.ontology_domains_list = None
|
|
context.ontology_layer1_list = None
|
|
context.ontology_chain = None
|
|
context.ontology_validation_errors = None
|
|
context.ontology_ttl_content = None
|
|
context.ontology_resolve_error = None
|
|
context.ontology_file_error = None
|
|
context.ontology_resolved_depth = None
|
|
context.ontology_custom_map = None
|
|
|
|
|
|
@given(
|
|
'a custom DetailLevelMap for domain "{domain}" '
|
|
'with parent "{parent_prefix}" and levels:'
|
|
)
|
|
def step_custom_map_with_parent(
|
|
context: Context,
|
|
domain: str,
|
|
parent_prefix: str,
|
|
) -> None:
|
|
parent_desc = get_domain(parent_prefix)
|
|
parent_map = parent_desc.detail_map
|
|
|
|
levels: dict[str, int] = {}
|
|
for row in context.table:
|
|
levels[row["level"]] = int(row["depth"])
|
|
|
|
# Determine max_depth: parent max or highest local level, whichever is bigger
|
|
local_max = max(levels.values()) if levels else 0
|
|
max_depth = max(parent_map.max_depth, local_max)
|
|
|
|
context.ontology_custom_map = DetailLevelMap(
|
|
domain=domain,
|
|
parent=parent_map,
|
|
levels=levels,
|
|
max_depth=max_depth,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps — domain lookup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I look up domain "{prefix}"')
|
|
def step_lookup_domain(context: Context, prefix: str) -> None:
|
|
context.ontology_error = None
|
|
try:
|
|
context.ontology_domain = get_domain(prefix)
|
|
except (ValueError, KeyError) as exc:
|
|
context.ontology_error = exc
|
|
|
|
|
|
@when("I look up an empty domain prefix")
|
|
def step_lookup_empty_domain(context: Context) -> None:
|
|
context.ontology_error = None
|
|
try:
|
|
context.ontology_domain = get_domain("")
|
|
except (ValueError, KeyError) as exc:
|
|
context.ontology_error = exc
|
|
|
|
|
|
@when("I list all domains")
|
|
def step_list_domains(context: Context) -> None:
|
|
context.ontology_domains_list = list_domains()
|
|
|
|
|
|
@when("I get Layer 1 domains")
|
|
def step_get_layer1(context: Context) -> None:
|
|
context.ontology_layer1_list = get_layer1_domains()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps — detail map chain
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I build a detail map chain from "{prefix}"')
|
|
def step_build_chain_single(context: Context, prefix: str) -> None:
|
|
context.ontology_error = None
|
|
try:
|
|
context.ontology_chain = build_detail_map_chain(prefix)
|
|
except ValueError as exc:
|
|
context.ontology_error = exc
|
|
|
|
|
|
@when("I build a detail map chain with no prefixes")
|
|
def step_build_chain_empty(context: Context) -> None:
|
|
context.ontology_error = None
|
|
try:
|
|
context.ontology_chain = build_detail_map_chain()
|
|
except ValueError as exc:
|
|
context.ontology_error = exc
|
|
|
|
|
|
@when('I try to resolve level "{level}"')
|
|
def step_try_resolve_level(context: Context, level: str) -> None:
|
|
context.ontology_resolve_error = None
|
|
try:
|
|
desc: DomainDescriptor = context.ontology_domain
|
|
desc.detail_map.resolve(level)
|
|
except ValueError as exc:
|
|
context.ontology_resolve_error = exc
|
|
# Also set the generic error so negative step works
|
|
context.ontology_error = exc
|
|
|
|
|
|
@when('I resolve "{level}" on the custom map')
|
|
def step_resolve_custom_map(context: Context, level: str) -> None:
|
|
context.ontology_resolved_depth = context.ontology_custom_map.resolve(level)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps — Turtle validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I validate the project Turtle file "{relpath}"')
|
|
def step_validate_project_ttl(context: Context, relpath: str) -> None:
|
|
full_path = _PROJECT_ROOT / relpath
|
|
context.ontology_validation_errors = validate_turtle_file(full_path)
|
|
|
|
|
|
@when("I validate Turtle content:")
|
|
def step_validate_turtle_content(context: Context) -> None:
|
|
context.ontology_validation_errors = validate_turtle(context.text)
|
|
|
|
|
|
@when("I validate empty Turtle content")
|
|
def step_validate_empty_turtle(context: Context) -> None:
|
|
context.ontology_validation_errors = validate_turtle("")
|
|
|
|
|
|
@when('I validate Turtle file "{path}"')
|
|
def step_validate_turtle_file_path(context: Context, path: str) -> None:
|
|
context.ontology_file_error = None
|
|
try:
|
|
context.ontology_validation_errors = validate_turtle_file(path)
|
|
except (FileNotFoundError, ValueError) as exc:
|
|
context.ontology_file_error = exc
|
|
context.ontology_error = exc
|
|
|
|
|
|
@when("I validate Turtle file with empty path")
|
|
def step_validate_turtle_empty_path(context: Context) -> None:
|
|
context.ontology_file_error = None
|
|
try:
|
|
context.ontology_validation_errors = validate_turtle_file("")
|
|
except (FileNotFoundError, ValueError) as exc:
|
|
context.ontology_file_error = exc
|
|
context.ontology_error = exc
|
|
|
|
|
|
@when('I read the project Turtle file "{relpath}"')
|
|
def step_read_project_ttl(context: Context, relpath: str) -> None:
|
|
full_path = _PROJECT_ROOT / relpath
|
|
context.ontology_ttl_content = full_path.read_text(encoding="utf-8")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps — domain descriptor assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the domain IRI should be "{expected_iri}"')
|
|
def step_check_domain_iri(context: Context, expected_iri: str) -> None:
|
|
desc: DomainDescriptor = context.ontology_domain
|
|
assert desc.iri == expected_iri, f"Expected IRI {expected_iri!r}, got {desc.iri!r}"
|
|
|
|
|
|
@then("the domain layer should be {expected:d}")
|
|
def step_check_domain_layer(context: Context, expected: int) -> None:
|
|
desc: DomainDescriptor = context.ontology_domain
|
|
assert desc.layer == expected, f"Expected layer {expected}, got {desc.layer}"
|
|
|
|
|
|
@then('the domain classes should contain "{cls}"')
|
|
def step_check_domain_class(context: Context, cls: str) -> None:
|
|
desc: DomainDescriptor = context.ontology_domain
|
|
assert cls in desc.classes, (
|
|
f"Class {cls!r} not found in {desc.prefix} classes: {sorted(desc.classes)}"
|
|
)
|
|
|
|
|
|
@then("the domain should have {count:d} classes")
|
|
def step_check_domain_class_count(context: Context, count: int) -> None:
|
|
desc: DomainDescriptor = context.ontology_domain
|
|
assert len(desc.classes) == count, (
|
|
f"Expected {count} classes, got {len(desc.classes)}: {sorted(desc.classes)}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps — error assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# REMOVED: Duplicate @then step - already defined in multi_project_service_coverage_boost_steps.py
|
|
|
|
|
|
@then("a FileNotFoundError should have been raised")
|
|
def step_check_file_not_found(context: Context) -> None:
|
|
err = context.ontology_file_error or context.ontology_error
|
|
assert err is not None, "Expected FileNotFoundError but none was raised"
|
|
assert isinstance(err, FileNotFoundError), (
|
|
f"Expected FileNotFoundError, got {type(err).__name__}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps — domain list assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the domain list should have {count:d} entries")
|
|
def step_check_domain_list_count(context: Context, count: int) -> None:
|
|
lst = context.ontology_domains_list
|
|
assert len(lst) == count, f"Expected {count} domains, got {len(lst)}"
|
|
|
|
|
|
@then("the domain list should be sorted by layer then prefix")
|
|
def step_check_domain_list_sorted(context: Context) -> None:
|
|
lst = context.ontology_domains_list
|
|
keys = [(d.layer, d.prefix) for d in lst]
|
|
assert keys == sorted(keys), f"Domain list not sorted: {keys}"
|
|
|
|
|
|
@then("the Layer 1 list should have {count:d} entries")
|
|
def step_check_layer1_count(context: Context, count: int) -> None:
|
|
lst = context.ontology_layer1_list
|
|
assert len(lst) == count, f"Expected {count} Layer 1 domains, got {len(lst)}"
|
|
|
|
|
|
@then("every domain in the Layer 1 list should have layer 1")
|
|
def step_check_all_layer1(context: Context) -> None:
|
|
for d in context.ontology_layer1_list:
|
|
assert d.layer == 1, f"Domain {d.prefix} has layer {d.layer}, expected 1"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps — detail map resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the detail map should resolve "{level}" to {expected:d}')
|
|
def step_check_detail_resolve(context: Context, level: str, expected: int) -> None:
|
|
desc: DomainDescriptor = context.ontology_domain
|
|
actual = desc.detail_map.resolve(level)
|
|
assert actual == expected, (
|
|
f"{desc.prefix} map resolved {level!r} to {actual}, expected {expected}"
|
|
)
|
|
|
|
|
|
@then("the detail map should clamp depth {raw:d} to {expected:d}")
|
|
def step_check_detail_clamp(context: Context, raw: int, expected: int) -> None:
|
|
desc: DomainDescriptor = context.ontology_domain
|
|
actual = desc.detail_map.resolve(raw)
|
|
assert actual == expected, (
|
|
f"{desc.prefix} map clamped {raw} to {actual}, expected {expected}"
|
|
)
|
|
|
|
|
|
@then("the resolved depth should be {expected:d}")
|
|
def step_check_resolved_depth(context: Context, expected: int) -> None:
|
|
actual = context.ontology_resolved_depth
|
|
assert actual == expected, f"Expected resolved depth {expected}, got {actual}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps — chain assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the chain should resolve "{level}" to {expected:d}')
|
|
def step_check_chain_resolve(context: Context, level: str, expected: int) -> None:
|
|
chain: DetailLevelMap = context.ontology_chain
|
|
actual = chain.resolve(level)
|
|
assert actual == expected, (
|
|
f"Chain resolved {level!r} to {actual}, expected {expected}"
|
|
)
|
|
|
|
|
|
@then("the chain max depth should be {expected:d}")
|
|
def step_check_chain_max_depth(context: Context, expected: int) -> None:
|
|
chain: DetailLevelMap = context.ontology_chain
|
|
assert chain.max_depth == expected, (
|
|
f"Chain max_depth is {chain.max_depth}, expected {expected}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps — Turtle validation assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the validation should produce {count:d} errors")
|
|
def step_check_validation_count(context: Context, count: int) -> None:
|
|
errs = context.ontology_validation_errors
|
|
assert len(errs) == count, (
|
|
f"Expected {count} validation error(s), got {len(errs)}: {errs}"
|
|
)
|
|
|
|
|
|
@then("the validation should produce at least {count:d} error")
|
|
def step_check_validation_min(context: Context, count: int) -> None:
|
|
errs = context.ontology_validation_errors
|
|
assert len(errs) >= count, (
|
|
f"Expected at least {count} error(s), got {len(errs)}: {errs}"
|
|
)
|
|
|
|
|
|
@then('the first error should contain "{fragment}"')
|
|
def step_check_first_error(context: Context, fragment: str) -> None:
|
|
errs = context.ontology_validation_errors
|
|
assert errs, "No validation errors to check"
|
|
assert fragment in errs[0], (
|
|
f"Expected first error to contain {fragment!r}, got: {errs[0]}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps — Turtle content assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the content should contain prefix "{prefix}"')
|
|
def step_check_content_prefix(context: Context, prefix: str) -> None:
|
|
content = context.ontology_ttl_content
|
|
# Match @prefix <name>: <iri> .
|
|
name = prefix.rstrip(":")
|
|
pattern = rf"@prefix\s+{re.escape(name)}\s*:"
|
|
assert re.search(pattern, content), (
|
|
f"Prefix {prefix!r} not declared in Turtle content"
|
|
)
|
|
|
|
|
|
@then('the content should contain "{text}"')
|
|
def step_check_content_text(context: Context, text: str) -> None:
|
|
content = context.ontology_ttl_content
|
|
assert text in content, f"Text {text!r} not found in Turtle content"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps — Universal View Guarantee
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Build a map of class → superclass(es) from the TTL for ancestry checks
|
|
_SUBCLASS_RE = re.compile(
|
|
r"(\S+:\S+)\s+a\s+owl:Class\s*;\s*\n\s*rdfs:subClassOf\s+([^;.]+)",
|
|
re.MULTILINE,
|
|
)
|
|
|
|
|
|
@then("every Layer 1 class should have a subClassOf chain to uko:InformationUnit")
|
|
def step_check_universal_view(context: Context) -> None:
|
|
content = context.ontology_ttl_content
|
|
|
|
# Parse subClassOf relationships
|
|
parent_map: dict[str, set[str]] = {}
|
|
for m in re.finditer(
|
|
r"(\S+:\S+)\s+a\s+owl:Class\s*;[^.]*?"
|
|
r"rdfs:subClassOf\s+([^;.]+)",
|
|
content,
|
|
re.DOTALL,
|
|
):
|
|
cls = m.group(1)
|
|
parents_str = m.group(2)
|
|
parents = {
|
|
p.strip().rstrip(";").strip() for p in parents_str.split(",") if p.strip()
|
|
}
|
|
parent_map[cls] = parents
|
|
|
|
# Layer 1 prefixes to check
|
|
layer1_prefixes = ("uko-code:", "uko-doc:", "uko-data:", "uko-infra:")
|
|
layer1_classes = [
|
|
cls for cls in parent_map if any(cls.startswith(p) for p in layer1_prefixes)
|
|
]
|
|
|
|
assert layer1_classes, "No Layer 1 classes found in TTL"
|
|
|
|
# Check each Layer 1 class can reach uko:InformationUnit
|
|
for cls in layer1_classes:
|
|
visited: set[str] = set()
|
|
queue = [cls]
|
|
found = False
|
|
while queue:
|
|
current = queue.pop()
|
|
if current == "uko:InformationUnit":
|
|
found = True
|
|
break
|
|
if current in visited:
|
|
continue
|
|
visited.add(current)
|
|
for parent in parent_map.get(current, set()):
|
|
queue.append(parent)
|
|
assert found, (
|
|
f"Class {cls} does not have a subClassOf chain to "
|
|
f"uko:InformationUnit (visited: {visited})"
|
|
)
|
|
|
|
|
|
@then("every domain detail map should resolve integer depth 0 to 0")
|
|
def step_check_all_maps_depth_zero(context: Context) -> None:
|
|
for d in context.ontology_layer1_list:
|
|
actual = d.detail_map.resolve(0)
|
|
assert actual == 0, (
|
|
f"Domain {d.prefix} resolved depth 0 to {actual}, expected 0"
|
|
)
|