# Implementation Plan: REQ-007 -- TUI Integration Tests with Textual Pilot

## Domain Research

- **Problem type**: Testing / Quality -- integration testing of a Textual TUI app using the Pilot API
- **Approaches evaluated**:
  1. Unit tests with MagicMock (current approach) -- fast but does NOT test real widget interaction, focus, or event routing. Misses the bugs users report.
  2. Textual Pilot integration tests (`app.run_test()`) -- runs the full Textual app in headless mode, simulates real keystrokes and clicks via `Pilot`. This is the standard Textual testing approach.
  3. Snapshot testing (`pytest-textual-snapshot`) -- generates SVG screenshots for visual regression. Overkill for this REQ; useful later.
- **Recommended approach**: Pilot-based integration tests (option 2). Write async tests using `app.run_test(size=(120, 40))` + `Pilot` for all interaction scenarios.
- **Libraries/tools**:
  - `textual==8.0.0` (already installed) -- provides `App.run_test()` and `Pilot`
  - `pytest-asyncio` (NOT installed, MUST ADD) -- required for native `async def test_*` functions. Alternative: wrap every test in `asyncio.run()` (current pattern in `test_compose_and_on_mount`), but this is fragile and non-standard.
- **Known pitfalls**:
  1. **`run_test()` defaults**: `notifications=False` by default. Tests that verify toast notifications (mandatory agent warning) MUST pass `notifications=True`.
  2. **Static.renderable does NOT exist in Textual 8.0.0**. The REQ example code `info.renderable` is wrong. Use `str(info.render())` to read Static widget content.
  3. **`pytest-asyncio` + fixtures**: `app.run_test()` is incompatible with pytest fixtures that yield the pilot. Each test must create its own `async with app.run_test()` block.
  4. **`await pilot.pause()`**: MUST be called after every action that triggers message processing (key press, click, focus change).

### Bug Diagnosis Findings (from live experiments)

These experiments were run against the actual `IevoApp` in Textual 8.0.0 headless mode:

**Bug 1: Focus does NOT land on SelectionList on mount.**
- `AUTO_FOCUS = "*"` selects the first focusable widget in DOM order.
- Header, Static, Footer, TabbedContent are all NOT focusable (`can_focus = False`).
- BUT: focus lands on `ContentTabs` (internal widget of TabbedContent), NOT on SelectionList.
- `ContentTabs` is a focusable child of TabbedContent that renders the tab strip.
- Result: User must press `Tab` once to move focus from ContentTabs to SelectionList.
- **Severity**: Medium. Navigation works, but the first interaction requires Tab.
- **Fix**: Set `AUTO_FOCUS = "#agents-list"` on IevoApp to focus SelectionList directly.

**Bug 2: `highlighted` is `None` initially even when SelectionList has focus.**
- After Tab -> SelectionList gets focus, `highlighted` is `None`.
- User must press `Down` once to highlight the first item (index 0).
- Only then does `Space` toggle the correct item.
- Without `Down` first, `Space` toggles item at index `None` which maps to the first item -- works but is not visually indicated.
- **Severity**: Low. Functional but confusing UX.
- **Fix**: Not in scope for REQ-007 (no new features). Document in tests.

**Bug 3 (NOT a bug): Single-letter key bindings do NOT conflict with SelectionList.**
- App bindings (`a`, `p`, `e`, `f`) have `priority=False`.
- Textual binding resolution: focused widget first -> parent chain -> App.
- SelectionList does not bind `a`/`p`/`e`/`f`, so they bubble up to App. This is correct behavior.
- When SelectionList has focus, pressing `a` switches to Agents tab (already there), `p` switches to Pipeline, etc. Focus moves away. This is by design.

**Bug 4 (NOT a bug): Tab switching via `action_tab()` works correctly.**
- `self.query_one(TabbedContent).active = tab_id` correctly switches visible tab.

**Summary of real bugs to fix**: Only Bug 1 (AUTO_FOCUS) needs a code fix. Bug 2 is a UX nit outside REQ-007 scope.

## Architecture Assessment

- **Fits existing architecture**: Yes. Adding a test file and a minor app fix.
- **New patterns introduced**: `pytest-asyncio` as dev dependency; async test pattern for Textual.
- **Modules affected**: `tests/test_tui.py` (add integration tests), `src/ievo/tui/app.py` (AUTO_FOCUS fix), `pyproject.toml` (add pytest-asyncio).
- **Risk level**: Low
- **Estimated complexity**: M (day)

## Pre-conditions

- [x] REQ-006 must be implemented (dependency -- bundled marketplace)
- [x] `src/ievo/tui/app.py` exists with IevoApp
- [x] `tests/test_tui.py` exists with unit tests
- [x] Git workflow confirmed in DECISIONS.md (D-012: trunk-based)

## Sub-tasks

### TASK-007-1: Add pytest-asyncio + write diagnostic tests exposing Bug 1
**Scope**: Add pytest-asyncio to dev deps. Write integration tests that prove (a) focus does NOT land on SelectionList by default and (b) tab navigation works. These tests expose the bug as red tests.
**Tests**: ~5
**Files**: `pyproject.toml`, `tests/test_tui_integration.py` (new file)
**Time**: ~10 min

### TASK-007-2: Fix Bug 1 (AUTO_FOCUS) + flip diagnostic tests to green
**Scope**: Set `AUTO_FOCUS = "#agents-list"` on IevoApp. Update the diagnostic test to assert focus IS on SelectionList. Verify all tab switching still works.
**Tests**: ~0 new (existing tests from TASK-007-1 turn green)
**Files**: `src/ievo/tui/app.py`
**Time**: ~5 min

### TASK-007-3: Agent selection integration tests
**Scope**: Write Pilot-based tests for agent toggle ON/OFF, mandatory agent warning, manifest and file side effects.
**Tests**: ~6
**Files**: `tests/test_tui_integration.py`
**Time**: ~12 min

### TASK-007-4: Edge case + lifecycle integration tests
**Scope**: No-project state, quit action, refresh action, compose/mount lifecycle with real project.
**Tests**: ~5
**Files**: `tests/test_tui_integration.py`
**Time**: ~10 min

## Micro-Steps

### Step 1: Add pytest-asyncio and configure pytest (TASK-007-1)

**What to do**:
1. Add `pytest-asyncio>=0.24` to both `[project.optional-dependencies].dev` and `[dependency-groups].dev` in `pyproject.toml`.
2. Add `asyncio_mode = "auto"` to `[tool.pytest.ini_options]` in `pyproject.toml` so all `async def test_*` functions are automatically treated as async tests without needing `@pytest.mark.asyncio`.
3. Run `uv sync` to install the new dependency.
4. Create `tests/test_tui_integration.py` with:
   - A helper function `_make_project(root: Path, agents: dict[str, str] | None = None) -> None` that creates `.ievo/manifest.yaml`, `agents/<name>/EVOLUTION_LOG.md` for each agent.
   - A helper function `_make_app(project_root: Path | None) -> IevoApp` that patches `find_project_root` and returns a configured app.

**Tests to write** (all in `tests/test_tui_integration.py`):
- `test_focus_lands_on_content_tabs_not_selection_list`: Mount app with project, assert `type(app.focused).__name__ == "ContentTabs"` and `NOT SelectionList`. This is the **RED** test that documents Bug 1.
  - **Note**: This test intentionally fails when Bug 1 is fixed in Step 2. After Step 2, rename/update it to `test_focus_lands_on_selection_list` asserting the opposite.
- `test_tab_switch_agents_to_pipeline`: Press `p`, assert `TabbedContent.active == "pipeline"`.
- `test_tab_switch_pipeline_to_evolution`: Press `e`, assert active == `"evolution"`.
- `test_tab_switch_evolution_to_files`: Press `f`, assert active == `"files"`.
- `test_tab_switch_files_back_to_agents`: Press `a`, assert active == `"agents"`.
- `test_tab_focus_moves_to_selection_list`: Press `tab`, assert `app.focused` is SelectionList with id `agents-list`.

**Criteria covered**: AC "Tab key moves focus", AC "Pressing p/e/f/a switches tabs"
**Files**: modify `pyproject.toml`, create `tests/test_tui_integration.py`
**Commit**: `REQ-007: step 1 -- add pytest-asyncio, write diagnostic tests for focus bug and tab navigation`

### Step 2: Fix AUTO_FOCUS bug (TASK-007-2)

**What to do**:
1. In `src/ievo/tui/app.py`, add class attribute to `IevoApp`:
   ```python
   AUTO_FOCUS = "#agents-list"
   ```
2. Update the diagnostic test from Step 1: change `test_focus_lands_on_content_tabs_not_selection_list` to `test_focus_lands_on_selection_list` -- assert `app.focused` is `SelectionList` with id `agents-list`.
3. Verify all tab navigation tests still pass (they should -- tab switching is via App bindings which are resolved after focused widget).

**Tests to write**: 0 new, 1 updated.
**Criteria covered**: AC "On app mount, Agents tab visible and SelectionList rendered", AC "Tab key moves focus -- SelectionList is focusable"
**Files**: modify `src/ievo/tui/app.py`, modify `tests/test_tui_integration.py`
**Commit**: `REQ-007: step 2 -- fix AUTO_FOCUS to land on SelectionList`

### Step 3: SelectionList rendering and count test (TASK-007-3a)

**What to do**:
Write tests verifying the SelectionList is populated with all 12 bundled agents and shows correct initial selection state.

**Tests to write**:
- `test_selection_list_has_12_agents`: Mount with project, assert `selection_list.option_count == 12`.
- `test_selection_list_shows_installed_checked`: Mount with `agents: {"spec-writer": "0.1.0"}`, assert `"spec-writer" in selection_list.selected`.
- `test_selection_list_shows_uninstalled_unchecked`: Mount with `agents: {}`, assert `selection_list.selected` is empty list.

**Criteria covered**: AC "Agents tab visible and SelectionList rendered with 12 entries"
**Files**: modify `tests/test_tui_integration.py`
**Commit**: `REQ-007: step 3 -- integration tests for SelectionList population`

### Step 4: Agent toggle ON/OFF integration tests (TASK-007-3b)

**What to do**:
Write Pilot-based tests that focus SelectionList, navigate with arrows, and press Space to toggle agents. Verify real file system side effects.

**Tests to write**:
- `test_toggle_agent_on_creates_md_file`: Focus SelectionList (should be auto-focused after Bug 1 fix), press `down` to highlight first agent, press `space` to toggle ON. Assert:
  - Agent `.md` file exists at `root / ".claude" / "agents" / "<name>.md"`
  - Manifest updated: `ProjectManifest.load(root).has_agent("<name>")` is True
- `test_toggle_agent_off_removes_md_file`: Set up project with agent installed + `.claude/agents/<name>.md` file. Focus, navigate to installed agent, press `space` to toggle OFF. Assert:
  - Agent `.md` file deleted
  - Manifest updated: `has_agent()` is False
- `test_toggle_mandatory_agent_off_shows_warning`: Toggle OFF a mandatory agent (e.g., spec-writer). Use `notifications=True` in `run_test()`. Assert notification was posted. Check `app.query(".Toast")` or inspect app's notification list.

**Criteria covered**: AC "Space toggles ON -- file created, manifest updated", AC "Space toggles OFF -- file removed, manifest updated", AC "Mandatory agent OFF shows warning"
**Files**: modify `tests/test_tui_integration.py`
**Commit**: `REQ-007: step 4 -- integration tests for agent toggle ON/OFF`

### Step 5: Keyboard navigation integration test (TASK-007-3c)

**What to do**:
Write tests for Up/Down arrow navigation within SelectionList.

**Tests to write**:
- `test_arrow_keys_navigate_selection_list`: Focus SelectionList, press `down` 3 times, assert `selection_list.highlighted == 2` (0-indexed, third item). Press `up`, assert highlighted == 1.
- `test_pipeline_updates_after_toggle`: Toggle an agent ON, switch to Pipeline tab with `p`, verify pipeline-view Static content contains the agent name. Use `str(app.query_one("#pipeline-view", Static).render())`.

**Criteria covered**: AC "Up/Down arrows navigate between entries", AC "Pipeline tab content updates after agent toggle"
**Files**: modify `tests/test_tui_integration.py`
**Commit**: `REQ-007: step 5 -- integration tests for keyboard navigation and pipeline update`

### Step 6: Edge cases and lifecycle integration tests (TASK-007-4)

**What to do**:
Write tests for no-project state, quit, and refresh.

**Tests to write**:
- `test_no_project_shows_message`: Mount with `find_project_root` returning `None`. Assert `str(app.query_one("#project-info", Static).render())` contains "No project".
- `test_no_project_no_crash_on_interaction`: Mount with no project, press `p`, `e`, `f`, `a`, `tab`, `space`. No exception raised.
- `test_quit_action`: Press `q`. Assert `app.return_code is not None` or use `pilot.exit()` expectation. Note: `q` binding calls `action_quit` which exits the app. In `run_test`, this ends the context manager.
- `test_refresh_action_updates_project_root`: Mount with project A, then patch `find_project_root` to return `None`, press `r`. Assert `app.project_root is None` and project-info shows "No project".
- `test_compose_yields_all_widgets`: Mount app, query all expected widget IDs: `#logo`, `#project-info`, `#agents-list`, `#pipeline-view`, `#evo-log`, `#project-tree`, `#help-text`. All must exist.

**Criteria covered**: AC "No project shows message, no crash", AC "q exits cleanly", AC "r refreshes data"
**Files**: modify `tests/test_tui_integration.py`
**Commit**: `REQ-007: step 6 -- edge case and lifecycle integration tests`

## Traceability

| Acceptance Criterion | Covered in Step |
|---------------------|-----------------|
| On mount, Agents tab visible + SelectionList with 12 agents | Steps 2, 3 |
| Pressing `p` switches to Pipeline tab | Step 1 |
| Pressing `e` switches to Evolution tab | Step 1 |
| Pressing `f` switches to Files tab | Step 1 |
| Pressing `a` returns to Agents tab | Step 1 |
| Tab key moves focus; SelectionList focusable | Steps 1, 2 |
| Up/Down arrows navigate agent entries | Step 5 |
| Space toggles ON -- file created, manifest updated | Step 4 |
| Space toggles OFF -- file removed, manifest updated | Step 4 |
| Mandatory agent OFF shows warning | Step 4 |
| Pipeline updates after toggle | Step 5 |
| No project -- shows message, no crash | Step 6 |
| `q` exits cleanly | Step 6 |
| `r` refreshes data | Step 6 |

## Scope Guard

- **Considered but excluded**: Snapshot testing (too heavyweight for this REQ, good future addition)
- **Considered but excluded**: Fixing `highlighted = None` initial state (UX nit, not a bug, outside REQ-007 scope)
- **Considered but excluded**: Testing Docker sandbox integration (explicitly out of scope per REQ)
- **Considered but excluded**: Testing Claude auth flow (explicitly out of scope per REQ)
- **Considered but excluded**: Refactoring existing mock-based tests to Pilot (existing tests still provide value for method-level coverage; new file `test_tui_integration.py` complements them)

## Architecture Notes for Coder

### Testing patterns

- **New file**: `tests/test_tui_integration.py` -- do NOT merge into existing `tests/test_tui.py`. The existing file tests internal methods with mocks (still valid for coverage). The new file tests real widget interaction through the Pilot API.
- **Async tests**: With `asyncio_mode = "auto"` in pyproject.toml, all `async def test_*` functions automatically work. No `@pytest.mark.asyncio` decorator needed. No `asyncio.run()` wrapper needed.
- **Project setup helper**: Each test creates a `tmp_path`-based project. Use a module-level helper `_make_project(root, agents)` similar to the existing `_setup_project` in `test_tui.py`.
- **App creation pattern**:
  ```python
  async def test_example(tmp_path: Path):
      _make_project(tmp_path, agents={"spec-writer": "0.1.0"})
      app = IevoApp()
      with patch("ievo.tui.app.find_project_root", return_value=tmp_path):
          async with app.run_test(size=(120, 40)) as pilot:
              await pilot.pause()
              # ... assertions
  ```
  The `patch` must wrap `run_test` because `on_mount()` calls `find_project_root()`.

### Critical API details (Textual 8.0.0)

- **`run_test()` signature**: `run_test(headless=True, size=(80,24), tooltips=False, notifications=False, message_hook=None)`
- **`notifications=True`**: MUST be passed when testing toast notifications (mandatory agent warning).
- **Static widget content**: Use `str(widget.render())` to read content. `widget.renderable` does NOT exist in 8.0.0.
- **SelectionList**:
  - `selection_list.option_count` -- number of items
  - `selection_list.selected` -- list of values (str) that are checked
  - `selection_list.highlighted` -- index (int or None) of currently highlighted item
  - Bindings: `space` -> toggle, `up`/`down` -> navigate (inherited from OptionList)
- **Focus**: `app.focused` returns the currently focused widget. Check with `isinstance()` or `widget.id`.
- **After every `pilot.press()` or `pilot.click()`**: call `await pilot.pause()` to let Textual process events.

### The AUTO_FOCUS fix

- Add `AUTO_FOCUS = "#agents-list"` as a class attribute on `IevoApp`.
- This replaces the default `"*"` (first focusable widget) with an explicit CSS selector targeting the SelectionList.
- `"*"` was landing on `ContentTabs` (TabbedContent's internal tab strip), which is focusable but not the widget users need to interact with.
- After this fix, SelectionList receives focus on mount, and users can immediately press Up/Down/Space to navigate and toggle agents.

### Existing test compatibility

- The existing `test_compose_and_on_mount` in `test_tui.py` uses `asyncio.run()` wrapping. This will still work with `asyncio_mode = "auto"` (auto mode only affects `async def test_*` functions).
- The AUTO_FOCUS change should not break any existing mock-based tests because they don't run the full Textual lifecycle.

### Notification testing

- For the mandatory agent warning test, use `notifications=True` in `run_test()`.
- After toggling a mandatory agent OFF, check for the toast notification. Textual stores notifications internally. You can check `len(app._notifications)` or query for Toast widgets. The simplest approach: mock `app.notify` (acceptable here since we're testing the toggle handler logic, not the notification system itself) OR just verify the notification was received via `app._notifications` list.
- Alternative approach that stays pure-integration: after toggle, check that the app's notification list is non-empty and contains the expected message text.

## Tech Debt Created

- Two parallel test files for TUI: `test_tui.py` (mocks) and `test_tui_integration.py` (Pilot). This is intentional -- they serve different purposes. Future: consider migrating mock tests to Pilot if they become maintenance burden.
- The existing `test_compose_and_on_mount` in `test_tui.py` uses `asyncio.run()` pattern instead of native async. Not worth changing now but should be migrated if more async tests are added to that file.
