<!-- STATUS: COMPLETE -->

# Phase 16: UA Testing Guide

## Quick Smoke Test (2-5 minutes)

### 1. Seed test campaign

```bash
python3 _planning/fixtures/seed_test_campaign.py
```

Expected output:
```
Test campaign created at: /tmp/unfamiliar_test_XXXXXX
Campaign ID: 1

To use with TUI, pass this path as campaign_dir and campaign_id=1
```

### 2. Verify theme loading

```bash
python3 -c "from unfamiliar.tui.themes import get_theme; t = get_theme('film_noir'); print(t.aliases)"
```

Expected: Dict with 10 command aliases

### 3. Verify view data imports

```bash
python3 -c "from unfamiliar.tui.views import HeaderData, StatusData, MissionDetail; print('OK')"
```

Expected: `OK`

### 4. Run existing tests

```bash
python3 -m pytest tests/ -q --tb=short
```

Expected: 903 tests pass

---

## Priority 1: Core TUI Functionality (15-20 minutes)

### Test 1: Launch TUI with seeded campaign

**Setup:** Run seed script and capture the campaign path.

```bash
python3 -c "
from pathlib import Path
import tempfile
import sys
sys.path.insert(0, 'src')

from unfamiliar.tui.app import run_tui

# Use the seeded campaign path from step 1
campaign_dir = Path('/tmp/unfamiliar_test_XXXXXX')  # Replace with actual path
run_tui(campaign_dir, campaign_id=1)
"
```

**Expected:**
- TUI launches with Film Noir styling (dark background, amber accents)
- Header shows campaign name and progress stats
- Command reference panel on right shows all 10 commands
- Command input at bottom with placeholder text

### Test 2: Command routing — themed aliases

**In the TUI, type these commands:**

| Input | Expected Result |
|-------|-----------------|
| `investigate` | Shows "Available Leads" list with missions |
| `case board` | Shows "Case Board" with progress stats |
| `case file` | Shows "Case File" with briefing info |
| `call in a marker` | Shows "Commands Reference" help |
| `help` | Same as above (canonical also works) |

### Test 3: Command routing — with arguments

| Input | Expected Result |
|-------|-----------------|
| `break in 5` | Attempts to unlock mission 5 (shows result or error) |
| `unlock 99` | Shows error "Failed to unlock" (mission 99 doesn't exist) |

### Test 4: Command routing — unknown commands

| Input | Expected Result |
|-------|-----------------|
| `foobar` | Shows "Unknown command: 'foobar'" message |
| `` (empty, just Enter) | Shows "Unknown command: ''" message |

### Test 5: Content rendering

**Navigate through content types:**

1. `investigate` → Mission list rendering
   - Should show numbered list with titles
   - Each mission shows category, difficulty, time estimate
   - Security-tagged missions show [SECURITY]

2. `case board` → Status view rendering
   - Shows overall progress percentage
   - Shows lead status breakdown (available/in progress/locked/completed)
   - Shows category progress with ASCII bar graphs
   - Shows case threads (chains) with checkmarks

3. `case file` → Briefing view rendering
   - Shows Intelligence Summary
   - Shows Technology Stack list
   - Shows Security Assessment

### Test 6: Key bindings

| Key | Expected Behavior |
|-----|-------------------|
| `?` | Shows help (same as `help` command) |
| `q` | Exits the TUI |
| `Escape` | No-op (cancel action, currently does nothing) |

### Test 7: Error handling

| Action | Expected Result |
|--------|-----------------|
| `mission` (when no mission selected) | Shows "No active lead" error |
| `complete` (when no mission in progress) | Shows "No mission in progress" error |
| `unlock` (no argument) | Shows usage help |

---

## Priority 2: Edge Cases (if time permits)

### Test 8: Mission selection flow

1. `investigate` to see available missions
2. Note a mission number from the list
3. `mission` should still show "No active lead" (selection not implemented yet)

**Note:** Mission selection by number is Phase 17 work.

### Test 9: Database connection handling

- Exit TUI with `q`
- Relaunch with same campaign path
- Should show same data (database persists)

### Test 10: Theme error handling

```bash
python3 -c "from unfamiliar.tui.themes import get_theme; get_theme('nonexistent')"
```

Expected: `ValueError: Unknown theme: 'nonexistent'. Available: film_noir`

---

## Issues Found During UA Testing

### Issue 1: CSS Variable Resolution in Widget DEFAULT_CSS
**Error:** `reference to undefined variable '$noir-bg-darker'`

**Root Cause:** Widget classes defined DEFAULT_CSS using `$noir-*` CSS variables that only exist in the noir.tcss file. Textual parses DEFAULT_CSS before the theme's TCSS is loaded.

**Fix Applied:** Removed DEFAULT_CSS from all widgets (InvestigationHeader, CommandReference, ContentArea, CommandInput) since noir.tcss already contains complete styling.

**Files Modified:**
- `tui/widgets/header.py` — removed DEFAULT_CSS block
- `tui/widgets/command_ref.py` — removed DEFAULT_CSS block
- `tui/widgets/content.py` — removed DEFAULT_CSS block
- `tui/widgets/command_input.py` — removed DEFAULT_CSS block

---

### Issue 2: Textual Widget Lifecycle — on_mount Timing
**Error:** `NoMatches: No nodes match '#header-campaign' on InvestigationHeader`

**Root Cause:** Parent widget's `on_mount()` fires before children are mounted. Calling `query_one()` for child widgets in `on_mount()` fails because they don't exist yet.

**Fix Applied:** Changed method calls to use `call_after_refresh()` which defers execution until after children are mounted.

**Files Modified:**
- `tui/widgets/header.py` — `on_mount()` and `watch_data()` now use `self.call_after_refresh(self._update_children)`
- `tui/widgets/command_ref.py` — same pattern applied

---

### Issue 3: Rich Style String Syntax — FIXED
**Error:**
```
MissingStyle: Failed to get style "bold color('#c9a227')"; unable to parse "color('#c9a227')" as color
```

**Root Cause:** Rich's `Text.append()` style strings don't support `color('#hex')` syntax. The correct format is `"bold #c9a227"` (just the hex color without the `color()` wrapper).

**Fix Applied:** Changed all `style="color('#hex')"` to `style="#hex"` across all widgets.

**Files Modified:**
- `tui/widgets/content.py` — all `show_*` methods
- `tui/widgets/header.py` — `_update_children()` method
- `tui/widgets/command_ref.py` — `_update_children()` method

---

### Issue 4: Widget Method Name Collision with Textual Internals — FIXED
**Error:**
```
AttributeError: 'NoneType' object has no attribute 'render_strips'
```

**Root Cause:** Naming a widget method `_render()` conflicts with Textual's internal `Widget._render()` method which returns a `Visual` object. Our `_render()` returned `None`, breaking the rendering pipeline.

**Fix Applied:** Renamed `_render()` to `_update_children()` in all widgets to avoid name collision.

**Files Modified:**
- `tui/widgets/header.py` — renamed `_render()` → `_update_children()`
- `tui/widgets/command_ref.py` — renamed `_render()` → `_update_children()`

---

### Issue 5: CSS_PATH Set Too Late in App Lifecycle — FIXED
**Error:** Command reference panel not visible on right side of layout.

**Root Cause:** `CSS_PATH` was being set in `on_mount()`, but Textual loads CSS before `compose()` runs. Setting `CSS_PATH` in `on_mount()` is too late — the layout has already been computed without the stylesheet.

**Fix Applied:** Moved theme loading and `CSS_PATH` assignment to `__init__()`, before `super().__init__()` is called.

**Original Code (broken):**
```python
def __init__(self, ...):
    super().__init__()
    self.theme_name = theme_name
    # ... other setup

def on_mount(self) -> None:
    self.theme_config = get_theme(self.theme_name)
    self.CSS_PATH = self.theme_config.css_path  # TOO LATE!
```

**Fixed Code:**
```python
def __init__(self, ...):
    # Load theme BEFORE super().__init__() so CSS_PATH is set before compose()
    self.theme_config = get_theme(theme_name)
    self.CSS_PATH = self.theme_config.css_path
    self.router = CommandRouter(self.theme_config)

    super().__init__()
    # ... other setup
```

**Files Modified:**
- `tui/app.py` — moved theme loading from `on_mount()` to `__init__()`

---

### Issue 6: CSS align Property Requires Two Values — FIXED
**Error:**
```
Invalid value for align property
The align property expects exactly 2 values
    e.g. align: <horizontal> <vertical>
```

**Root Cause:** Textual's `align` property requires both horizontal and vertical values (e.g., `center middle`), not just one.

**Fix Applied:** Changed `align: center;` to `align: center middle;`

**Files Modified:**
- `tui/themes/film_noir/noir.tcss` — line 247, `.dialog-actions` selector

---

### Issue 7: Layout Container CSS Missing — FIXED
**Error:** Command reference panel still not visible after fixing CSS_PATH timing.

**Root Cause:** The layout containers (`#main-container`, `#content-row`, `#main-content`) had no CSS rules defining their dimensions. Without explicit `height: 1fr` and `width: 1fr` rules, the flexible layout wasn't working.

**Fix Applied:** Added CSS rules for layout containers with proper flex and overflow settings.

**Added CSS:**
```css
/* --- Layout Containers --- */
#main-container {
    height: 1fr;
    overflow: hidden;
}

#content-row {
    height: 1fr;
    overflow: hidden;
}

#main-content {
    width: 1fr;
    height: 1fr;
}

#content-scroll {
    height: 1fr;
}

ContentArea {
    height: 1fr;
}
```

**Files Modified:**
- `tui/themes/film_noir/noir.tcss` — added Layout Containers section with explicit flex sizing

---

### Issue 8: Vertical Layout Overflow — CommandInput Pushed Off Screen — FIXED
**Error:** Command input bar at bottom was pushed outside the visible frame.

**Root Cause:** Without `overflow: hidden` on container elements, the flexible content area could expand beyond its allocated space, pushing the bottom element out of view.

**Fix Applied:** Added `overflow: hidden` to `#main-container` and `#content-row` to constrain them to their flex-allocated space.

**Files Modified:**
- `tui/themes/film_noir/noir.tcss` — same as Issue 7 (overflow: hidden added)

---

## Current Status

**All 8 issues resolved.** TUI now launches successfully with:
- Header showing campaign name, repo, and progress
- Command reference panel on right side (32-char fixed width)
- Content area on left (flexible width)
- Command input at bottom (fixed height)
- Proper vertical layout (no overflow)
- All CSS loaded before layout composition

---

## Interface Contract Updates

The following contract changes were discovered during UA testing and have been applied to the codebase. These supersede the original contracts in `plan.md`.

### Contract Change 1: Widget CSS Architecture

**Original Contract:**
```python
class InvestigationHeader(Widget):
    DEFAULT_CSS = """
    InvestigationHeader {
        background: $noir-bg-darker;
        ...
    }
    """
```

**Updated Contract:**
```python
class InvestigationHeader(Widget):
    """Note: Styling is defined in noir.tcss, not DEFAULT_CSS, to use theme variables."""
    # No DEFAULT_CSS defined
```

**Rationale:** Textual parses `DEFAULT_CSS` before theme TCSS files are loaded. CSS variables like `$noir-bg-darker` only exist in `noir.tcss`, causing "undefined variable" errors. All styling must be in the theme's TCSS file, not in widget class definitions.

**Applies to:**
- `InvestigationHeader`
- `CommandReference`
- `ContentArea`
- `CommandInput`

---

### Contract Change 2: Widget Lifecycle — Deferred Child Access

**Original Contract:**
```python
def on_mount(self) -> None:
    """Render initial data on mount."""
    self._update_children()  # Direct call
```

**Updated Contract:**
```python
def on_mount(self) -> None:
    """Render initial data on mount."""
    # Defer until children are mounted (parent on_mount fires before children exist)
    self.call_after_refresh(self._update_children)
```

**Rationale:** In Textual's widget lifecycle, the parent's `on_mount()` fires *before* child widgets are mounted. Calling `query_one("#child-id")` in `on_mount()` fails with `NoMatches`. Using `call_after_refresh()` defers execution until the next refresh cycle, when children exist.

**Pattern to use:**
- `on_mount()` → `self.call_after_refresh(self._update_children)`
- `watch_<reactive>()` → `self.call_after_refresh(self._update_children)`

---

### Contract Change 3: Rich Style String Syntax

**Original Contract:**
```python
text.append("Title", style="bold color('#c9a227')")
```

**Updated Contract:**
```python
text.append("Title", style="bold #c9a227")
```

**Rationale:** Rich's `Text.append()` style strings use a space-separated format where colors are specified as bare hex values (`#c9a227`), not wrapped in `color()`. The `color('#hex')` syntax is only valid in Rich *markup* (e.g., `Text.from_markup("[color('#c9a227')]text[/]")`), not in style strings passed to `append()`.

**Correct style string formats:**
- `"bold #c9a227"` — bold with gold color
- `"italic #8a8678"` — italic with muted color
- `"dim #5a574d"` — dim with dim color
- `"bold"` — just bold, no color override

---

### Contract Change 4: Widget Method Naming — Avoid Textual Internals

**Original Contract:**
```python
class InvestigationHeader(Widget):
    def _render(self) -> None:
        """Update child widgets with current data."""
        ...
```

**Updated Contract:**
```python
class InvestigationHeader(Widget):
    def _update_children(self) -> None:
        """Update child widgets with current data."""
        ...
```

**Rationale:** Textual's `Widget` class has an internal `_render()` method that returns a `Visual` object. Defining our own `_render()` that returns `None` breaks the rendering pipeline with `AttributeError: 'NoneType' object has no attribute 'render_strips'`. All custom widget update methods must use names that don't collide with Textual internals.

**Reserved method names to avoid:**
- `_render()` — returns Visual for rendering
- `render()` — public render method
- `render_line()` — renders a single line
- `_compose()` — internal compose implementation

**Safe naming patterns:**
- `_update_children()` — updates child widget content
- `_refresh_display()` — refreshes display data
- `_sync_from_data()` — syncs widget state from data

---

### Contract Change 5: App CSS_PATH Must Be Set Before super().__init__()

**Original Contract:**
```python
class UnfamiliarApp(App):
    CSS_PATH = None  # Will be set dynamically from theme

    def __init__(self, ...):
        super().__init__()
        self.theme_name = theme_name

    def on_mount(self) -> None:
        self.theme_config = get_theme(self.theme_name)
        self.CSS_PATH = self.theme_config.css_path
```

**Updated Contract:**
```python
class UnfamiliarApp(App):
    def __init__(self, ...):
        # Load theme BEFORE super().__init__() so CSS_PATH is set before compose()
        self.theme_config = get_theme(theme_name)
        self.CSS_PATH = self.theme_config.css_path
        self.router = CommandRouter(self.theme_config)

        super().__init__()
```

**Rationale:** Textual loads CSS during app initialization, before `compose()` is called. Setting `CSS_PATH` in `on_mount()` is too late — the layout has already been computed without any CSS rules. The CSS path must be set before `super().__init__()` is called.

**Pattern for dynamic CSS loading:**
- Load theme config in `__init__` before `super().__init__()`
- Set `CSS_PATH` immediately after loading theme
- Only database connections and data loading belong in `on_mount()`

---

### Contract Change 6: Layout Containers Require Explicit Flex Sizing

**Original Contract:**
```css
/* No layout container CSS — relying on default behavior */
ContentArea {
    background: $noir-bg;
    padding: 1 2;
}
```

**Updated Contract:**
```css
/* Layout Containers — required for proper flex layout */
#main-container {
    height: 1fr;
    overflow: hidden;
}

#content-row {
    height: 1fr;
    overflow: hidden;
}

#main-content {
    width: 1fr;
    height: 1fr;
}

#content-scroll {
    height: 1fr;
}

ContentArea {
    background: $noir-bg;
    padding: 1 2;
    height: 1fr;
}
```

**Rationale:** Textual's flex layout requires explicit `height: 1fr` and `width: 1fr` rules on container elements to participate in flexible sizing. Without these:
- Fixed-size elements (header, footer, sidebar) render correctly
- Flexible elements collapse or overflow
- `overflow: hidden` prevents flexible containers from expanding beyond their allocated space

**Required CSS for horizontal split (sidebar + content):**
- Container: `height: 1fr; overflow: hidden;`
- Flexible child: `width: 1fr;`
- Fixed child: `width: <fixed-value>;`

---

### Contract Change 7: CSS align Property Syntax

**Original Contract:**
```css
.dialog-actions {
    align: center;
}
```

**Updated Contract:**
```css
.dialog-actions {
    align: center middle;
}
```

**Rationale:** Textual's `align` property requires exactly 2 values: horizontal (`left`, `center`, `right`) and vertical (`top`, `middle`, `bottom`). Single-value shorthand is not supported.

---

## Files Modified During UA Testing

| File | Changes |
|------|---------|
| `tui/widgets/header.py` | Removed DEFAULT_CSS; renamed `_render()` → `_update_children()`; added `call_after_refresh()` |
| `tui/widgets/command_ref.py` | Removed DEFAULT_CSS; renamed `_render()` → `_update_children()`; added `call_after_refresh()` |
| `tui/widgets/content.py` | Removed DEFAULT_CSS; fixed all Rich style strings from `color('#hex')` → `#hex` |
| `tui/widgets/command_input.py` | Removed DEFAULT_CSS |
| `tui/app.py` | Moved theme loading from `on_mount()` to `__init__()` (CSS_PATH must be set before `super().__init__()`) |
| `tui/themes/film_noir/noir.tcss` | Added Layout Containers section with flex sizing; fixed `align: center` → `align: center middle`; added `overflow: hidden` to containers |

---

## Possibly Obsolete Tests

None identified. All TUI code is new.

---

## Coverage Gaps

- No automated tests for TUI widgets (Textual pilot tests would require additional setup)
- No tests for Rich text rendering in ContentArea
- No tests for multi-word alias parsing edge cases

---

## User Testing Notes

### Issues Found
1. CSS variable resolution error — FIXED (removed DEFAULT_CSS, using noir.tcss)
2. Widget lifecycle timing error — FIXED (call_after_refresh)
3. Rich style string syntax error — FIXED (changed `color('#hex')` → `#hex`)
4. Widget method name collision — FIXED (renamed `_render()` → `_update_children()`)
5. CSS_PATH set too late (in on_mount) — FIXED (moved to __init__ before super().__init__())
6. CSS align property syntax error — FIXED (align: center → align: center middle)
7. Layout container CSS missing — FIXED (added flex sizing for #main-container, #content-row, #main-content, #content-scroll)
8. Vertical layout overflow — FIXED (added overflow: hidden to containers)

### Error Messages / Logs
All errors resolved. See Issues sections above for details.

### Questions
(None)

### General Feedback
TUI launches successfully with all layout elements visible:
- Header at top
- Command reference panel on right
- Content area on left
- Command input at bottom

### Result
- [x] All good
- [ ] Issues found (list above)
