"""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")