Files
temp/features/steps/correction_service_coverage_steps.py
freemo 55aee7cf22 fix(test): commit after each add_skill to prevent session GC rollback, and improved coverage.
The step_register_skills_table step called add_skill in a loop but only
committed once at the end. Because SkillRepository.create() obtains a
new session per call and only flushes (never commits), the intermediate
sessions could be garbage-collected before the final commit, rolling
back their transactions on the shared SQLite :memory: connection. Moving
_commit_pending inside the loop ensures each skill is durably committed
before the next session is created.

ISSUES CLOSED: #418
2026-02-24 12:19:04 -05:00

770 lines
25 KiB
Python

"""Step definitions for correction_service_coverage.feature.
Provides full line-and-branch coverage for
``cleveragents.application.services.correction_service.CorrectionService``.
All step-text uses a ``cov-`` prefix to avoid collisions with existing
correction_flows_steps.py and correction_service_new_coverage_steps.py.
"""
from __future__ import annotations
from behave import given, then, when
from cleveragents.application.services.correction_service import CorrectionService
from cleveragents.core.exceptions import ResourceNotFoundError, ValidationError
from cleveragents.domain.models.core.correction import (
CorrectionMode,
CorrectionStatus,
)
# -------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------
def _parse_adj(spec: str) -> dict[str, list[str]]:
"""Parse ``"A->B,C;B->D"`` into an adjacency dict."""
tree: dict[str, list[str]] = {}
for seg in spec.split(";"):
seg = seg.strip()
if not seg:
continue
parent, children_str = seg.split("->")
tree[parent.strip()] = [c.strip() for c in children_str.split(",")]
return tree
def _build_chain_cov(root: str, count: int) -> dict[str, list[str]]:
"""Build a linear chain of *count* total nodes starting at *root*."""
tree: dict[str, list[str]] = {}
current = root
for i in range(1, count):
child = f"{root}_ch{i}"
tree[current] = [child]
current = child
return tree
# -------------------------------------------------------------------
# Background / Given
# -------------------------------------------------------------------
@given("a coverage test correction service")
def step_bg_service(context):
context.cov_svc = CorrectionService()
context.cov_cid = None
context.cov_req = None
context.cov_impact = None
context.cov_revert_result = None
context.cov_append_result = None
context.cov_dispatch_result = None
context.cov_dry_run = None
context.cov_retrieved = None
context.cov_list = None
context.cov_attempts = None
context.cov_subtree = None
context.cov_error = None
@given('a cov-stored revert correction for plan "{plan}" targeting "{target}"')
def step_stored_revert_cov(context, plan, target):
req = context.cov_svc.request_correction(
plan_id=plan,
target_decision_id=target,
mode=CorrectionMode.REVERT,
)
context.cov_cid = req.correction_id
context.cov_req = req
@given('a cov-stored append correction for plan "{plan}" targeting "{target}"')
def step_stored_append_cov(context, plan, target):
req = context.cov_svc.request_correction(
plan_id=plan,
target_decision_id=target,
mode=CorrectionMode.APPEND,
)
context.cov_cid = req.correction_id
context.cov_req = req
@given("the cov-stored correction has been executed via revert with empty tree")
def step_pre_execute_revert(context):
context.cov_svc.execute_revert(context.cov_cid, {})
@given("the cov-stored correction has been executed via append")
def step_pre_execute_append(context):
context.cov_svc.execute_append(context.cov_cid)
@given("the cov-stored correction has been analyzed with empty tree")
def step_pre_analyze(context):
context.cov_svc.analyze_impact(context.cov_cid, {})
@given('the cov-stored correction status is forced to "{status}"')
def step_force_status(context, status):
req = context.cov_svc.get_correction(context.cov_cid)
req.status = CorrectionStatus(status)
# -------------------------------------------------------------------
# When steps - request_correction
# -------------------------------------------------------------------
@when(
'I cov-request a correction for plan "{plan}" targeting "{target}" in revert mode'
)
def step_request_revert(context, plan, target):
context.cov_req = context.cov_svc.request_correction(
plan_id=plan,
target_decision_id=target,
mode=CorrectionMode.REVERT,
)
context.cov_cid = context.cov_req.correction_id
@when(
'I cov-request a correction for plan "{plan}" targeting "{target}"'
' in append mode with guidance "{guidance}" and dry_run'
)
def step_request_append_guidance_dryrun(context, plan, target, guidance):
context.cov_req = context.cov_svc.request_correction(
plan_id=plan,
target_decision_id=target,
mode=CorrectionMode.APPEND,
guidance=guidance,
dry_run=True,
)
context.cov_cid = context.cov_req.correction_id
@when(
'I cov-attempt to create a correction with plan_id "{plan}" and target "{target}"'
)
def step_attempt_create(context, plan, target):
try:
context.cov_svc.request_correction(
plan_id=plan,
target_decision_id=target,
mode=CorrectionMode.REVERT,
)
context.cov_error = None
except (ValidationError, Exception) as exc:
context.cov_error = exc
@when('I cov-attempt to create a correction with empty plan_id and target "{target}"')
def step_attempt_create_empty_plan(context, target):
try:
context.cov_svc.request_correction(
plan_id="",
target_decision_id=target,
mode=CorrectionMode.REVERT,
)
context.cov_error = None
except (ValidationError, Exception) as exc:
context.cov_error = exc
@when('I cov-attempt to create a correction with plan_id "{plan}" and empty target')
def step_attempt_create_empty_target(context, plan):
try:
context.cov_svc.request_correction(
plan_id=plan,
target_decision_id="",
mode=CorrectionMode.REVERT,
)
context.cov_error = None
except (ValidationError, Exception) as exc:
context.cov_error = exc
# -------------------------------------------------------------------
# When steps - analyze_impact
# -------------------------------------------------------------------
@when("I cov-analyze impact with an empty tree")
def step_analyze_empty(context):
context.cov_impact = context.cov_svc.analyze_impact(context.cov_cid, {})
@when('I cov-analyze impact with adjacency "{spec}"')
def step_analyze_adj(context, spec):
tree = _parse_adj(spec)
context.cov_impact = context.cov_svc.analyze_impact(context.cov_cid, tree)
@when('I cov-attempt to analyze impact for correction id "{cid}"')
def step_attempt_analyze(context, cid):
try:
context.cov_svc.analyze_impact(cid, {})
context.cov_error = None
except ResourceNotFoundError as exc:
context.cov_error = exc
# -------------------------------------------------------------------
# When steps - generate_dry_run_report
# -------------------------------------------------------------------
@when("I cov-generate dry-run report with an empty tree")
def step_dryrun_empty(context):
context.cov_dry_run = context.cov_svc.generate_dry_run_report(context.cov_cid, {})
@when('I cov-generate dry-run report with adjacency "{spec}"')
def step_dryrun_adj(context, spec):
tree = _parse_adj(spec)
context.cov_dry_run = context.cov_svc.generate_dry_run_report(context.cov_cid, tree)
@when('I cov-generate dry-run report with a chain of {n:d} nodes rooted at "{root}"')
def step_dryrun_chain(context, n, root):
tree = _build_chain_cov(root, n)
context.cov_dry_run = context.cov_svc.generate_dry_run_report(context.cov_cid, tree)
@when('I cov-attempt to generate dry-run report for correction id "{cid}"')
def step_attempt_dryrun(context, cid):
try:
context.cov_svc.generate_dry_run_report(cid, {})
context.cov_error = None
except ResourceNotFoundError as exc:
context.cov_error = exc
# -------------------------------------------------------------------
# When steps - execute_revert
# -------------------------------------------------------------------
@when('I cov-execute revert with adjacency "{spec}"')
def step_exec_revert_adj(context, spec):
tree = _parse_adj(spec)
context.cov_revert_result = context.cov_svc.execute_revert(context.cov_cid, tree)
@when("I cov-execute revert with empty tree")
def step_exec_revert_empty(context):
context.cov_revert_result = context.cov_svc.execute_revert(context.cov_cid, {})
@when("I cov-attempt to execute revert on the stored correction")
def step_attempt_exec_revert(context):
try:
context.cov_svc.execute_revert(context.cov_cid, {})
context.cov_error = None
except (ValidationError, ResourceNotFoundError) as exc:
context.cov_error = exc
# -------------------------------------------------------------------
# When steps - execute_append
# -------------------------------------------------------------------
@when("I cov-execute append for the stored correction")
def step_exec_append(context):
context.cov_append_result = context.cov_svc.execute_append(context.cov_cid)
@when("I cov-attempt to execute append on the stored correction")
def step_attempt_exec_append(context):
try:
context.cov_svc.execute_append(context.cov_cid)
context.cov_error = None
except (ValidationError, ResourceNotFoundError) as exc:
context.cov_error = exc
# -------------------------------------------------------------------
# When steps - execute_correction (dispatch)
# -------------------------------------------------------------------
@when('I cov-dispatch execute correction with adjacency "{spec}"')
def step_dispatch_adj(context, spec):
tree = _parse_adj(spec)
context.cov_dispatch_result = context.cov_svc.execute_correction(
context.cov_cid, tree
)
@when("I cov-dispatch execute correction with no tree")
def step_dispatch_no_tree(context):
context.cov_dispatch_result = context.cov_svc.execute_correction(context.cov_cid)
# -------------------------------------------------------------------
# When steps - get_correction
# -------------------------------------------------------------------
@when("I cov-get the stored correction by id")
def step_get_by_id(context):
context.cov_retrieved = context.cov_svc.get_correction(context.cov_cid)
@when('I cov-attempt to get correction with id "{cid}"')
def step_attempt_get(context, cid):
try:
context.cov_svc.get_correction(cid)
context.cov_error = None
except ResourceNotFoundError as exc:
context.cov_error = exc
# -------------------------------------------------------------------
# When steps - list_corrections
# -------------------------------------------------------------------
@when("I cov-list all corrections without filter")
def step_list_all(context):
context.cov_list = context.cov_svc.list_corrections()
@when('I cov-list corrections for plan "{plan}"')
def step_list_by_plan(context, plan):
context.cov_list = context.cov_svc.list_corrections(plan_id=plan)
# -------------------------------------------------------------------
# When steps - list_attempts
# -------------------------------------------------------------------
@when("I cov-list attempts for the stored correction")
def step_list_attempts(context):
context.cov_attempts = context.cov_svc.list_attempts(context.cov_cid)
@when('I cov-attempt to list attempts for correction id "{cid}"')
def step_attempt_list_attempts(context, cid):
try:
context.cov_svc.list_attempts(cid)
context.cov_error = None
except ResourceNotFoundError as exc:
context.cov_error = exc
# -------------------------------------------------------------------
# When steps - cancel_correction
# -------------------------------------------------------------------
@when("I cov-cancel the stored correction")
def step_cancel(context):
context.cov_svc.cancel_correction(context.cov_cid)
@when("I cov-attempt to cancel the stored correction")
def step_attempt_cancel(context):
try:
context.cov_svc.cancel_correction(context.cov_cid)
context.cov_error = None
except ValidationError as exc:
context.cov_error = exc
# -------------------------------------------------------------------
# When steps - _compute_affected_subtree
# -------------------------------------------------------------------
@when('I cov-compute subtree for "{root}" with empty tree')
def step_subtree_empty(context, root):
context.cov_subtree = CorrectionService._compute_affected_subtree(root, {})
@when('I cov-compute subtree for "{root}" with adjacency "{spec}"')
def step_subtree_adj(context, root, spec):
tree = _parse_adj(spec)
context.cov_subtree = CorrectionService._compute_affected_subtree(root, tree)
# ===================================================================
# Then steps
# ===================================================================
# -------------------------------------------------------------------
# __init__ assertions
# -------------------------------------------------------------------
@then("the coverage service corrections dict should be empty")
def step_init_corrections(context):
assert len(context.cov_svc._corrections) == 0
@then("the coverage service impacts dict should be empty")
def step_init_impacts(context):
assert len(context.cov_svc._impacts) == 0
@then("the coverage service attempts dict should be empty")
def step_init_attempts(context):
assert len(context.cov_svc._attempts) == 0
@then("the coverage service results dict should be empty")
def step_init_results(context):
assert len(context.cov_svc._results) == 0
# -------------------------------------------------------------------
# request_correction assertions
# -------------------------------------------------------------------
@then("the cov-request should be stored in the service")
def step_req_stored(context):
assert context.cov_req is not None
fetched = context.cov_svc.get_correction(context.cov_req.correction_id)
assert fetched.correction_id == context.cov_req.correction_id
@then('the cov-request plan_id should be "{val}"')
def step_req_plan(context, val):
assert context.cov_req.plan_id == val
@then('the cov-request target_decision_id should be "{val}"')
def step_req_target(context, val):
assert context.cov_req.target_decision_id == val
@then('the cov-request mode should be "{val}"')
def step_req_mode(context, val):
assert context.cov_req.mode.value == val
@then('the cov-request status should be "{val}"')
def step_req_status(context, val):
assert context.cov_req.status.value == val
@then("the cov-request dry_run should be false")
def step_req_dryrun_false(context):
assert context.cov_req.dry_run is False
@then("the cov-request dry_run should be true")
def step_req_dryrun_true(context):
assert context.cov_req.dry_run is True
@then('the cov-request guidance should be "{val}"')
def step_req_guidance(context, val):
assert context.cov_req.guidance == val
@then("the cov-request guidance should be empty")
def step_req_guidance_empty(context):
assert context.cov_req.guidance == ""
# -------------------------------------------------------------------
# Error assertions
# -------------------------------------------------------------------
@then("a cov ValidationError should have been raised")
def step_cov_val_error(context):
assert context.cov_error is not None, "Expected ValidationError but none was raised"
assert isinstance(context.cov_error, ValidationError), (
f"Expected ValidationError, got {type(context.cov_error).__name__}"
)
@then("a cov ResourceNotFoundError should have been raised")
def step_cov_rnf_error(context):
assert context.cov_error is not None, (
"Expected ResourceNotFoundError but none was raised"
)
assert isinstance(context.cov_error, ResourceNotFoundError), (
f"Expected ResourceNotFoundError, got {type(context.cov_error).__name__}"
)
# -------------------------------------------------------------------
# Impact assertions
# -------------------------------------------------------------------
@then('the cov-impact affected decisions should be "{expected}"')
def step_impact_decisions(context, expected):
expected_list = [d.strip() for d in expected.split(",")]
assert context.cov_impact.affected_decisions == expected_list, (
f"Expected {expected_list}, got {context.cov_impact.affected_decisions}"
)
@then('the cov-impact risk level should be "{val}"')
def step_impact_risk(context, val):
assert context.cov_impact.risk_level == val
@then('the cov-impact rollback tier should be "{val}"')
def step_impact_rollback(context, val):
assert context.cov_impact.rollback_tier == val
@then("the cov-impact estimated cost should be {cost:g}")
def step_impact_cost(context, cost):
assert context.cov_impact.estimated_cost == cost
@then('the cov-impact artifacts to archive should be "{expected}"')
def step_impact_artifacts(context, expected):
expected_list = [a.strip() for a in expected.split(",")]
assert context.cov_impact.artifacts_to_archive == expected_list
@then('the cov-impact affected files should be "{expected}"')
def step_impact_files(context, expected):
expected_list = [f.strip() for f in expected.split(",")]
assert context.cov_impact.affected_files == expected_list
@then("the cov-impact affected decisions count should be {n:d}")
def step_impact_count(context, n):
actual = len(context.cov_impact.affected_decisions)
assert actual == n, f"Expected {n}, got {actual}"
@then('the cov-stored correction status should be "{val}"')
def step_stored_status(context, val):
req = context.cov_svc.get_correction(context.cov_cid)
assert req.status.value == val, f"Expected status '{val}', got '{req.status.value}'"
# -------------------------------------------------------------------
# Dry-run report assertions
# -------------------------------------------------------------------
@then('the cov dry-run mode should be "{val}"')
def step_dr_mode(context, val):
assert context.cov_dry_run.mode.value == val
@then("the cov dry-run warnings should be empty")
def step_dr_warnings_empty(context):
assert len(context.cov_dry_run.warnings) == 0, (
f"Expected no warnings, got {context.cov_dry_run.warnings}"
)
@then('the cov dry-run warnings should contain "{fragment}"')
def step_dr_warning_contains(context, fragment):
joined = " | ".join(context.cov_dry_run.warnings)
assert fragment in joined, (
f"'{fragment}' not found in warnings: {context.cov_dry_run.warnings}"
)
@then('the cov dry-run decisions to invalidate should be "{expected}"')
def step_dr_invalidate(context, expected):
expected_list = [d.strip() for d in expected.split(",")]
assert context.cov_dry_run.decisions_to_invalidate == expected_list
@then("the cov dry-run decisions to invalidate should be empty list")
def step_dr_invalidate_empty(context):
assert context.cov_dry_run.decisions_to_invalidate == []
@then("the cov dry-run decisions to invalidate count should be {n:d}")
def step_dr_invalidate_count(context, n):
actual = len(context.cov_dry_run.decisions_to_invalidate)
assert actual == n, f"Expected {n}, got {actual}"
@then("the cov dry-run estimated recompute time should be {t:g}")
def step_dr_recompute(context, t):
assert context.cov_dry_run.estimated_recompute_time_seconds == t
# -------------------------------------------------------------------
# Revert result assertions
# -------------------------------------------------------------------
@then('the cov revert result status should be "{val}"')
def step_revert_status(context, val):
assert context.cov_revert_result.status.value == val
@then('the cov revert result reverted decisions should include "{d}"')
def step_revert_incl(context, d):
assert d in context.cov_revert_result.reverted_decisions
@then('the cov revert result archived artifacts should include "{a}"')
def step_revert_artifacts(context, a):
assert a in context.cov_revert_result.archived_artifacts
# -------------------------------------------------------------------
# Append result assertions
# -------------------------------------------------------------------
@then('the cov append result status should be "{val}"')
def step_append_status(context, val):
assert context.cov_append_result.status.value == val
@then("the cov append result spawned_child_plan_id should not be none")
def step_append_child(context):
assert context.cov_append_result.spawned_child_plan_id is not None
@then("the cov append result new_decisions should not be empty")
def step_append_new_decisions(context):
assert len(context.cov_append_result.new_decisions) > 0
@then("the cov first attempt details should contain spawned_child_plan_id")
def step_append_attempt_details(context):
attempts = context.cov_svc.list_attempts(context.cov_cid)
assert "spawned_child_plan_id" in attempts[0].details
# -------------------------------------------------------------------
# Dispatch result assertions
# -------------------------------------------------------------------
@then('the cov dispatch result status should be "{val}"')
def step_dispatch_status(context, val):
assert context.cov_dispatch_result.status.value == val
@then('the cov dispatch result reverted decisions should include "{d}"')
def step_dispatch_reverted(context, d):
assert d in context.cov_dispatch_result.reverted_decisions
@then("the cov dispatch result spawned_child_plan_id should not be none")
def step_dispatch_child(context):
assert context.cov_dispatch_result.spawned_child_plan_id is not None
# -------------------------------------------------------------------
# get_correction assertions
# -------------------------------------------------------------------
@then('the cov-retrieved correction plan_id should be "{val}"')
def step_get_plan(context, val):
assert context.cov_retrieved.plan_id == val
@then('the cov-retrieved correction target_decision_id should be "{val}"')
def step_get_target(context, val):
assert context.cov_retrieved.target_decision_id == val
# -------------------------------------------------------------------
# list_corrections assertions
# -------------------------------------------------------------------
@then("the cov corrections list should have at least {n:d} items")
def step_list_min(context, n):
actual = len(context.cov_list)
assert actual >= n, f"Expected at least {n}, got {actual}"
@then("the cov corrections list should have {n:d} item")
def step_list_exact_one(context, n):
actual = len(context.cov_list)
assert actual == n, f"Expected {n}, got {actual}"
@then("the cov corrections list should have {n:d} items")
def step_list_exact(context, n):
actual = len(context.cov_list)
assert actual == n, f"Expected {n}, got {actual}"
# -------------------------------------------------------------------
# list_attempts assertions
# -------------------------------------------------------------------
@then("the cov attempts list should have {n:d} items")
def step_attempts_count_plural(context, n):
actual = len(context.cov_attempts)
assert actual == n, f"Expected {n}, got {actual}"
@then("the cov attempts list should have {n:d} item")
def step_attempts_count_single(context, n):
actual = len(context.cov_attempts)
assert actual == n, f"Expected {n}, got {actual}"
# -------------------------------------------------------------------
# Attempt detail assertions
# -------------------------------------------------------------------
@then("the cov attempts for the stored correction should have {n:d} entry")
def step_attempts_for_stored(context, n):
attempts = context.cov_svc.list_attempts(context.cov_cid)
assert len(attempts) == n, f"Expected {n}, got {len(attempts)}"
@then("the cov first attempt should be successful")
def step_first_attempt_ok(context):
attempts = context.cov_svc.list_attempts(context.cov_cid)
assert attempts[0].success is True
@then("the cov first attempt completed_at should be set")
def step_first_attempt_completed(context):
attempts = context.cov_svc.list_attempts(context.cov_cid)
assert attempts[0].completed_at is not None
# -------------------------------------------------------------------
# _classify_risk assertions
# -------------------------------------------------------------------
@then('cov classifying risk for {n:d} affected returns "{expected}"')
def step_classify_risk(context, n, expected):
result = CorrectionService._classify_risk(n)
assert result == expected, (
f"_classify_risk({n}) = '{result}', expected '{expected}'"
)
# -------------------------------------------------------------------
# _compute_affected_subtree assertions
# -------------------------------------------------------------------
@then('the cov subtree should be "{expected}"')
def step_subtree_result(context, expected):
expected_list = [n.strip() for n in expected.split(",")]
assert context.cov_subtree == expected_list, (
f"Expected {expected_list}, got {context.cov_subtree}"
)
@then("the cov subtree count should be {n:d}")
def step_subtree_count(context, n):
actual = len(context.cov_subtree)
assert actual == n, f"Expected {n}, got {actual}"