a73x

specs/011-dashboard-testing/plan.md

Ref:   Size: 7.9 KiB

# Implementation Plan: Dashboard Testing Infrastructure

**Branch**: `011-dashboard-testing` | **Date**: 2026-03-21 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/011-dashboard-testing/spec.md`

## Summary

Add automated testing infrastructure for the TUI dashboard. The App struct in `src/tui.rs` currently has private visibility and tightly couples state management with rendering, making it untestable from outside the module. This plan refactors App to be testable and adds three layers of tests: unit tests for state transitions, render tests using ratatui's TestBackend, and integration tests simulating key sequences.

## Technical Context

**Language/Version**: Rust 2021 edition
**Primary Dependencies**: ratatui 0.30 (includes `backend::TestBackend` for headless render testing), crossterm 0.29, git2 0.19
**Storage**: N/A (tests use ephemeral tempfile repos)
**Testing**: `cargo test`, `cargo clippy`
**Target Platform**: Linux (CI-compatible, headless)
**Project Type**: CLI tool with TUI
**Performance Goals**: All tests complete in under 5 seconds
**Constraints**: No real terminal required; tests must be deterministic and isolated
**Scale/Scope**: ~15-20 test functions across 3 test layers

## Constitution Check

No violations. This feature adds tests only -- no new runtime dependencies, no architectural changes beyond visibility adjustments.

## Project Structure

### Documentation (this feature)

```text
specs/010-dashboard-testing/
├── spec.md
├── plan.md
└── checklists/
    └── requirements.md
```

### Source Code (repository root)

```text
src/
├── tui.rs           # Refactored: make App, Tab, Pane, ViewMode pub(crate);
│                    #   extract handle_key() from run_loop() inline match
└── ...              # No other source changes

src/tui/
└── tests.rs         # In-module #[cfg(test)] tests (unit + render + integration)
                     # OR: tests added as #[cfg(test)] mod tests at bottom of tui.rs
```

**Structure Decision**: Tests will live inside `src/tui.rs` as a `#[cfg(test)] mod tests` block. This is the simplest approach because it gives tests direct access to private types (App, Tab, Pane, ViewMode) without requiring visibility changes. If the test module grows too large, it can later be extracted to `src/tui/tests.rs` by converting `tui.rs` to `tui/mod.rs`.

## Implementation Phases

### Phase 1: Refactor for testability (P1 prerequisite)

**Goal**: Make the App struct and key-handling logic testable without a terminal.

**Changes to `src/tui.rs`**:

1. Extract key-handling logic from the inline `match key.code` block in `run_loop()` into a standalone method `App::handle_key(&mut self, key: KeyCode, modifiers: KeyModifiers) -> bool` that returns `false` when the app should quit. This separates input processing from the event polling loop and terminal I/O.

2. No visibility changes needed since tests will be in-module (`#[cfg(test)] mod tests` inside `tui.rs`).

**Rationale**: The current `run_loop()` reads terminal events, handles keys, and renders -- all in one loop. Extracting `handle_key()` lets tests call it directly with synthetic key events, without needing crossterm's event system or a real terminal.

### Phase 2: Test helpers and fixtures

**Goal**: Create reusable test helper functions for constructing App instances with known data.

**Helper functions** (inside `#[cfg(test)] mod tests`):

- `make_test_issues(n: usize) -> Vec<IssueState>` -- creates `n` issues with predictable IDs, titles, and statuses (alternating open/closed)
- `make_test_patches(n: usize) -> Vec<PatchState>` -- creates `n` patches with predictable data
- `make_app(issues: usize, patches: usize) -> App` -- convenience wrapper
- `buffer_to_string(buf: &Buffer) -> String` -- extracts text content from a ratatui Buffer for assertions
- `assert_buffer_contains(buf: &Buffer, expected: &str)` -- asserts a string appears somewhere in the rendered buffer

**Data construction**: Test fixtures use `IssueState` and `PatchState` structs directly (from `crate::state`), populated with hardcoded values. No git repos needed for unit or render tests -- only integration tests that exercise `App::reload()` would need a repo, and those can use `tempfile::TempDir` + `git2::Repository::init()`.

### Phase 3: Unit tests for state transitions (P1)

**Goal**: Cover all state-mutating methods with fast, isolated unit tests.

**Tests** (each is a `#[test]` function):

| Test name | What it verifies |
|-----------|-----------------|
| `test_new_app_defaults` | Initial state: tab=Issues, selection=Some(0), scroll=0, pane=ItemList, mode=Details, show_all=false |
| `test_new_app_empty` | When created with empty issues/patches, selection=None |
| `test_move_selection_down` | move_selection(1) increments selected index |
| `test_move_selection_up` | move_selection(-1) decrements selected index |
| `test_move_selection_clamp_bottom` | move_selection(1) at last index stays at last index |
| `test_move_selection_clamp_top` | move_selection(-1) at index 0 stays at 0 |
| `test_move_selection_empty_list` | move_selection on empty list is a no-op |
| `test_switch_tab_issues_to_patches` | switch_tab resets selection, scroll, mode, pane |
| `test_switch_tab_same_tab_noop` | switch_tab to current tab is a no-op |
| `test_toggle_show_all` | Toggling show_all changes visible count and resets selection |
| `test_visible_issues_filters_closed` | visible_issues() excludes closed when show_all=false |
| `test_visible_patches_filters_closed` | visible_patches() excludes closed/merged when show_all=false |
| `test_handle_key_quit` | handle_key('q') returns false (quit signal) |
| `test_handle_key_tab_switch` | handle_key('1'/'2') switches tabs |
| `test_handle_key_diff_toggle_patches` | handle_key('d') on Patches tab toggles ViewMode |
| `test_handle_key_diff_noop_issues` | handle_key('d') on Issues tab does nothing |
| `test_handle_key_pane_toggle` | handle_key(Tab/Enter) toggles pane |
| `test_scroll_in_detail_pane` | j/k in Detail pane changes scroll, not selection |

### Phase 4: Render/snapshot tests (P2)

**Goal**: Verify TUI layout renders correctly for known states.

**Approach**: Use `ratatui::Terminal::new(TestBackend::new(width, height))` to create an in-memory terminal. Call `terminal.draw(|frame| ui(frame, &mut app))` and inspect `terminal.backend().buffer()`.

**Tests**:

| Test name | What it verifies |
|-----------|-----------------|
| `test_render_issues_tab` | Tab bar shows "1:Issues", list shows issue titles, detail shows selected issue info |
| `test_render_patches_tab` | Tab bar highlights "2:Patches", list shows patch titles |
| `test_render_empty_state` | Detail pane shows "No issues to display." |
| `test_render_footer_keys` | Footer contains key hints (j/k, Tab, q, etc.) |
| `test_render_small_terminal` | Rendering to a 20x10 backend does not panic |

### Phase 5: Integration tests with key sequences (P3)

**Goal**: Verify that realistic multi-step user interactions produce correct final state.

**Tests**:

| Test name | What it verifies |
|-----------|-----------------|
| `test_navigate_to_patch_and_view_diff` | Key sequence: j, j, 2, j, d -> Patches tab, idx 1, Diff mode |
| `test_toggle_filter_and_navigate` | Key sequence: a, j, j -> show_all=true, sees closed items, selection at 2 |
| `test_pane_switching_scroll_isolation` | Tab into detail, scroll down, Tab back -> selection unchanged |

### Phase 6: CI considerations

- All tests use `#[cfg(test)]` and run with `cargo test` -- no special CI setup needed
- `TestBackend` requires no terminal, so tests work in headless CI environments
- No new dev-dependencies needed (`tempfile` is already in Cargo.toml dev-dependencies)
- ratatui 0.30's `TestBackend` is included in the default ratatui crate (no feature flags needed)

## Complexity Tracking

No constitution violations. This feature adds only test code and a minor refactor (extracting `handle_key()`). No new dependencies, no new modules, no architectural changes.