8fa9e7652d
CI / quality (pull_request) Successful in 42s
CI / lint (pull_request) Successful in 46s
CI / security (pull_request) Successful in 47s
CI / build (pull_request) Successful in 50s
CI / typecheck (pull_request) Successful in 1m4s
CI / integration_tests (pull_request) Successful in 1m9s
CI / unit_tests (pull_request) Successful in 3m43s
CI / coverage (pull_request) Successful in 3m42s
CI / status-check (pull_request) Successful in 3s
CI / lint (push) Successful in 59s
CI / typecheck (push) Successful in 58s
CI / quality (push) Successful in 1m5s
CI / security (push) Successful in 1m9s
CI / build (push) Successful in 51s
CI / integration_tests (push) Successful in 1m40s
CI / unit_tests (push) Successful in 3m53s
CI / coverage (push) Successful in 3m35s
CI / status-check (push) Successful in 3s
Create Canonicalizer class implementing Package Registry Standard v1.0.0 §6: - NFC normalization of all string values via unicodedata.normalize - Lexicographic key sorting for deterministic output - Lifecycle field stripping (version, release_date) - RFC-8785 canonical JSON serialization (no whitespace, sorted keys) - SHA-1 content-addressed hashing via compute_package_id() - Internal reference resolution with configurable resolver callback 10 integration test cases covering: - Complex content canonicalization with lifecycle stripping - Deterministic output across multiple runs - Key sorting enforcement - Unicode NFC normalization - PackageId computation for all 8 package types - Different content produces different output ISSUES CLOSED: #25
269 lines
11 KiB
Python
269 lines
11 KiB
Python
"""Robot Framework keyword library for Canonicalizer integration tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from cleveractors.registry.canonical import Canonicalizer
|
|
from cleveractors.registry.types import PackageType
|
|
|
|
ROBOT_LIBRARY_SCOPE = "TEST SUITE" # pragma: no cover - integration test library
|
|
|
|
|
|
class CanonicalizerLib: # pragma: no cover - integration test library
|
|
"""Keyword library for Canonicalizer Robot Framework integration tests."""
|
|
|
|
def __init__(self) -> None:
|
|
self._canonicalizer: Canonicalizer = Canonicalizer()
|
|
self._result: str = ""
|
|
self._content: dict[str, Any] = {}
|
|
self._package_id_string: str = ""
|
|
self._package_id_string_2: str = ""
|
|
self._canonical_output_2: str = ""
|
|
self._content_original: dict[str, Any] = {}
|
|
|
|
def create_canonicalizer(self) -> None:
|
|
"""Create a fresh Canonicalizer instance for a test case."""
|
|
self._canonicalizer = Canonicalizer()
|
|
|
|
def set_content_from_yaml_dict(self, yaml_string: str) -> None:
|
|
"""Parse a YAML string into the internal content dict."""
|
|
import yaml
|
|
|
|
self._content = yaml.safe_load(yaml_string)
|
|
|
|
def set_complex_content(self) -> None:
|
|
"""Set content to a complex nested dict with lifecycle fields."""
|
|
self._content = {
|
|
"name": "complex-actor",
|
|
"description": "An actor with many properties",
|
|
"type": "llm",
|
|
"model": "gpt-4",
|
|
"temperature": 0.7,
|
|
"max_tokens": 4096,
|
|
"version": "2.1.0",
|
|
"release_date": "2026-06-07",
|
|
"metadata": {
|
|
"author": "Luis Mendes",
|
|
"tags": ["ai", "nlp", "conversation"],
|
|
"license": "MIT",
|
|
"version": "1.0.0",
|
|
},
|
|
"parameters": {
|
|
"top_p": 0.9,
|
|
"frequency_penalty": 0.0,
|
|
"presence_penalty": 0.0,
|
|
},
|
|
}
|
|
|
|
def set_unicode_content(self) -> None:
|
|
"""Set content containing accented Unicode strings for NFC testing."""
|
|
self._content = {
|
|
"name": "unicode-test",
|
|
"description": "café résumé naïve garçon voilà",
|
|
"labels": ["café", "résumé", "naïve"],
|
|
"nested": {"key": "garçon voilà"},
|
|
}
|
|
|
|
def set_content_with_unsorted_keys(self) -> None:
|
|
"""Set content with intentionally unsorted keys for sorting tests."""
|
|
from collections import OrderedDict
|
|
|
|
self._content = OrderedDict(
|
|
[
|
|
("zulu", "last"),
|
|
("alpha", "first"),
|
|
("mike", "middle"),
|
|
("version", "should-be-stripped"),
|
|
]
|
|
)
|
|
|
|
def canonicalize_content(self) -> None:
|
|
"""Canonicalize the current content and store the result."""
|
|
self._result = self._canonicalizer.canonicalize(self._content)
|
|
|
|
def canonicalize_content_twice(self) -> None:
|
|
"""Canonicalize content twice to enable determinism assertions."""
|
|
first = self._canonicalizer.canonicalize(self._content)
|
|
second = self._canonicalizer.canonicalize(self._content)
|
|
self._result = first
|
|
self._canonical_output_2 = second
|
|
|
|
def compute_package_id_for_type(self, pkg_type: str) -> None:
|
|
"""Compute a PackageId for the current content by type name."""
|
|
pt = PackageType[pkg_type.upper()]
|
|
self._package_id_string = self._canonicalizer.compute_package_id(
|
|
self._content, pt
|
|
).id_string
|
|
|
|
def compute_package_id_twice_same_type(self, pkg_type: str) -> None:
|
|
"""Compute PackageId twice with same type to verify determinism."""
|
|
pt = PackageType[pkg_type.upper()]
|
|
pid1 = self._canonicalizer.compute_package_id(self._content, pt)
|
|
pid2 = self._canonicalizer.compute_package_id(self._content, pt)
|
|
self._package_id_string = str(pid1)
|
|
self._package_id_string_2 = str(pid2)
|
|
|
|
def compute_package_ids_for_types(self, type1: str, type2: str) -> None:
|
|
"""Compute PackageIds for two different package types."""
|
|
pt1 = PackageType[type1.upper()]
|
|
pt2 = PackageType[type2.upper()]
|
|
self._package_id_string = self._canonicalizer.compute_package_id(
|
|
self._content, pt1
|
|
).id_string
|
|
self._package_id_string_2 = self._canonicalizer.compute_package_id(
|
|
self._content, pt2
|
|
).id_string
|
|
|
|
def content_differs_by_one_field(self) -> None:
|
|
"""Save original content then modify the current content."""
|
|
self._content_original = dict(self._content)
|
|
self._content["modified"] = True
|
|
|
|
def canonicalize_both_contents(self) -> None:
|
|
"""Canonicalize both original and modified content for comparison."""
|
|
self._result = self._canonicalizer.canonicalize(self._content_original)
|
|
self._canonical_output_2 = self._canonicalizer.canonicalize(self._content)
|
|
|
|
# ── Assertions ──────────────────────────────────────────────────────
|
|
|
|
def result_is_valid_json(self) -> None:
|
|
"""Assert the canonicalization result parses as valid JSON dict."""
|
|
parsed = json.loads(self._result)
|
|
assert isinstance(parsed, dict), (
|
|
f"Expected dict from JSON, got {type(parsed).__name__}"
|
|
)
|
|
|
|
def result_has_key(self, key: str) -> None:
|
|
"""Assert the result JSON contains the given top-level key."""
|
|
parsed = json.loads(self._result)
|
|
assert key in parsed, f"Key {key!r} not found in result"
|
|
|
|
def result_does_not_have_key(self, key: str) -> None:
|
|
"""Assert the result JSON does NOT contain the given top-level key."""
|
|
parsed = json.loads(self._result)
|
|
assert key not in parsed, f"Key {key!r} should not be in result"
|
|
|
|
def result_keys_are_sorted(self) -> None:
|
|
"""Assert top-level keys in the result are lexicographically sorted."""
|
|
parsed = json.loads(self._result)
|
|
keys = list(parsed.keys())
|
|
assert keys == sorted(keys), f"Keys not sorted: {keys}"
|
|
|
|
def result_keys_are_sorted_recursively(self) -> None:
|
|
"""Assert all keys at every nesting level are lexicographically sorted."""
|
|
parsed = json.loads(self._result)
|
|
|
|
def _check_sorted(obj: Any, path: str) -> None:
|
|
if isinstance(obj, dict):
|
|
keys = list(obj.keys())
|
|
assert keys == sorted(keys), f"Keys not sorted at {path}: {keys}"
|
|
for k, v in obj.items():
|
|
_check_sorted(v, f"{path}.{k}")
|
|
elif isinstance(obj, list):
|
|
for i, item in enumerate(obj):
|
|
_check_sorted(item, f"{path}[{i}]")
|
|
|
|
_check_sorted(parsed, "root")
|
|
|
|
def canonicalization_is_deterministic(self) -> None:
|
|
"""Assert two canonicalization calls produced identical output."""
|
|
assert self._result == self._canonical_output_2, (
|
|
f"Canonicalization not deterministic:\n{self._result!r}\n!=\n{self._canonical_output_2!r}"
|
|
)
|
|
|
|
def canonicalization_produces_different_output(self) -> None:
|
|
"""Assert canonicalization of different content produces different output."""
|
|
assert self._result != self._canonical_output_2, (
|
|
"Different content should produce different canonical output"
|
|
)
|
|
|
|
def package_id_starts_with(self, prefix: str) -> None:
|
|
"""Assert the PackageId string starts with the expected type prefix."""
|
|
assert self._package_id_string.startswith(prefix), (
|
|
f"PackageId {self._package_id_string!r} should start with {prefix!r}"
|
|
)
|
|
|
|
def package_ids_are_identical(self) -> None:
|
|
"""Assert two computed PackageIds are identical."""
|
|
assert self._package_id_string == self._package_id_string_2, (
|
|
f"PackageIds should be identical: {self._package_id_string} != {self._package_id_string_2}"
|
|
)
|
|
|
|
def package_ids_are_different(self) -> None:
|
|
"""Assert two computed PackageIds differ."""
|
|
assert self._package_id_string != self._package_id_string_2, (
|
|
f"PackageIds should differ: {self._package_id_string} == {self._package_id_string_2}"
|
|
)
|
|
|
|
def package_id_is_40_hex_chars(self) -> None:
|
|
"""Assert the PackageId SHA-1 portion is 40 lowercase hex characters."""
|
|
sha1 = self._package_id_string.rsplit("_", 1)[-1]
|
|
assert len(sha1) == 40, f"SHA1 should be 40 chars: {len(sha1)}"
|
|
assert all(c in "0123456789abcdef" for c in sha1), (
|
|
f"SHA1 should be hex: {sha1!r}"
|
|
)
|
|
|
|
def result_contains_nfc_normalized_text(self, expected: str) -> None:
|
|
"""Assert the expected NFC-normalized text exists in the result and all strings are NFC."""
|
|
import unicodedata
|
|
|
|
parsed = json.loads(self._result)
|
|
|
|
def _collect_strings(obj: Any) -> list[str]:
|
|
strings: list[str] = []
|
|
if isinstance(obj, str):
|
|
strings.append(obj)
|
|
elif isinstance(obj, dict):
|
|
for v in obj.values():
|
|
strings.extend(_collect_strings(v))
|
|
elif isinstance(obj, list):
|
|
for item in obj:
|
|
strings.extend(_collect_strings(item))
|
|
return strings
|
|
|
|
all_strings = _collect_strings(parsed)
|
|
nfc_expected = unicodedata.normalize("NFC", expected)
|
|
assert nfc_expected in all_strings, (
|
|
f"Expected text {nfc_expected!r} not found in strings: {all_strings!r}"
|
|
)
|
|
for s in all_strings:
|
|
assert s == unicodedata.normalize("NFC", s), (
|
|
f"String not NFC-normalized: {s!r}"
|
|
)
|
|
|
|
def result_does_not_contain_lifecycle_field(self, field: str) -> None:
|
|
"""Assert the given lifecycle field is not present at any nesting level."""
|
|
parsed = json.loads(self._result)
|
|
|
|
def _has_field(obj: Any, target: str) -> bool:
|
|
if isinstance(obj, dict):
|
|
if target in obj:
|
|
return True
|
|
return any(_has_field(v, target) for v in obj.values())
|
|
if isinstance(obj, list):
|
|
return any(_has_field(item, target) for item in obj)
|
|
return False
|
|
|
|
assert not _has_field(parsed, field), (
|
|
f"Lifecycle field {field!r} found in canonical output"
|
|
)
|
|
|
|
def result_has_string_value(self, key: str, expected: str) -> None:
|
|
"""Assert the result contains the given key with the expected string value."""
|
|
parsed = json.loads(self._result)
|
|
|
|
def _find(obj: Any, target: str) -> Any:
|
|
if isinstance(obj, dict):
|
|
if target in obj:
|
|
return obj[target]
|
|
for v in obj.values():
|
|
found = _find(v, target)
|
|
if found is not None:
|
|
return found
|
|
return None
|
|
|
|
actual = _find(parsed, key)
|
|
assert actual == expected, f"Key {key!r}: expected {expected!r}, got {actual!r}"
|