df8bc06f58
Implemented command to display all indexed entries with tier, size, and last-accessed metadata. Implemented command to index files/directories with optional --tag and --policy flags. - Added features/acms_context_list_add_cli.feature with 27 scenarios - Added test step definitions using Typer CliRunner for real CLI invocation - Added context.py implementation with --tag, --policy, --format flags - Updated CHANGELOG.md entry under [Unreleased] > Added - Removed out-of-scope A2A test files that belonged to a different Epic ISSUES CLOSED: #9585
462 lines
16 KiB
Python
462 lines
16 KiB
Python
"""Step definitions for diff review artifact BDD tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.domain.models.core.change import (
|
|
ChangeEntry,
|
|
ChangeOperation,
|
|
SpecChangeSet,
|
|
)
|
|
from cleveragents.domain.models.core.diff_review import (
|
|
DiffBuilder,
|
|
DiffSerializer,
|
|
ReviewArtifact,
|
|
)
|
|
|
|
__all__: list[str] = []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_entry(
|
|
operation: str,
|
|
path: str,
|
|
resource_id: str,
|
|
plan_id: str,
|
|
*,
|
|
before_hash: str | None = None,
|
|
after_hash: str | None = None,
|
|
after_mode: int | None = None,
|
|
) -> ChangeEntry:
|
|
"""Build a ChangeEntry with sensible defaults."""
|
|
kwargs: dict[str, Any] = {
|
|
"plan_id": plan_id,
|
|
"resource_id": resource_id,
|
|
"tool_name": "builtin/test-tool",
|
|
"operation": ChangeOperation(operation),
|
|
"path": path,
|
|
}
|
|
if before_hash is not None:
|
|
kwargs["before_hash"] = before_hash
|
|
if after_hash is not None:
|
|
kwargs["after_hash"] = after_hash
|
|
if after_mode is not None:
|
|
kwargs["after_mode"] = after_mode
|
|
return ChangeEntry(**kwargs)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a plan ID "{plan_id}"')
|
|
def step_given_plan_id(context: Context, plan_id: str) -> None:
|
|
context.plan_id = plan_id
|
|
context.before_contents = {} # dict[str, str]
|
|
context.after_contents = {} # dict[str, str]
|
|
context.resource_names = {} # dict[str, str]
|
|
|
|
|
|
@given("an empty changeset for the plan")
|
|
def step_given_empty_changeset(context: Context) -> None:
|
|
context.changeset = SpecChangeSet(plan_id=context.plan_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Change entry creation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a change entry with operation "{op}" for path "{path}" in resource "{res}"')
|
|
def step_add_change_entry(context: Context, op: str, path: str, res: str) -> None:
|
|
entry = _make_entry(op, path, res, context.plan_id)
|
|
context.changeset.add_change(entry)
|
|
|
|
|
|
@given(
|
|
'a delete entry for path "{path}" in resource "{res}" with before_hash "{bhash}"'
|
|
)
|
|
def step_add_delete_with_hash(
|
|
context: Context, path: str, res: str, bhash: str
|
|
) -> None:
|
|
entry = _make_entry("delete", path, res, context.plan_id, before_hash=bhash)
|
|
context.changeset.add_change(entry)
|
|
|
|
|
|
@given('a create entry for path "{path}" in resource "{res}" with after_hash "{ahash}"')
|
|
def step_add_create_with_hash(
|
|
context: Context, path: str, res: str, ahash: str
|
|
) -> None:
|
|
entry = _make_entry("create", path, res, context.plan_id, after_hash=ahash)
|
|
context.changeset.add_change(entry)
|
|
|
|
|
|
@given(
|
|
'a hashed change entry "{op}" path "{path}" '
|
|
'resource "{res}" before_hash "{bhash}" after_hash "{ahash}"'
|
|
)
|
|
def step_add_entry_with_hashes(
|
|
context: Context,
|
|
op: str,
|
|
path: str,
|
|
res: str,
|
|
bhash: str,
|
|
ahash: str,
|
|
) -> None:
|
|
entry = _make_entry(
|
|
op, path, res, context.plan_id, before_hash=bhash, after_hash=ahash
|
|
)
|
|
context.changeset.add_change(entry)
|
|
|
|
|
|
@given('a moded change entry "{op}" path "{path}" resource "{res}" after_mode {mode}')
|
|
def step_add_entry_with_mode(
|
|
context: Context, op: str, path: str, res: str, mode: str
|
|
) -> None:
|
|
# mode comes as e.g. "0o755" — parse it
|
|
mode_int = int(mode, 0)
|
|
entry = _make_entry(op, path, res, context.plan_id, after_mode=mode_int)
|
|
context.changeset.add_change(entry)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Content setup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('before content for "{path}" is "{content}"')
|
|
def step_before_content(context: Context, path: str, content: str) -> None:
|
|
# Interpret escape sequences
|
|
context.before_contents[path] = content.replace("\\n", "\n").replace(
|
|
"\\x00", "\x00"
|
|
)
|
|
|
|
|
|
@given('after content for "{path}" is "{content}"')
|
|
def step_after_content(context: Context, path: str, content: str) -> None:
|
|
context.after_contents[path] = content.replace("\\n", "\n").replace("\\x00", "\x00")
|
|
|
|
|
|
@given('after content for "{path}" is a string of {count} characters')
|
|
def step_after_content_large(context: Context, path: str, count: str) -> None:
|
|
context.after_contents[path] = "x" * int(count)
|
|
|
|
|
|
@given('resource name "{res_id}" is "{name}"')
|
|
def step_resource_name(context: Context, res_id: str, name: str) -> None:
|
|
context.resource_names[res_id] = name
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Build actions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I build a review artifact from the changeset")
|
|
def step_build_artifact(context: Context) -> None:
|
|
builder = DiffBuilder()
|
|
context.artifact = builder.build(
|
|
context.changeset,
|
|
before_contents=context.before_contents,
|
|
after_contents=context.after_contents,
|
|
resource_names=context.resource_names,
|
|
)
|
|
|
|
|
|
@when("I build a review artifact with max diff size {size}")
|
|
def step_build_artifact_limited(context: Context, size: str) -> None:
|
|
builder = DiffBuilder(max_diff_size=int(size))
|
|
context.artifact = builder.build(
|
|
context.changeset,
|
|
before_contents=context.before_contents,
|
|
after_contents=context.after_contents,
|
|
resource_names=context.resource_names,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Serialization actions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I serialize the artifact to plain text")
|
|
def step_serialize_plain(context: Context) -> None:
|
|
context.plain_text = DiffSerializer.to_plain(context.artifact)
|
|
|
|
|
|
@when("I serialize the artifact to JSON")
|
|
def step_serialize_json(context: Context) -> None:
|
|
context.json_text = DiffSerializer.to_json(context.artifact)
|
|
|
|
|
|
@when("I serialize the artifact to rich format")
|
|
def step_serialize_rich(context: Context) -> None:
|
|
context.rich_text = DiffSerializer.to_rich(context.artifact)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Review artifact assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the review artifact should have {n} total changes")
|
|
def step_assert_total_changes(context: Context, n: str) -> None:
|
|
artifact: ReviewArtifact = context.artifact
|
|
assert artifact.total_changes == int(n), (
|
|
f"Expected {n} total changes, got {artifact.total_changes}"
|
|
)
|
|
|
|
|
|
@then("the review artifact should have {n} groups")
|
|
def step_assert_groups(context: Context, n: str) -> None:
|
|
artifact: ReviewArtifact = context.artifact
|
|
assert len(artifact.groups) == int(n), (
|
|
f"Expected {n} groups, got {len(artifact.groups)}"
|
|
)
|
|
|
|
|
|
@then("the review artifact should not be truncated")
|
|
def step_assert_not_truncated(context: Context) -> None:
|
|
assert not context.artifact.truncated, "Artifact should not be truncated"
|
|
|
|
|
|
@then("the review artifact should be truncated")
|
|
def step_assert_truncated(context: Context) -> None:
|
|
assert context.artifact.truncated, "Artifact should be truncated"
|
|
|
|
|
|
@then("the review artifact plan ID should match the changeset plan ID")
|
|
def step_plan_id_matches(context: Context) -> None:
|
|
assert context.artifact.plan_id == context.changeset.plan_id
|
|
|
|
|
|
@then("the review artifact changeset ID should match the changeset ID")
|
|
def step_changeset_id_matches(context: Context) -> None:
|
|
assert context.artifact.changeset_id == context.changeset.changeset_id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Group assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("group {g} should have {n} entries")
|
|
def step_group_entries(context: Context, g: str, n: str) -> None:
|
|
group = context.artifact.groups[int(g)]
|
|
assert len(group.entries) == int(n), (
|
|
f"Expected {n} entries in group {g}, got {len(group.entries)}"
|
|
)
|
|
|
|
|
|
@then('group {g} should have resource id "{rid}"')
|
|
def step_group_resource_id(context: Context, g: str, rid: str) -> None:
|
|
group = context.artifact.groups[int(g)]
|
|
assert group.resource_id == rid, (
|
|
f"Expected resource id '{rid}', got '{group.resource_id}'"
|
|
)
|
|
|
|
|
|
@then("group {g} should have {n} added")
|
|
def step_group_added(context: Context, g: str, n: str) -> None:
|
|
group = context.artifact.groups[int(g)]
|
|
assert group.added == int(n), f"Expected {n} added, got {group.added}"
|
|
|
|
|
|
@then("group {g} should have {n} modified")
|
|
def step_group_modified(context: Context, g: str, n: str) -> None:
|
|
group = context.artifact.groups[int(g)]
|
|
assert group.modified == int(n), f"Expected {n} modified, got {group.modified}"
|
|
|
|
|
|
@then("group {g} should have {n} deleted")
|
|
def step_group_deleted(context: Context, g: str, n: str) -> None:
|
|
group = context.artifact.groups[int(g)]
|
|
assert group.deleted == int(n), f"Expected {n} deleted, got {group.deleted}"
|
|
|
|
|
|
@then("group {g} should have {n} renamed")
|
|
def step_group_renamed(context: Context, g: str, n: str) -> None:
|
|
group = context.artifact.groups[int(g)]
|
|
assert group.renamed == int(n), f"Expected {n} renamed, got {group.renamed}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entry assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('entry {e} in group {g} should have change type "{ct}"')
|
|
def step_entry_change_type(context: Context, e: str, g: str, ct: str) -> None:
|
|
entry = context.artifact.groups[int(g)].entries[int(e)]
|
|
assert entry.change_type == ct, (
|
|
f"Expected change_type '{ct}', got '{entry.change_type}'"
|
|
)
|
|
|
|
|
|
@then('entry {e} in group {g} should have path "{path}"')
|
|
def step_entry_path(context: Context, e: str, g: str, path: str) -> None:
|
|
entry = context.artifact.groups[int(g)].entries[int(e)]
|
|
assert entry.path == path, f"Expected path '{path}', got '{entry.path}'"
|
|
|
|
|
|
@then("entry {e} in group {g} should be binary")
|
|
def step_entry_binary(context: Context, e: str, g: str) -> None:
|
|
entry = context.artifact.groups[int(g)].entries[int(e)]
|
|
assert entry.is_binary, "Entry should be binary"
|
|
|
|
|
|
@then("entry {e} in group {g} should be a rename")
|
|
def step_entry_is_rename(context: Context, e: str, g: str) -> None:
|
|
entry = context.artifact.groups[int(g)].entries[int(e)]
|
|
assert entry.is_rename, "Entry should be a rename"
|
|
|
|
|
|
@then("entry {e} in group {g} should not be a rename")
|
|
def step_entry_not_rename(context: Context, e: str, g: str) -> None:
|
|
entry = context.artifact.groups[int(g)].entries[int(e)]
|
|
assert not entry.is_rename, "Entry should not be a rename"
|
|
|
|
|
|
@then('entry {e} in group {g} rename should be from "{src}" to "{dst}"')
|
|
def step_entry_rename_paths(
|
|
context: Context, e: str, g: str, src: str, dst: str
|
|
) -> None:
|
|
entry = context.artifact.groups[int(g)].entries[int(e)]
|
|
assert entry.rename_from == src, (
|
|
f"Expected rename_from '{src}', got '{entry.rename_from}'"
|
|
)
|
|
assert entry.rename_to == dst, (
|
|
f"Expected rename_to '{dst}', got '{entry.rename_to}'"
|
|
)
|
|
|
|
|
|
@then("entry {e} in group {g} should be truncated")
|
|
def step_entry_truncated(context: Context, e: str, g: str) -> None:
|
|
entry = context.artifact.groups[int(g)].entries[int(e)]
|
|
assert entry.truncated, "Entry should be truncated"
|
|
|
|
|
|
@then('entry {e} in group {g} should have before hash "{h}"')
|
|
def step_entry_before_hash(context: Context, e: str, g: str, h: str) -> None:
|
|
entry = context.artifact.groups[int(g)].entries[int(e)]
|
|
assert entry.before_hash == h, (
|
|
f"Expected before_hash '{h}', got '{entry.before_hash}'"
|
|
)
|
|
|
|
|
|
@then('entry {e} in group {g} should have after hash "{h}"')
|
|
def step_entry_after_hash(context: Context, e: str, g: str, h: str) -> None:
|
|
entry = context.artifact.groups[int(g)].entries[int(e)]
|
|
assert entry.after_hash == h, f"Expected after_hash '{h}', got '{entry.after_hash}'"
|
|
|
|
|
|
@then("entry {e} in group {g} should have file mode {mode}")
|
|
def step_entry_file_mode(context: Context, e: str, g: str, mode: str) -> None:
|
|
entry = context.artifact.groups[int(g)].entries[int(e)]
|
|
assert entry.file_mode == int(mode), (
|
|
f"Expected file_mode {mode}, got {entry.file_mode}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Diff text assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the diff text in entry {e} of group {g} should contain "{text}"')
|
|
def step_diff_contains(context: Context, e: str, g: str, text: str) -> None:
|
|
entry = context.artifact.groups[int(g)].entries[int(e)]
|
|
assert text in entry.diff_text, (
|
|
f"Expected diff_text to contain '{text}', got: {entry.diff_text!r}"
|
|
)
|
|
|
|
|
|
@then("the diff text in entry {e} of group {g} should be empty")
|
|
def step_diff_empty(context: Context, e: str, g: str) -> None:
|
|
entry = context.artifact.groups[int(g)].entries[int(e)]
|
|
assert entry.diff_text == "", f"Expected empty diff_text, got: {entry.diff_text!r}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Serialization assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the plain text should contain "{text}"')
|
|
def step_plain_contains(context: Context, text: str) -> None:
|
|
assert text in context.plain_text, f"Expected plain text to contain '{text}'"
|
|
|
|
|
|
@then("the JSON output should be valid JSON")
|
|
def step_json_valid(context: Context) -> None:
|
|
try:
|
|
json.loads(context.json_text)
|
|
except json.JSONDecodeError as exc:
|
|
raise AssertionError(f"Invalid JSON: {exc}") from exc
|
|
|
|
|
|
@then('the JSON output should contain key "{key}"')
|
|
def step_json_has_key(context: Context, key: str) -> None:
|
|
# Support both context.json_text (diff review tests) and
|
|
# context.json_output (ACMS context CLI tests)
|
|
if hasattr(context, "json_output"):
|
|
data = context.json_output
|
|
else:
|
|
data = json.loads(context.json_text)
|
|
assert key in data, f"Expected key '{key}' in JSON output"
|
|
|
|
|
|
@then('the rich text should contain "{text}"')
|
|
def step_rich_contains(context: Context, text: str) -> None:
|
|
assert text in context.rich_text, f"Expected rich text to contain '{text}'"
|
|
|
|
|
|
@then('the JSON output should have entry with before hash "{h}"')
|
|
def step_json_entry_before_hash(context: Context, h: str) -> None:
|
|
data = json.loads(context.json_text)
|
|
for group in data["groups"]:
|
|
for entry in group["entries"]:
|
|
if entry.get("before_hash") == h:
|
|
return
|
|
raise AssertionError(f"No entry with before_hash '{h}' in JSON output")
|
|
|
|
|
|
@then('the JSON output should have entry with after hash "{h}"')
|
|
def step_json_entry_after_hash(context: Context, h: str) -> None:
|
|
data = json.loads(context.json_text)
|
|
for group in data["groups"]:
|
|
for entry in group["entries"]:
|
|
if entry.get("after_hash") == h:
|
|
return
|
|
raise AssertionError(f"No entry with after_hash '{h}' in JSON output")
|
|
|
|
|
|
@then("the JSON output should have entry with file mode")
|
|
def step_json_entry_file_mode(context: Context) -> None:
|
|
data = json.loads(context.json_text)
|
|
for group in data["groups"]:
|
|
for entry in group["entries"]:
|
|
if "file_mode" in entry:
|
|
return
|
|
raise AssertionError("No entry with file_mode in JSON output")
|
|
|
|
|
|
@then('the JSON output should have entry with rename from "{path}"')
|
|
def step_json_entry_rename_from(context: Context, path: str) -> None:
|
|
data = json.loads(context.json_text)
|
|
for group in data["groups"]:
|
|
for entry in group["entries"]:
|
|
if entry.get("rename_from") == path:
|
|
return
|
|
raise AssertionError(f"No entry with rename_from '{path}' in JSON output")
|