"""Step definitions for PlanApplyService rendering coverage tests.""" from __future__ import annotations import datetime from typing import TYPE_CHECKING, Any from behave import given, then, when from behave.runner import Context if TYPE_CHECKING: pass # --------------------------------------------------------------------------- # Helpers — build plain data dicts for the render functions # --------------------------------------------------------------------------- def _make_diff_data( mode: str = "revert", original_decision_id: str = "dec-0", new_decision_id: str | None = "dec-1", guidance: str = "", completed_at: bool = False, patched_hint: str | None = None, artifacts_path: str | None = None, ) -> dict[str, Any]: """Build a minimal correction-diff data dict for render function tests.""" now_iso = datetime.datetime.now(datetime.UTC).isoformat() summary: dict[str, Any] = { "correction": "corr-test-001", "original_decision": original_decision_id, "mode": mode, "state": "complete" if completed_at else "executing", "guidance": guidance or "", "created_at": now_iso, "completed_at": now_iso if completed_at else None, } reverted_decisions: list[dict[str, Any]] = [] added_decisions: list[dict[str, Any]] = [] if original_decision_id: reverted_decisions.append( {"decision_id": original_decision_id, "status": "reverted"} ) if new_decision_id: added_decisions.append({"decision_id": new_decision_id, "status": "added"}) comparison = { "reverted_decisions": reverted_decisions, "added_decisions": added_decisions, "decisions_changed": len(reverted_decisions) + len(added_decisions), "decisions_added": len(added_decisions), "decisions_reverted": len(reverted_decisions), } if patched_hint is None and artifacts_path is None: patch_preview = [ { "note": ( "Patch preview is available after correction execution completes. " "Current state: executing" ), } ] elif artifacts_path: patch_preview = [ { "note": "Corrected execution artifacts available", "artifacts_path": artifacts_path, } ] else: patch_preview = [{"note": patched_hint}] return { "correction_diff": summary, "comparison": comparison, "patch_preview": patch_preview, } def _import_render_functions() -> tuple: """Lazy-import the render functions.""" from cleveragents.application.services import plan_apply_service as mod return ( mod._render_correction_diff_plain, mod._render_correction_diff_rich, mod._build_correction_diff_dict, ) # --------------------------------------------------------------------------- # Given steps # --------------------------------------------------------------------------- @given( 'I have a CorrectionAttemptRecord with mode="{mode}", ' 'original_decision_id="{orig}", new_decision_id="{new}", ' 'guidance="{guidance}", and archived_artifacts_path="{artifacts}"' ) def step_corr_record_with_artifact( context: Context, mode: str, orig: str, new: str, guidance: str, artifacts: str, ) -> None: """Construct a minimal CorrectionAttemptRecord for _build_correction_diff_dict.""" from cleveragents.domain.models.core.correction import ( CorrectionAttemptRecord, CorrectionAttemptState, CorrectionMode, ) record = CorrectionAttemptRecord( correction_attempt_id="test-corr-001", plan_id="plan-001", original_decision_id=orig, new_decision_id=new if new != "none" else None, mode=CorrectionMode.REVERT if mode == "revert" else CorrectionMode.APPEND, guidance=guidance or "", archived_artifacts_path=artifacts if artifacts not in ("", "none") else None, state=CorrectionAttemptState.COMPLETE, ) context.corr_record: CORRECTIONATTEMPTRECORD = record # noqa: F821 @given( 'I have a CorrectionAttemptRecord with mode="{mode}", ' 'original_decision_id="{orig}", no new_decision, ' 'guidance="{guidance}", and no archived artifacts' ) def step_corr_record_no_artifact( context: Context, mode: str, orig: str, guidance: str ) -> None: """Construct a CorrectionAttemptRecord without new_decision or archived artifacts.""" from cleveragents.domain.models.core.correction import ( CorrectionAttemptRecord, CorrectionAttemptState, CorrectionMode, ) record = CorrectionAttemptRecord( correction_attempt_id="test-corr-002", plan_id="plan-002", original_decision_id=orig, new_decision_id=None, mode=CorrectionMode.REVERT if mode == "revert" else CorrectionMode.APPEND, guidance=guidance or "", archived_artifacts_path=None, state=CorrectionAttemptState.PENDING, ) context.corr_record: CORRECTIONATTEMPTRECORD = record # noqa: F821 @given( 'I have a correction diff dict with mode="{mode}", ' 'original_decision_id="{orig}", guidance="{guidance}", ' 'completed_at present, and one added decision "{added}"' ) def step_diff_dict_with_artifacts( context: Context, mode: str, orig: str, guidance: str, added: str ) -> None: """Build a plain data dict for render function tests.""" # Use the added value as new_decision_id if provided nid = added if added != "none" and added else None data = _make_diff_data( mode=mode, original_decision_id=orig, new_decision_id=nid, guidance=guidance, completed_at=True, ) context.diff_data = data @given( 'I have a correction diff dict with mode="{mode}", ' 'original_decision_id="{orig}", no guidance text, ' 'and patch preview note about "{hint}"' ) def step_diff_dict_no_guidance( context: Context, mode: str, orig: str, hint: str ) -> None: """Build a plain data dict with missing fields.""" data = _make_diff_data( mode=mode, original_decision_id=orig, new_decision_id=None, guidance="", completed_at=False, patched_hint=f"Patch preview is available after correction execution completes. " f"Current state: {hint}", ) context.diff_data = data # --------------------------------------------------------------------------- # When steps # --------------------------------------------------------------------------- @when("I call _build_correction_diff_dict on the record") def step_build_corr_dict(context: Context) -> None: """Call the production _build_correction_diff_dict function & save dict output.""" _, _, build_func = _import_render_functions() record = context.corr_record # type: ignore[attr-defined] data = build_func(record) context.diff_data = data @when("I call _render_correction_diff_plain on the dict") def step_render_plain(context: Context) -> None: """Call plain-text renderer and save output.""" render_plain, _, _ = _import_render_functions() context.render_output = render_plain(context.diff_data) # type: ignore[attr-defined] @when("I call _render_correction_diff_rich on the dict") def step_render_rich(context: Context) -> None: """Call rich-text renderer and save output.""" _, render_rich, _ = _import_render_functions() context.render_output = render_rich(context.diff_data) # type: ignore[attr-defined] # --------------------------------------------------------------------------- # Then steps — verify structure & content # --------------------------------------------------------------------------- @then('the result should contain "{key1}", "{key2}", and "{key3}" keys') def step_result_contains_keys( context: Context, key1: str, key2: str, key3: str ) -> None: """Verify the data dict has the expected top-level keys.""" for k in (key1, key2, key3): assert k in context.diff_data, f"Missing key '{k}' in result" @then( "the comparison should have {n_reverted:d} reverted decision and " "{n_added:d} added decisions" ) def step_comparison_count(context: Context, n_reverted: int, n_added: int) -> None: """Verify the number of reverted/added decisions in comparison.""" cmp = context.diff_data["comparison"] actual_reverted = len(cmp["reverted_decisions"]) actual_added = len(cmp["added_decisions"]) assert actual_reverted == n_reverted, ( f"Expected {n_reverted} reverted, got {actual_reverted}" ) assert actual_added == n_added, f"Expected {n_added} added, got {actual_added}" @then('the patch preview should include the artifacts path "{path}"') def step_patch_has_artifacts(context: Context, path: str) -> None: """Verify patch-preview contains the specified artifacts path.""" patch = context.diff_data["patch_preview"][0] assert patch.get("artifacts_path") == path, ( f"Expected artifacts_path='{path}', got {patch}" ) @then("the patch preview should indicate correction execution is pending") def step_patch_pending(context: Context) -> None: """Verify that the patch-preview note signals a pending/complete state.""" patch = context.diff_data["patch_preview"][0] note_lower = patch.get("note", "").lower() assert "pending" in note_lower or "complete" in note_lower, ( f"Expected pending/complete hint in: {patch['note']}" ) @then('the output should contain "{text}" heading') def step_output_contains_heading(context: Context, text: str) -> None: """Verify the rendered output contains a given heading string.""" assert text in context.render_output, ( # type: ignore[attr-defined] f"Expected '{text}' in render output:\n{context.render_output}" ) @then('the output should contain "{value}" mode value') def step_output_contains_mode(context: Context, value: str) -> None: """Verify the rendered output contains a given mode string.""" assert value in context.render_output, ( # type: ignore[attr-defined] f"Expected '{value}' in render output:\n{context.render_output}" ) @then('the output should contain "{text}" trailer') def step_output_contains_trailer(context: Context, text: str) -> None: """Verify the rendered output contains an end marker string.""" assert text in context.render_output, ( # type: ignore[attr-defined] f"Expected '{text}' in render output:\n{context.render_output}" ) @then('the output should contain the added decision line starting with "{marker} {id}"') def step_output_contains_added_decision(context: Context, marker: str, id: str) -> None: """Verify rendered output contains an added-decision line.""" for line in context.render_output.splitlines(): # type: ignore[attr-defined] stripped = line.strip() if f"+ {id}" in stripped or f"{marker} {id}" in stripped: break else: raise AssertionError( f"Expected added decision '{marker} {id}' in output:\n{context.render_output}" ) @then('the output should NOT contain "{text}" line') def step_output_not_contains(context: Context, text: str) -> None: """Verify the rendered output does NOT contain a given text string.""" for line in context.render_output.splitlines(): # type: ignore[attr-defined] assert text not in line.rstrip(), f"Unexpected '{text}' found in line: {line}" @then("the output should contain the pending-execution note text") def step_output_contains_pending_note(context: Context) -> None: """Verify render output includes the patch-preview hint.""" assert "Patch preview is available after" in context.render_output, ( # type: ignore[attr-defined] f"Expected pending-note: {context.render_output}" ) @then("the output should contain a red-colored reverted decision ID") def step_output_contains_red_revert(context: Context) -> None: """Verify rich-rendered output has the [red] markup for reverted decisions.""" assert "[red]" in context.render_output, ( # type: ignore[attr-defined] f"Expected '[red]' markup: {context.render_output}" ) @then("the output should contain a green-colored added decision ID") def step_output_contains_green_added(context: Context) -> None: """Verify rich-rendered output has the [green] markup for added decisions.""" assert "[green]" in context.render_output, ( # type: ignore[attr-defined] f"Expected '[green]' markup: {context.render_output}" ) @then('the output should contain "{marker} OK {marker}" checkmark marker at end') def step_output_contains_marker(context: Context, marker: str) -> None: """Verify the rich-rendered output includes the [green]✔ OK footer.""" assert "OK" in context.render_output, ( # type: ignore[attr-defined] f"Expected 'OK' footer: {context.render_output}" ) @then("the output should contain the pending-execution note in dim markup") def step_pending_in_dim(context: Context) -> None: """Verify rendered text includes '[dim]' color wrapper.""" assert "[dim]" in context.render_output, ( # type: ignore[attr-defined] f"Expected '[dim]' markup: {context.render_output}" ) # --------------------------------------------------------------------------- # Comparison-count shortcut — avoids ambiguity with the generic matcher below. # --------------------------------------------------------------------------- @then('the comparison counts are "{n_rev}" reverted decisions and "{n_add}" new ones') def step_comparison_counts_shorthand(context: Context, n_rev: str, n_add: str) -> None: """Verify the comparison dict has the expected revert/add counts. This step uses literal string values from the Gherkin to disambiguate from the parameterised "should have {n:d} ..." matcher above. """ cmp = context.diff_data["comparison"] actual_rev = len(cmp["reverted_decisions"]) actual_add = len(cmp["added_decisions"]) assert str(actual_rev) == n_rev, f"Expected {n_rev} reverted, got {actual_rev}" assert str(actual_add) == n_add, f"Expected {n_add} added, got {actual_add}" # --------------------------------------------------------------------------- # Singular / variant steps that the feature file uses # --------------------------------------------------------------------------- @then( "the comparison should have {n_reverted:d} reverted decision" " and {n_added:d} added decision" ) def step_comparison_count_singular( context: Context, n_reverted: int, n_added: int ) -> None: """Singular variant of the comparison count step (feature uses singular).""" cmp = context.diff_data["comparison"] actual_reverted = len(cmp["reverted_decisions"]) actual_added = len(cmp["added_decisions"]) assert actual_reverted == n_reverted, ( f"Expected {n_reverted} reverted, got {actual_reverted}" ) assert actual_added == n_added, f"Expected {n_added} added, got {actual_added}" @then("the patch preview should include the artifacts path") def step_patch_has_any_artifacts(context: Context) -> None: """Verify patch-preview contains an artifacts_path (any value).""" patch = context.diff_data["patch_preview"][0] assert patch.get("artifacts_path"), ( f"Expected artifacts_path to be set, got: {patch}" ) @then('the output should contain the mode value "{value}"') def step_output_contains_mode_value(context: Context, value: str) -> None: """Verify rendered output contains the given mode value string.""" assert value in context.render_output, ( # type: ignore[attr-defined] f"Expected mode value '{value}' in render output:\n{context.render_output}" ) @then('the output should contain "{text}" timestamp') def step_output_contains_timestamp(context: Context, text: str) -> None: """Verify rendered output contains a given timestamp label string.""" assert text in context.render_output, ( # type: ignore[attr-defined] f"Expected '{text}' in render output:\n{context.render_output}" ) @then('the output should NOT contain a "{text}" line') def step_output_not_contains_a_line(context: Context, text: str) -> None: """Verify rendered output does NOT contain a given text (with 'a' article).""" for line in context.render_output.splitlines(): # type: ignore[attr-defined] assert text not in line.rstrip(), f"Unexpected '{text}' found in line: {line}" @then('the output should contain "{text}" mode color markup') def step_output_contains_mode_markup(context: Context, text: str) -> None: """Verify rendered output contains the given rich-markup mode string.""" assert text in context.render_output, ( # type: ignore[attr-defined] f"Expected mode markup '{text}' in render output:\n{context.render_output}" ) @then("the output should contain the rich green checkmark marker at end") def step_output_contains_checkmark(context: Context) -> None: """Verify rich-rendered output includes the green checkmark footer.""" output = context.render_output # type: ignore[attr-defined] assert "[green]" in output or "✔" in output or "OK" in output, ( f"Expected green checkmark / OK footer: {output}" ) @given( 'I have a correction diff dict with mode="{mode}", ' 'original_decision_id="{orig}", no guidance text, ' "and patch preview note about pending execution" ) def step_diff_dict_no_guidance_pending(context: Context, mode: str, orig: str) -> None: """Build a plain data dict with no guidance and a pending-execution hint.""" data = _make_diff_data( mode=mode, original_decision_id=orig, new_decision_id=None, guidance="", completed_at=False, patched_hint=( "Patch preview is available after correction execution completes. " "Current state: executing" ), ) context.diff_data = data @given( 'I have a correction diff dict with mode="{mode}", ' 'original_decision_id="{orig}", no guidance, ' "and patch preview note about pending execution" ) def step_diff_dict_no_guidance_short_pending( context: Context, mode: str, orig: str ) -> None: """Same as the 'no guidance text' variant - 'no guidance' short form.""" data = _make_diff_data( mode=mode, original_decision_id=orig, new_decision_id=None, guidance="", completed_at=False, patched_hint=( "Patch preview is available after correction execution completes. " "Current state: executing" ), ) context.diff_data = data