# Textual TUI Testing & Validation Skill

Comprehensive testing and validation procedures for Styrene TUI development using the Textual framework.

## Skill Purpose

This skill provides structured testing workflows for:
- Screen and widget functionality
- User interactions and navigation
- Layout and styling validation
- Integration testing
- Regression detection

## When to Use This Skill

- After implementing new screens or widgets
- When modifying existing UI components
- Before committing UI changes
- When investigating UI bugs
- During refactoring of TUI code

## Testing Philosophy

**Follow TDD for TUI development:**
1. Write tests that define expected behavior
2. Implement the feature
3. Validate with both automated and manual tests
4. Document any visual/interactive aspects that need human QA

## Testing Toolkit

### Core Tools

| Tool | Purpose | Usage |
|------|---------|-------|
| `pytest` | Unit and integration testing | `pytest tests/` |
| `pytest-asyncio` | Async test support | Automatic with `@pytest.mark.asyncio` |
| `textual run --dev` | Hot-reload development | Live testing during development |
| `Pilot` | Textual test driver | Simulates user interactions |
| `run_test()` | App test context | Async context manager for tests |
| `tmux` | Visual testing harness | Launch TUI, capture output, send keys |
| `termshot` | ANSI → PNG conversion | Optional: `brew install termshot` |
| `pytest-textual-snapshot` | SVG snapshot testing | Optional: in dev dependencies |

### Test Helpers

```python
from textual.pilot import Pilot
from styrene.app import StyreneApp

# Standard test pattern
@pytest.mark.asyncio
async def test_screen_behavior():
    app = StyreneApp()
    async with app.run_test() as pilot:
        # Test code here
        assert pilot.app.screen is not None
```

## Test Categories

### 1. Screen Tests

**Purpose**: Verify screen composition, mounting, and navigation.

**Location**: `tests/test_screens.py`

**Patterns**:

```python
@pytest.mark.asyncio
async def test_screen_mounts():
    """Verify screen mounts without errors."""
    app = StyreneApp()
    async with app.run_test() as pilot:
        # Push screen
        await pilot.app.push_screen(DashboardScreen())

        # Verify screen is active
        assert isinstance(pilot.app.screen, DashboardScreen)

        # Verify required widgets exist
        assert pilot.app.screen.query_one("#device-table")

@pytest.mark.asyncio
async def test_screen_navigation():
    """Test navigation between screens."""
    app = StyreneApp()
    async with app.run_test() as pilot:
        # Simulate key press
        await pilot.press("p")  # Provision screen binding

        # Verify screen changed
        assert isinstance(pilot.app.screen, ProvisionScreen)
```

### 2. Widget Tests

**Purpose**: Test custom widget behavior in isolation.

**Location**: `tests/widgets/test_<widget_name>.py`

**Patterns**:

```python
@pytest.mark.asyncio
async def test_widget_updates():
    """Test widget reacts to data changes."""
    from textual.app import App

    class TestApp(App):
        def compose(self):
            yield ReticulumPanel()

    app = TestApp()
    async with app.run_test() as pilot:
        panel = app.query_one(ReticulumPanel)

        # Trigger refresh
        panel.refresh_data()

        # Verify display updated
        identity_display = panel.query_one("#reticulum-identity")
        assert identity_display.identity_hash is not None
```

### 3. Interaction Tests

**Purpose**: Test user interactions like button clicks, input, navigation.

**Location**: `tests/test_interactions.py`

**Patterns**:

```python
@pytest.mark.asyncio
async def test_button_click():
    """Test button click triggers expected action."""
    app = StyreneApp()
    async with app.run_test() as pilot:
        # Click button
        await pilot.click("#save-btn")

        # Verify action occurred
        # (check state change, screen navigation, etc.)

@pytest.mark.asyncio
async def test_input_validation():
    """Test input field validation."""
    app = StyreneApp()
    async with app.run_test() as pilot:
        input_widget = pilot.app.query_one("#hostname", Input)

        # Set invalid value
        input_widget.value = ""
        await pilot.pause()

        # Verify validation
        assert input_widget.has_class("-invalid")
```

### 4. Layout Tests

**Purpose**: Verify CSS and layout behave correctly.

**Location**: `tests/test_layouts.py`

**Patterns**:

```python
@pytest.mark.asyncio
async def test_responsive_layout():
    """Test layout adapts to screen size."""
    app = StyreneApp()
    async with app.run_test(size=(80, 24)) as pilot:
        # Verify layout at specific size
        container = pilot.app.query_one("#wizard-content")
        assert container.size.width <= 80

def test_css_classes_defined():
    """Verify required CSS classes exist in stylesheet."""
    from pathlib import Path

    css_file = Path("src/styrene/styles/imperial_crt.tcss")
    content = css_file.read_text()

    # Check for required classes
    assert ".wizard-title" in content
    assert "#wizard-content" in content
```

### 5. Integration Tests

**Purpose**: Test complete workflows across multiple screens.

**Location**: `tests/test_integration.py`

**Patterns**:

```python
@pytest.mark.asyncio
async def test_provision_workflow():
    """Test complete device provisioning workflow."""
    app = StyreneApp()
    async with app.run_test() as pilot:
        # Navigate to provision screen
        await pilot.press("p")

        # Select device
        # ... fill form ...
        # Click provision

        # Verify completion
        # Check dashboard shows new device
```

## Test Execution

### Run All Tests

```bash
pytest tests/ -v
```

### Run Specific Test Files

```bash
# Screen tests only
pytest tests/test_screens.py -v

# Widget tests only
pytest tests/widgets/ -v

# Integration tests only
pytest tests/test_integration.py -v
```

### Run Tests Matching Pattern

```bash
# All wizard tests
pytest tests/ -k wizard -v

# All async tests
pytest tests/ -k asyncio -v
```

### Run with Coverage

```bash
pytest tests/ --cov=src/styrene --cov-report=html
```

### Run Failed Tests Only

```bash
# Rerun only tests that failed last time
pytest --lf -v
```

## Test Development Workflow

### 1. Feature Planning Phase

Before writing code:

```python
# tests/test_new_feature.py
@pytest.mark.asyncio
async def test_new_feature_basic():
    """Test new feature works as expected."""
    pytest.skip("Not implemented yet")
    # Write test definition
    # This documents expected behavior
```

### 2. Implementation Phase

Write the feature to make tests pass:

```bash
# Run in watch mode during development
pytest tests/test_new_feature.py --watch
```

### 3. Validation Phase

After implementation:

```bash
# Run full test suite
pytest tests/ -v

# Run validation checks
ruff check src/
mypy src/
```

### 4. Interactive Testing

Launch TUI for manual validation:

```bash
# Development mode with hot reload
textual run --dev src/styrene/app.py

# Normal mode
python -m styrene
```

## Manual Testing Checklist

After automated tests pass, perform manual validation:

### Visual Validation

- [ ] Layout renders correctly
- [ ] Colors match Imperial CRT theme
- [ ] Text is readable
- [ ] Borders and spacing look correct
- [ ] No visual glitches or overlaps

### Interaction Validation

- [ ] All keybindings work
- [ ] Mouse clicks register correctly
- [ ] Input fields accept text
- [ ] Buttons respond to clicks
- [ ] Navigation flows smoothly

### Edge Cases

- [ ] Test with small terminal size (80x24)
- [ ] Test with large terminal size (200x60)
- [ ] Test with no data
- [ ] Test with maximum data
- [ ] Test error conditions

### Accessibility

- [ ] Tab navigation works
- [ ] Focus indicators are visible
- [ ] Keyboard-only operation possible
- [ ] Screen reader friendly (where applicable)

## Debugging Tests

### View App Output

```python
@pytest.mark.asyncio
async def test_with_debugging():
    app = StyreneApp()
    async with app.run_test() as pilot:
        # Print app tree
        print(pilot.app.query("*").results())

        # Print specific widget
        widget = pilot.app.query_one("#my-widget")
        print(f"Widget: {widget}")
        print(f"Classes: {widget.classes}")
        print(f"Styles: {widget.styles}")
```

### Pause Test Execution

```python
@pytest.mark.asyncio
async def test_with_pause():
    app = StyreneApp()
    async with app.run_test() as pilot:
        # Pause to inspect state
        await pilot.pause()

        # Continue after inspection
        assert True
```

### Screenshot Tests

```python
@pytest.mark.asyncio
async def test_with_screenshot():
    app = StyreneApp()
    async with app.run_test() as pilot:
        # Take screenshot (requires textual-dev)
        screenshot = pilot.app.export_screenshot()

        # Save for comparison
        Path("screenshots/test_screen.svg").write_text(screenshot)
```

## Visual Testing with tmux

tmux enables visual verification of the rendered TUI by launching it in a controlled terminal environment, capturing the actual ANSI output, and optionally converting to PNG for image-based analysis.

### Why tmux?

| Capability | Benefit |
|------------|---------|
| Fixed terminal size | Reproducible layouts (e.g., 80x24) |
| Detached sessions | Run TUI without interactive terminal |
| ANSI capture | Get actual rendered output with colors |
| Key injection | Simulate user input programmatically |
| Session isolation | Tests don't interfere with each other |

### Session Management

```bash
# Create a test session with fixed dimensions
tmux new-session -d -s styrene_test -x 80 -y 24

# List sessions
tmux list-sessions

# Kill session when done
tmux kill-session -t styrene_test
```

### Launching the TUI

```bash
# Start the app in the test session
tmux send-keys -t styrene_test 'cd /path/to/styrene-tui && python -m styrene' Enter

# Wait for render (adjust based on app startup time)
sleep 1
```

### Capturing Output

```bash
# Capture with ANSI escape codes (colors, styles)
tmux capture-pane -t styrene_test -e -p > /tmp/styrene_capture.txt

# Capture plain text only (no ANSI)
tmux capture-pane -t styrene_test -p > /tmp/styrene_plain.txt

# Capture specific line range
tmux capture-pane -t styrene_test -e -p -S 0 -E 23 > /tmp/styrene_capture.txt
```

### Sending Keys

```bash
# Send single key
tmux send-keys -t styrene_test 'p'

# Send special keys
tmux send-keys -t styrene_test Escape
tmux send-keys -t styrene_test Enter
tmux send-keys -t styrene_test Tab

# Send key combination
tmux send-keys -t styrene_test C-c  # Ctrl+C

# Send text input
tmux send-keys -t styrene_test 'hostname-01' Enter
```

### Complete Visual Test Workflow

```bash
#!/bin/bash
# scripts/visual_test.sh

SESSION="styrene_visual_test"
CAPTURE_DIR="/tmp/styrene_captures"
mkdir -p "$CAPTURE_DIR"

# Cleanup any existing session
tmux kill-session -t "$SESSION" 2>/dev/null

# Create fresh session
tmux new-session -d -s "$SESSION" -x 80 -y 24

# Launch app
tmux send-keys -t "$SESSION" 'cd /Users/cwilson/workspace/vanderlyn/projects/styrene-tui && source .venv/bin/activate && python -m styrene' Enter
sleep 2

# Capture dashboard
tmux capture-pane -t "$SESSION" -e -p > "$CAPTURE_DIR/01_dashboard.txt"

# Navigate to provision screen
tmux send-keys -t "$SESSION" 'p'
sleep 0.5
tmux capture-pane -t "$SESSION" -e -p > "$CAPTURE_DIR/02_provision.txt"

# Navigate back
tmux send-keys -t "$SESSION" Escape
sleep 0.5
tmux capture-pane -t "$SESSION" -e -p > "$CAPTURE_DIR/03_back_to_dashboard.txt"

# Cleanup
tmux send-keys -t "$SESSION" 'q'
sleep 0.5
tmux kill-session -t "$SESSION"

echo "Captures saved to $CAPTURE_DIR"
ls -la "$CAPTURE_DIR"
```

### Converting to PNG with termshot

If `termshot` is installed (`brew install termshot`), convert ANSI captures to PNG:

```bash
# Convert capture to PNG
termshot --filename /tmp/dashboard.png -- cat /tmp/styrene_captures/01_dashboard.txt

# Or capture and convert in one step
tmux capture-pane -t styrene_test -e -p | termshot --filename /tmp/live.png -- cat
```

### Automated Visual Test Pattern

```python
# tests/test_visual.py
import subprocess
import time
from pathlib import Path

import pytest

CAPTURE_DIR = Path("/tmp/styrene_visual_tests")


class TmuxTestHarness:
    """Harness for visual TUI testing via tmux."""

    def __init__(self, session_name: str = "styrene_test", width: int = 80, height: int = 24):
        self.session = session_name
        self.width = width
        self.height = height

    def start(self):
        """Create tmux session and launch app."""
        # Kill existing session
        subprocess.run(["tmux", "kill-session", "-t", self.session], capture_output=True)

        # Create new session
        subprocess.run([
            "tmux", "new-session", "-d",
            "-s", self.session,
            "-x", str(self.width),
            "-y", str(self.height)
        ], check=True)

        # Launch app
        subprocess.run([
            "tmux", "send-keys", "-t", self.session,
            "cd /Users/cwilson/workspace/vanderlyn/projects/styrene-tui && "
            "source .venv/bin/activate && python -m styrene",
            "Enter"
        ], check=True)

        time.sleep(2)  # Wait for app to render

    def stop(self):
        """Quit app and kill session."""
        subprocess.run(["tmux", "send-keys", "-t", self.session, "q"])
        time.sleep(0.5)
        subprocess.run(["tmux", "kill-session", "-t", self.session], capture_output=True)

    def send_keys(self, keys: str):
        """Send keys to the TUI."""
        subprocess.run(["tmux", "send-keys", "-t", self.session, keys], check=True)
        time.sleep(0.3)  # Brief pause for render

    def capture(self, with_ansi: bool = True) -> str:
        """Capture current pane content."""
        args = ["tmux", "capture-pane", "-t", self.session, "-p"]
        if with_ansi:
            args.insert(3, "-e")
        result = subprocess.run(args, capture_output=True, text=True, check=True)
        return result.stdout

    def save_capture(self, name: str, with_ansi: bool = True) -> Path:
        """Capture and save to file."""
        CAPTURE_DIR.mkdir(exist_ok=True)
        content = self.capture(with_ansi=with_ansi)
        path = CAPTURE_DIR / f"{name}.txt"
        path.write_text(content)
        return path


@pytest.fixture
def tmux_harness():
    """Provide tmux test harness."""
    harness = TmuxTestHarness()
    harness.start()
    yield harness
    harness.stop()


class TestVisualRendering:
    """Visual rendering tests using tmux."""

    def test_dashboard_renders(self, tmux_harness):
        """Verify dashboard renders expected content."""
        capture = tmux_harness.capture(with_ansi=False)

        # Check for expected text content
        assert "STYRENE" in capture or "Fleet" in capture
        assert "Device" in capture or "Status" in capture

    def test_navigation_to_provision(self, tmux_harness):
        """Test navigation updates the screen."""
        # Capture initial state
        initial = tmux_harness.capture(with_ansi=False)

        # Navigate
        tmux_harness.send_keys("p")

        # Capture new state
        after_nav = tmux_harness.capture(with_ansi=False)

        # Screen should have changed
        assert initial != after_nav

    def test_escape_returns_to_previous(self, tmux_harness):
        """Test escape key navigation."""
        initial = tmux_harness.capture(with_ansi=False)

        tmux_harness.send_keys("p")
        tmux_harness.send_keys("Escape")

        final = tmux_harness.capture(with_ansi=False)

        # Should be back to initial screen
        # (comparing structure, not exact match due to timing)
        assert "STYRENE" in final or "Fleet" in final

    def test_color_codes_present(self, tmux_harness):
        """Verify ANSI color codes are rendered."""
        capture = tmux_harness.capture(with_ansi=True)

        # Check for ANSI escape sequences (colors)
        assert "\x1b[" in capture, "No ANSI escape codes found - colors may not be rendering"
```

### Analyzing ANSI Captures

When reviewing ANSI captures, look for:

```python
def analyze_ansi_capture(capture: str) -> dict:
    """Analyze ANSI capture for visual elements."""
    import re

    # ANSI escape pattern
    ansi_pattern = re.compile(r'\x1b\[[0-9;]*m')

    # Extract color codes
    colors = ansi_pattern.findall(capture)

    # Strip ANSI for text analysis
    plain_text = ansi_pattern.sub('', capture)
    lines = plain_text.split('\n')

    return {
        "total_lines": len(lines),
        "non_empty_lines": len([l for l in lines if l.strip()]),
        "has_color": len(colors) > 0,
        "unique_color_codes": len(set(colors)),
        "contains_box_drawing": any(c in plain_text for c in "─│┌┐└┘├┤┬┴┼"),
        "text_content": plain_text,
    }
```

### Integration with CI

For CI environments without a display:

```yaml
# .github/workflows/visual-tests.yml
jobs:
  visual-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install tmux
        run: sudo apt-get install -y tmux

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: |
          pip install -e ".[dev]"

      - name: Run visual tests
        run: |
          pytest tests/test_visual.py -v

      - name: Upload captures on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-captures
          path: /tmp/styrene_visual_tests/
```

## Mocking Guidelines

### Mock External Services

```python
from unittest.mock import patch, MagicMock

@pytest.mark.asyncio
async def test_with_mocked_service():
    with patch('styrene.services.rns_service.get_rns_service') as mock_service:
        mock_service.return_value.is_initialized = True

        app = StyreneApp()
        async with app.run_test() as pilot:
            # Test with mocked service
            pass
```

### Mock File Operations

```python
@pytest.mark.asyncio
async def test_with_mocked_files(tmp_path):
    with patch('styrene.services.reticulum.OPERATOR_IDENTITY_PATH', tmp_path / "operator.key"):
        # Test file operations
        pass
```

## Test Fixtures

### Common Fixtures

Create in `tests/conftest.py`:

```python
import pytest
from styrene.app import StyreneApp

@pytest.fixture
def app():
    """Provide StyreneApp instance."""
    return StyreneApp()

@pytest.fixture
async def pilot(app):
    """Provide Pilot for app testing."""
    async with app.run_test() as pilot:
        yield pilot

@pytest.fixture
def mock_config(tmp_path):
    """Provide temporary config directory."""
    config_dir = tmp_path / ".styrene"
    config_dir.mkdir()
    return config_dir
```

## Performance Testing

### Measure Render Time

```python
import time

@pytest.mark.asyncio
async def test_render_performance():
    app = StyreneApp()
    async with app.run_test() as pilot:
        start = time.perf_counter()

        # Trigger heavy render
        await pilot.app.push_screen(DashboardScreen())
        await pilot.pause()

        elapsed = time.perf_counter() - start

        # Should render in under 100ms
        assert elapsed < 0.1, f"Render took {elapsed:.3f}s"
```

## Regression Testing

### Snapshot Testing

When visual layout is critical:

1. Generate baseline:
```bash
pytest tests/ --snapshot-update
```

2. Compare against baseline:
```bash
pytest tests/
```

### Behavior Regression

Track critical user journeys:

```python
@pytest.mark.asyncio
async def test_critical_path():
    """Ensure critical user path never breaks."""
    app = StyreneApp()
    async with app.run_test() as pilot:
        # Complete critical workflow
        # Start -> Action -> Result

        # Verify end state
        assert "expected result"
```

## Test Maintenance

### Keep Tests Fast

- Mock slow operations (network, file I/O)
- Use fixtures for common setup
- Run subset during development
- Run full suite in CI

### Keep Tests Reliable

- Avoid timing dependencies
- Don't rely on external services
- Use deterministic test data
- Clean up after tests

### Keep Tests Readable

- One assertion per test (when possible)
- Clear test names describe behavior
- Add comments for complex setup
- Use AAA pattern (Arrange, Act, Assert)

## Continuous Integration

In CI pipeline:

```yaml
- name: Run TUI Tests
  run: |
    pytest tests/ -v --cov=src/styrene

- name: Validate Styling
  run: |
    ruff check src/
    mypy src/

- name: Check CSS
  run: |
    # Validate TCSS syntax
    textual run --dev src/styrene/app.py --exit
```

## Example: Full Test Suite for New Screen

```python
# tests/test_new_screen.py
import pytest
from styrene.app import StyreneApp
from styrene.screens.new_screen import NewScreen

class TestNewScreen:
    """Test suite for NewScreen."""

    @pytest.mark.asyncio
    async def test_screen_mounts(self):
        """Verify screen mounts without errors."""
        app = StyreneApp()
        async with app.run_test() as pilot:
            await pilot.app.push_screen(NewScreen())
            assert isinstance(pilot.app.screen, NewScreen)

    @pytest.mark.asyncio
    async def test_widgets_present(self):
        """Verify all required widgets exist."""
        app = StyreneApp()
        async with app.run_test() as pilot:
            await pilot.app.push_screen(NewScreen())

            # Check for widgets
            assert pilot.app.screen.query_one("#widget1")
            assert pilot.app.screen.query_one("#widget2")

    @pytest.mark.asyncio
    async def test_button_action(self):
        """Test button triggers expected behavior."""
        app = StyreneApp()
        async with app.run_test() as pilot:
            await pilot.app.push_screen(NewScreen())

            # Click button
            await pilot.click("#action-btn")

            # Verify result
            # ... assertions ...

    @pytest.mark.asyncio
    async def test_navigation_back(self):
        """Test escape key returns to previous screen."""
        app = StyreneApp()
        async with app.run_test() as pilot:
            await pilot.app.push_screen(NewScreen())

            # Press escape
            await pilot.press("escape")

            # Verify screen popped
            assert not isinstance(pilot.app.screen, NewScreen)

    def test_css_styling(self):
        """Verify CSS classes are defined."""
        from pathlib import Path

        css = Path("src/styrene/styles/imperial_crt.tcss").read_text()

        # Check for screen-specific styles
        assert ".new-screen" in css
```

## Troubleshooting

### Test Hangs

- Check for missing `await` on async operations
- Verify `pilot.pause()` isn't indefinite
- Look for blocking operations

### Test Flakes

- Add `await pilot.pause()` after state changes
- Use `pilot.wait_for_scheduled_animations()`
- Mock time-dependent operations

### Import Errors

- Ensure `sys.path` includes src directory
- Check for circular imports
- Verify all dependencies installed

## Resources

- [Textual Testing Guide](https://textual.textualize.io/guide/testing/)
- [Pytest Documentation](https://docs.pytest.org/)
- [Textual Pilot API](https://textual.textualize.io/api/pilot/)
- Project CLAUDE.md: Testing section

## Skill Invocation

When this skill is active, Claude will:

1. Write tests before or alongside implementation
2. Use appropriate test patterns for TUI components
3. Include manual testing checklists
4. Mock external dependencies properly
5. Provide clear test documentation
6. Validate styling and layout
7. Consider edge cases and error conditions
8. Balance automated and manual testing

Remember: **A TUI without tests is a ticking time bomb. Test early, test often.**
