Files
temp/features/steps/tui_first_run_steps.py
freemo 02250473ad fix(ci): restore all CI quality gates to passing on master
Fix all failing CI quality gates (lint, unit_tests, format) without
suppressing any quality enforcement.

Root causes and fixes:

1. Format: features/steps/plan_namespaced_name_tdd_steps.py had trailing
   whitespace; fixed by running ruff format.

2. Unit tests - A2A JSON-RPC 2.0 migration (commit 9c6d6915) renamed
   A2aRequest fields (operation→method, request_id→id, a2a_version→jsonrpc)
   and A2aResponse fields (status+data→result, request_id→id) but did not
   update all step files and feature files:
   - a2a_jsonrpc_wire_format_steps.py: added use_step_matcher('re') and
     reset to 'parse' at end to prevent parallel test interference
   - a2a_facade_wiring_steps.py: updated operation= to method=, .status/.data
     to .result
   - a2a_facade_steps.py: updated request_id→id, a2a_version→jsonrpc,
     A2aResponse(request_id=..., status=...) to new API
   - m6_facade_steps.py: updated all old API usage
   - devcontainer_cleanup_steps.py: updated A2aRequest(operation=...)
   - plan_prompt_command_steps.py: updated A2aRequest(operation=...)
   - wf03_plan_prompt_confidence_steps.py: updated A2aRequest(operation=...)
   - consolidated_misc.feature: updated old A2aRequest/A2aResponse scenarios

3. Unit tests - Session CLI output changed (commit 0d5d9cf0 and others):
   - 'Session Created' → 'Session created' (lowercase)
   - 'Session Details' → 'Session Summary'
   - 'Sessions (N total)' → 'Sessions'
   - session list JSON: top-level 'total' → nested 'summary.total'
   - Fixed in: session_cli.feature, session_cli_coverage_boost.feature,
     session_cli_uncovered_branches.feature, session_list_error.feature,
     tdd_session_create_persist_steps.py

4. Unit tests - Plan list output changed (commit 1a07a891):
   - 'V3 Lifecycle Plans' → 'Plans'
   - 'Lifecycle Plans' → 'Plans'
   - Name column removed (restored in source)
   - Invariants column removed (restored in source)
   - Project truncation removed (restored in source)
   - Fixed in: plan_cli_cancel_revert_coverage.feature,
     plan_lifecycle_cli_coverage.feature, plan_cli_coverage_boost_steps.py,
     plan.py (source code restored)

5. Unit tests - Plan apply command now requires ULID (commit 300a5d6d):
   - plan_cli_coverage_r3.feature: updated 'PLAN-001' to valid ULID
   - plan_cli_coverage_r3_steps.py: added --yes flag, added new step for
     no-eligible-plans path

6. Unit tests - Various source code bugs:
   - ThoughtBlock: converted from @dataclass to Pydantic BaseModel
     (architecture test requires all dataclasses to use Pydantic)
   - session.py: added DatabaseError handling to export, import, tell commands
   - database.py: fixed rollback_to() to reuse checkpoint connection for writes
   - database.py: added _get_checkpoint_conn() helper
   - check-tls-cert.py: fixed SSLCertVerificationError.reason AttributeError

7. Unit tests - Test step bugs:
   - error_recovery_coverage_boost_steps.py: fixed invalid ULID _PLAN_ID
   - session_service_coverage_steps.py: fixed 'sha256:' prefix bug in checksum
   - database_models_new_coverage_steps.py: added 'name' field to session mock
   - async_audit_recording_steps.py: fixed Settings(audit_async=False) via env var
   - coverage_threshold_config_steps.py: added --coverage-min pattern support
   - m5_acms_smoke_steps.py: updated usage hint text
   - actor_cli_yaml_steps.py: updated 'Removed actor' → 'Actor removed'
   - aimodelscredentials_steps.py: set context.imported_class in import step
   - domain_base_model.feature: added missing 'When I examine model_config' step
   - tui_first_run_steps.py: fixed module reload to restore cleveragents.tui.*
     modules after test (prevented patch interference in subsequent tests)
   - tui_first_run_steps.py: added set_search('') step for empty string
   - resource_handler_base_coverage_r3_steps.py: use _MinimalHandler instead
     of DatabaseResourceHandler for NotImplementedError tests
   - resource_handler_crud.feature: updated to test new DatabaseHandler behavior
   - resource_handler_sandbox.feature: updated to test new DatabaseHandler behavior
   - tdd_json_decode_crash_persistence.feature: fixed @tdd_bug → @tdd_issue tags

8. Parallel test interference:
   - All step files using use_step_matcher('re') now reset to 'parse' at end
     to prevent global matcher state leaking to subsequent step files
2026-04-04 20:38:16 +00:00

491 lines
16 KiB
Python

"""Step definitions for tui_first_run.feature.
Covers:
- is_first_run helper
- create_default_persona_for_actor helper
- render_actor_selection helper
- ActorSelectionOverlay widget
- TUI app integration (on_mount first-run detection, _complete_first_run)
"""
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
from types import ModuleType
from unittest.mock import MagicMock
from behave import given, then, when
# ---------------------------------------------------------------------------
# Shared mock-Textual infrastructure (mirrors tui_app_coverage_steps.py)
# ---------------------------------------------------------------------------
_MOCK_TEXTUAL_KEYS = [
"textual",
"textual.app",
"textual.containers",
"textual.widgets",
]
def _build_mock_textual() -> dict[str, ModuleType]:
mock_textual = ModuleType("textual")
mock_textual_app = ModuleType("textual.app")
mock_textual_containers = ModuleType("textual.containers")
mock_textual_widgets = ModuleType("textual.widgets")
class MockApp:
def __init__(self, *args: object, **kwargs: object) -> None:
self._widgets: dict[str, object] = {}
def query_one(self, selector: str, widget_type: type | None = None) -> object:
if selector in self._widgets:
return self._widgets[selector]
if widget_type is not None:
widget = widget_type(id=selector.lstrip("#"))
self._widgets[selector] = widget
return widget
return MagicMock()
class MockVertical:
def __init__(self, *args: object, **kwargs: object) -> None:
pass
def __enter__(self) -> MockVertical:
return self
def __exit__(self, *args: object) -> None:
pass
class MockHeader:
def __init__(self, *args: object, **kwargs: object) -> None:
pass
class MockFooter:
def __init__(self, *args: object, **kwargs: object) -> None:
pass
class MockStatic:
def __init__(self, *args: object, **kwargs: object) -> None:
self._text = ""
def update(self, text: str) -> None:
self._text = text
mock_textual_app.App = MockApp
mock_textual_containers.Vertical = MockVertical
mock_textual_widgets.Header = MockHeader
mock_textual_widgets.Footer = MockFooter
mock_textual_widgets.Static = MockStatic
return {
"textual": mock_textual,
"textual.app": mock_textual_app,
"textual.containers": mock_textual_containers,
"textual.widgets": mock_textual_widgets,
}
def _reload_tui_modules(mock_modules: dict[str, ModuleType]) -> None:
"""Reload TUI modules with mocked Textual in sys.modules."""
tui_keys = [
key for key in list(sys.modules.keys()) if key.startswith("cleveragents.tui")
]
for key in tui_keys:
del sys.modules[key]
for name, mod in mock_modules.items():
sys.modules[name] = mod
def _restore_textual(original: dict[str, object]) -> None:
for key in _MOCK_TEXTUAL_KEYS:
if key in original:
sys.modules[key] = original[key] # type: ignore[assignment]
else:
sys.modules.pop(key, None)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_registry(tmp_dir: Path) -> object:
from cleveragents.tui.persona.registry import PersonaRegistry
return PersonaRegistry(config_dir=tmp_dir)
# ---------------------------------------------------------------------------
# is_first_run
# ---------------------------------------------------------------------------
@given("an empty persona registry")
def step_empty_registry(context: object) -> None:
tmp = tempfile.mkdtemp()
context._tmp_dir = Path(tmp)
context._registry = _make_registry(context._tmp_dir)
@given('a persona registry with a default persona using actor "{actor}"')
def step_registry_with_persona(context: object, actor: str) -> None:
tmp = tempfile.mkdtemp()
context._tmp_dir = Path(tmp)
context._registry = _make_registry(context._tmp_dir)
from cleveragents.tui.persona.schema import Persona
persona = Persona(name="default", actor=actor, description="test")
context._registry.save(persona)
@then("is_first_run should return True")
def step_is_first_run_true(context: object) -> None:
from cleveragents.tui.first_run import is_first_run
assert is_first_run(context._registry) is True
@then("is_first_run should return False")
def step_is_first_run_false(context: object) -> None:
from cleveragents.tui.first_run import is_first_run
assert is_first_run(context._registry) is False
# ---------------------------------------------------------------------------
# create_default_persona_for_actor
# ---------------------------------------------------------------------------
@when('I call create_default_persona_for_actor with actor "{actor}"')
def step_create_default_persona(context: object, actor: str) -> None:
from cleveragents.tui.first_run import create_default_persona_for_actor
context._created_persona = create_default_persona_for_actor(
context._registry, actor
)
@then('the registry should contain a persona named "{name}"')
def step_registry_has_persona(context: object, name: str) -> None:
persona = context._registry.get(name)
assert persona is not None, f"Expected persona '{name}' in registry"
@then('the default persona actor should be "{actor}"')
def step_default_persona_actor(context: object, actor: str) -> None:
persona = context._registry.get("default")
assert persona is not None
assert persona.actor == actor, f"Expected actor '{actor}', got '{persona.actor}'"
@then('the registry last persona should be "{name}"')
def step_registry_last_persona(context: object, name: str) -> None:
last = context._registry.get_last_persona()
assert last == name, f"Expected last persona '{name}', got '{last}'"
# ---------------------------------------------------------------------------
# render_actor_selection
# ---------------------------------------------------------------------------
@when("I render the actor selection with default actors")
def step_render_default(context: object) -> None:
from cleveragents.tui.widgets.actor_selection_overlay import (
_DEFAULT_ACTORS,
render_actor_selection,
)
context._rendered = render_actor_selection(_DEFAULT_ACTORS, 0, "")
@when("I render the actor selection with selected index 2")
def step_render_selected_index(context: object) -> None:
from cleveragents.tui.widgets.actor_selection_overlay import (
_DEFAULT_ACTORS,
render_actor_selection,
)
context._rendered = render_actor_selection(_DEFAULT_ACTORS, 2, "")
@then("the rendered text should contain a cursor marker")
def step_rendered_contains_cursor(context: object) -> None:
# The cursor character is the heavy right-pointing angle quotation mark
# used as a selection indicator per the spec mockup.
cursor = "\u276f"
assert cursor in context._rendered, (
f"Expected cursor marker in rendered text:\n{context._rendered}"
)
@when('I render the actor selection with search query "{query}"')
def step_render_with_query(context: object, query: str) -> None:
from cleveragents.tui.widgets.actor_selection_overlay import (
_DEFAULT_ACTORS,
render_actor_selection,
)
context._rendered = render_actor_selection(_DEFAULT_ACTORS, 0, query)
@then('the rendered text should contain "{text}"')
def step_rendered_contains(context: object, text: str) -> None:
assert text in context._rendered, (
f"Expected '{text}' in rendered text:\n{context._rendered}"
)
# ---------------------------------------------------------------------------
# ActorSelectionOverlay widget
# ---------------------------------------------------------------------------
@given("a new ActorSelectionOverlay")
def step_new_overlay(context: object) -> None:
from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay
context._overlay = ActorSelectionOverlay()
@when("I call show on the overlay")
def step_overlay_show(context: object) -> None:
context._overlay.show()
@when("I call hide on the overlay")
def step_overlay_hide(context: object) -> None:
context._overlay.hide()
@when("I call show on the overlay with actors {actors_json}")
def step_overlay_show_custom(context: object, actors_json: str) -> None:
import json
actors = json.loads(actors_json)
context._overlay.show(actors=actors)
@when("I call move_down on the overlay")
def step_overlay_move_down(context: object) -> None:
context._overlay.move_down()
@when("I call move_up on the overlay")
def step_overlay_move_up(context: object) -> None:
context._overlay.move_up()
@when("I move_down past the last actor")
def step_overlay_move_down_past_last(context: object) -> None:
count = len(context._overlay.filtered_actors)
for _ in range(count):
context._overlay.move_down()
@when('I call set_search with query "{query}"')
def step_overlay_set_search(context: object, query: str) -> None:
context._overlay.set_search(query)
@when('I call set_search with query ""')
def step_overlay_set_search_empty(context: object) -> None:
context._overlay.set_search("")
@when("I call confirm on the overlay")
def step_overlay_confirm(context: object) -> None:
context._confirmed_result = context._overlay.confirm()
@then("the overlay should be visible")
def step_overlay_visible(context: object) -> None:
assert context._overlay.visible is True
@then("the overlay should not be visible")
def step_overlay_not_visible(context: object) -> None:
assert context._overlay.visible is False
@then('the overlay actors list should contain "{actor}"')
def step_overlay_actors_contains(context: object, actor: str) -> None:
assert actor in context._overlay.actors, (
f"Expected '{actor}' in actors: {context._overlay.actors}"
)
@then("the overlay selected index should be 1")
def step_overlay_selected_1(context: object) -> None:
assert context._overlay.selected_index == 1, (
f"Expected 1, got {context._overlay.selected_index}"
)
@then("the overlay selected index should be the last index")
def step_overlay_selected_last(context: object) -> None:
last = len(context._overlay.filtered_actors) - 1
assert context._overlay.selected_index == last, (
f"Expected {last}, got {context._overlay.selected_index}"
)
@then("the overlay selected index should be 0")
def step_overlay_selected_0(context: object) -> None:
assert context._overlay.selected_index == 0, (
f"Expected 0, got {context._overlay.selected_index}"
)
@then('the overlay filtered actors should only contain actors matching "{query}"')
def step_overlay_filtered_match(context: object, query: str) -> None:
for actor in context._overlay.filtered_actors:
assert query.lower() in actor.lower(), (
f"Actor '{actor}' does not match query '{query}'"
)
@then("the overlay filtered actors count should equal the full actors count")
def step_overlay_filtered_count_full(context: object) -> None:
assert len(context._overlay.filtered_actors) == len(context._overlay.actors), (
f"Filtered: {len(context._overlay.filtered_actors)}, "
f"Full: {len(context._overlay.actors)}"
)
@then('the confirmed actor should be "{actor}"')
def step_confirmed_actor(context: object, actor: str) -> None:
assert context._confirmed_result == actor, (
f"Expected '{actor}', got '{context._confirmed_result}'"
)
@then("the confirmed actor should be None")
def step_confirmed_actor_none(context: object) -> None:
assert context._confirmed_result is None, (
f"Expected None, got '{context._confirmed_result}'"
)
@then("the overlay confirmed flag should be True")
def step_overlay_confirmed_flag(context: object) -> None:
assert context._overlay.confirmed is True
@then("the overlay selected_actor should be None")
def step_overlay_selected_actor_none(context: object) -> None:
assert context._overlay.selected_actor is None
@then('the overlay search_query should be ""')
def step_overlay_search_query_empty(context: object) -> None:
assert context._overlay.search_query == "", (
f"Expected empty string, got '{context._overlay.search_query}'"
)
# ---------------------------------------------------------------------------
# TUI app integration
# ---------------------------------------------------------------------------
def _build_first_run_app(context: object, *, has_personas: bool) -> None:
"""Build a mocked TUI app for first-run integration tests."""
tmp = tempfile.mkdtemp()
context._tmp_dir = Path(tmp)
original = {k: sys.modules[k] for k in _MOCK_TEXTUAL_KEYS if k in sys.modules}
# Save all cleveragents.tui.* modules before deleting them so we can
# restore them after the test (prevents module re-import issues in
# subsequent tests that patch cleveragents.tui.commands.get_container).
original_tui = {
k: sys.modules[k]
for k in list(sys.modules.keys())
if k.startswith("cleveragents.tui")
}
mock_modules = _build_mock_textual()
_reload_tui_modules(mock_modules)
def _cleanup() -> None:
_restore_textual(original)
# Restore cleveragents.tui.* modules to prevent re-import issues
for key, mod in original_tui.items():
sys.modules[key] = mod
context.add_cleanup(_cleanup)
from cleveragents.tui.persona.registry import PersonaRegistry
from cleveragents.tui.persona.schema import Persona
from cleveragents.tui.persona.state import PersonaState
registry = PersonaRegistry(config_dir=context._tmp_dir)
if has_personas:
persona = Persona(name="default", actor="local/mock", description="test")
registry.save(persona)
context._registry = registry
persona_state = PersonaState(registry=registry)
class _MockRouter:
def handle(self, raw: str, *, session_id: str) -> str:
return f"handled:{raw}"
from cleveragents.tui.app import CleverAgentsTuiApp
app = CleverAgentsTuiApp(command_router=_MockRouter(), persona_state=persona_state)
context._first_run_app = app
@given("the TUI app is initialised with an empty persona registry")
def step_app_empty_registry(context: object) -> None:
_build_first_run_app(context, has_personas=False)
@given("the TUI app is initialised with an existing persona registry")
def step_app_existing_registry(context: object) -> None:
_build_first_run_app(context, has_personas=True)
@when("I call on_mount on the first-run app")
def step_app_on_mount(context: object) -> None:
context._first_run_app.on_mount()
@then("the actor selection overlay should be visible")
def step_actor_overlay_visible(context: object) -> None:
from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay
overlay = context._first_run_app.query_one(
"#actor-selection", ActorSelectionOverlay
)
assert overlay.visible is True, "Expected actor selection overlay to be visible"
@then("the actor selection overlay should not be visible")
def step_actor_overlay_not_visible(context: object) -> None:
from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay
overlay = context._first_run_app.query_one(
"#actor-selection", ActorSelectionOverlay
)
assert overlay.visible is False, "Expected actor selection overlay to be hidden"
@when('I call _complete_first_run with actor "{actor}"')
def step_complete_first_run(context: object, actor: str) -> None:
context._first_run_app._complete_first_run(actor)
@then("the persona bar should reflect the new actor")
def step_persona_bar_reflects_actor(context: object) -> None:
from cleveragents.tui.widgets.persona_bar import PersonaBar
bar = context._first_run_app.query_one("#persona-bar", PersonaBar)
assert "anthropic/claude-4-sonnet" in bar._text, (
f"Expected actor in persona bar, got: {bar._text}"
)