# Plan 01: Testing Infrastructure & CLI Automation

**Status**: 📋 Planned
**Priority**: High
**Estimated Effort**: 15-22 hours (3-5 days)
**Dependencies**: None (foundational)

---

## Overview & Motivation

### The Problem
BaoLife currently cannot be tested without:
- Running a full WebSocket server
- MySQL database connection
- Manual browser-based interaction
- Real OpenAI API calls (costing money)

This makes it:
- **Slow** to verify changes
- **Risky** to refactor code
- **Expensive** to test conversations
- **Impossible** to run automated CI/CD

### The Solution
Transform BaoLife into a testable system with:
- **CLI test runner** - Run game simulation from command line
- **Automated test suite** - Unit + integration tests
- **Mock infrastructure** - No external dependencies for unit tests
- **Switchable backends** - Use real or mock database/API as needed

### Success Outcomes
✅ Run complete game simulation without browser or WebSocket
✅ Execute test suite in <30 seconds (unit tests)
✅ 70%+ code coverage on core business logic
✅ Tests run in CI/CD pipeline
✅ Existing WebSocket server continues working unchanged

---

## Current State Analysis

### Architecture Issues

**Tight Coupling to WebSocket**
```python
# ws/app.py:158
async def initLifeSim(websocket, oneTimePlayer=False):
    if websocket.userID == 'DUMMY_USER_ID':
        return False
    # Game loop requires websocket parameter throughout
    await sendEventMessage(websocket, result)
```
**Impact**: Cannot run game loop independently

**Hardcoded Database Connection**
```python
# ws/functions.py:2648
def connect_to_database():
    mydb = mysql.connector.connect(
        host="localhost",
        user="root",
        password="H8g6gRA2r/h$[t{6",  # Hardcoded!
        database="lifesim"
    )
```
**Impact**: All tests require MySQL running

**Global State**
```python
# ws/app.py
USERS = set()
playerRecords = {}
mydb = connect_to_database()  # Module-level
```
**Impact**: Tests cannot run in isolation

**Real-time Async Execution**
```python
# Ticks happen in real-time
await asyncio.sleep(0.001)
player.gameSpeed = 300  # Milliseconds per tick
```
**Impact**: Tests take real time to execute

### What's Already Good

**Pure Business Logic**
Many functions are already testable:
```python
# ws/events.py
def likeSchool(player, type='message', message=False, response=False):
    fname = 'likeSchool'
    check = fname not in player.askedQuestions and player.c.occupation == 'student'
    # Pure condition check - easily testable
```

**Well-Defined Classes**
- `playerClass`, `personClass`, `messageEvent` are instantiable
- JSON serializable
- Clear state containers

**Mock Data Exists**
- `ws/mockData.json` provides test fixtures

---

## Implementation Plan

### Phase 1: Testing Infrastructure Setup (2-3 hours)

#### Task 1.1: Create Test Directory Structure
```bash
mkdir -p ws/tests/{unit,integration,fixtures,mocks}
touch ws/tests/__init__.py
touch ws/tests/conftest.py
```

**Files to create**:
- `ws/tests/__init__.py` - Empty marker file
- `ws/tests/conftest.py` - Pytest configuration
- `ws/requirements-dev.txt` - Development dependencies

**`ws/requirements-dev.txt`**:
```txt
pytest==7.4.3
pytest-asyncio==0.21.1
pytest-mock==3.12.0
pytest-cov==4.1.0
```

**`ws/tests/conftest.py`**:
```python
"""Pytest configuration and shared fixtures"""
import pytest
import sys
from pathlib import Path

# Add ws/ to Python path
sys.path.insert(0, str(Path(__file__).parent.parent))

# Configure pytest-asyncio
pytest_plugins = ('pytest_asyncio',)

@pytest.fixture
def test_mode():
    """Enable test mode for all tests"""
    import functions
    original = functions.TEST_MODE
    functions.TEST_MODE = True
    yield
    functions.TEST_MODE = original
```

#### Task 1.2: Build Mock Infrastructure

**Create `ws/tests/mocks/__init__.py`**:
```python
"""Mock implementations for testing"""
from .output_mock import MockGameOutput, CollectorOutput
from .storage_mock import InMemoryStorage
from .services_mock import MockConversationService
from .time_mock import ControllableGameClock

__all__ = [
    'MockGameOutput',
    'CollectorOutput',
    'InMemoryStorage',
    'MockConversationService',
    'ControllableGameClock'
]
```

**Create `ws/tests/mocks/output_mock.py`**:
```python
"""Mock output handlers for testing"""
from typing import List, Dict, Any

class CollectorOutput:
    """Collects all game output instead of sending to WebSocket"""

    def __init__(self):
        self.events: List[Dict[str, Any]] = []
        self.messages: List[str] = []
        self.questions: List[Dict[str, Any]] = []
        self.player_updates: List[Dict[str, Any]] = []

    async def send_event(self, event):
        """Collect event instead of sending"""
        self.events.append(event)

    async def send_message(self, message):
        """Collect message instead of sending"""
        self.messages.append(message)

    async def send_question(self, question):
        """Collect question instead of sending"""
        self.questions.append(question)

    async def send_player_update(self, player_data):
        """Collect player state instead of sending"""
        self.player_updates.append(player_data)

    def get_latest_event(self):
        """Get most recent event"""
        return self.events[-1] if self.events else None

    def get_events_by_type(self, event_type):
        """Filter events by type"""
        return [e for e in self.events if e.get('type') == event_type]

    def clear(self):
        """Reset all collections"""
        self.events.clear()
        self.messages.clear()
        self.questions.clear()
        self.player_updates.clear()
```

**Create `ws/tests/mocks/storage_mock.py`**:
```python
"""In-memory storage for testing"""
from typing import Dict, Optional
import json
import copy

class InMemoryStorage:
    """In-memory game storage (no database required)"""

    def __init__(self):
        self._games: Dict[str, str] = {}  # userID -> JSON

    def save(self, player):
        """Save player to memory"""
        from functions import playerClass

        # Serialize player to JSON (same as real saveGame)
        player_dict = player.to_dict()  # Assuming we add this method
        self._games[player.userID] = json.dumps(player_dict)
        return True

    def load(self, user_id: str) -> Optional[dict]:
        """Load player from memory"""
        if user_id not in self._games:
            return None
        return json.loads(self._games[user_id])

    def exists(self, user_id: str) -> bool:
        """Check if game exists"""
        return user_id in self._games

    def delete(self, user_id: str) -> bool:
        """Delete saved game"""
        if user_id in self._games:
            del self._games[user_id]
            return True
        return False

    def clear_all(self):
        """Clear all saved games"""
        self._games.clear()
```

**Create `ws/tests/mocks/services_mock.py`**:
```python
"""Mock external services"""
from typing import Dict, List, Optional

class MockConversationService:
    """Mock OpenAI conversation service"""

    def __init__(self):
        self.responses: List[str] = []
        self.call_count = 0
        self.last_request = None

    def set_response(self, response: str):
        """Set next response to return"""
        self.responses.append(response)

    def set_responses(self, responses: List[str]):
        """Set sequence of responses"""
        self.responses = responses.copy()

    async def get_response(self, conversation_history: List[Dict],
                          character: Dict,
                          context: Dict) -> str:
        """Return pre-scripted response"""
        self.call_count += 1
        self.last_request = {
            'conversation': conversation_history,
            'character': character,
            'context': context
        }

        if not self.responses:
            return "I don't know what to say."

        return self.responses.pop(0)

    def reset(self):
        """Reset mock state"""
        self.responses.clear()
        self.call_count = 0
        self.last_request = None
```

**Create `ws/tests/mocks/time_mock.py`**:
```python
"""Controllable time for testing"""
from datetime import datetime, timedelta

class ControllableGameClock:
    """Mock clock that advances instantly"""

    def __init__(self, start_date: datetime = None):
        self.current_time = start_date or datetime(2000, 1, 1, 0, 0)
        self.tick_count = 0

    def advance_tick(self):
        """Advance by one game tick (1 minute)"""
        self.current_time += timedelta(minutes=1)
        self.tick_count += 1

    def advance_minutes(self, minutes: int):
        """Advance by N minutes"""
        self.current_time += timedelta(minutes=minutes)
        self.tick_count += minutes

    def advance_to_time(self, hour: int, minute: int = 0):
        """Fast-forward to specific time of day"""
        target = self.current_time.replace(hour=hour, minute=minute)
        if target < self.current_time:
            target += timedelta(days=1)

        diff = target - self.current_time
        minutes = int(diff.total_seconds() / 60)
        self.advance_minutes(minutes)

    def advance_to_date(self, date_str: str):
        """Fast-forward to specific date (format: 'YYYY-MM-DD')"""
        target = datetime.strptime(date_str, '%Y-%m-%d')
        target = target.replace(
            hour=self.current_time.hour,
            minute=self.current_time.minute
        )

        diff = target - self.current_time
        minutes = int(diff.total_seconds() / 60)
        if minutes > 0:
            self.advance_minutes(minutes)

    def get_time(self):
        """Get current game time"""
        return self.current_time

    async def sleep(self, duration: float):
        """Mock sleep (does nothing - instant)"""
        pass
```

#### Task 1.3: Create Test Fixtures

**Create `ws/tests/fixtures/player_fixtures.py`**:
```python
"""Test player factories"""
import pytest
from functions import playerClass, personClass
from datetime import datetime

@pytest.fixture
def newborn_player():
    """Create player with newborn character"""
    player = playerClass()
    player.userID = 'test_user_newborn'
    player.c = personClass()
    player.c.firstname = 'Test'
    player.c.lastname = 'Baby'
    player.c.sex = 'Male'
    player.c.age = 0
    player.c.ageMonths = 0
    player.c.birthday = datetime.now()
    player.date = datetime.now()
    player.hourOfDay = 0
    player.minuteOfHour = 0
    return player

@pytest.fixture
def student_player():
    """Create player with high school student"""
    player = playerClass()
    player.userID = 'test_user_student'
    player.c = personClass()
    player.c.firstname = 'Test'
    player.c.lastname = 'Student'
    player.c.sex = 'Female'
    player.c.age = 16
    player.c.ageMonths = 192
    player.c.occupation = 'student'
    player.c.education = 'High School'
    player.c.grade = 11
    player.date = datetime(2024, 9, 15)  # Mid-semester
    player.hourOfDay = 8
    return player

@pytest.fixture
def adult_player():
    """Create player with working adult"""
    player = playerClass()
    player.userID = 'test_user_adult'
    player.c = personClass()
    player.c.firstname = 'Test'
    player.c.lastname = 'Adult'
    player.c.sex = 'Male'
    player.c.age = 30
    player.c.ageMonths = 360
    player.c.occupation = 'Software Developer'
    player.c.education = 'Bachelor'
    player.c.money = 50000
    player.date = datetime(2024, 6, 15)
    return player
```

---

### Phase 2: Core Refactoring for Testability (4-6 hours)

#### Task 2.1: Decouple Game Loop from WebSocket

**Modify `ws/app.py`**:

Add at top of file:
```python
# Test mode configuration
TEST_MODE = False
```

Extract core game loop logic:
```python
def run_game_tick_sync(player, output_handler=None, storage_handler=None):
    """
    Synchronous version of game tick for testing.

    Args:
        player: playerClass instance
        output_handler: Optional output collector (for testing)
        storage_handler: Optional storage backend (for testing)

    Returns:
        dict: Tick results (events triggered, state changes, etc.)
    """
    results = {
        'events_triggered': [],
        'questions_asked': [],
        'time_advanced': False,
        'game_over': False
    }

    # Advance time
    player.minuteOfHour += 1
    if player.minuteOfHour >= 60:
        player.minuteOfHour = 0
        player.hourOfDay += 1
        results['time_advanced'] = True

        if player.hourOfDay >= 24:
            player.hourOfDay = 0
            player.date += timedelta(days=1)

    # Parse events (existing logic)
    events = parseEvents(player)
    for event in events:
        if output_handler:
            output_handler.events.append(event)
        results['events_triggered'].append(event)

    # Parse day events
    day_events = parseDayEvents(player)
    for event in day_events:
        if output_handler:
            output_handler.events.append(event)
        results['events_triggered'].append(event)

    # Update stats
    updateStats(player)

    # Check death
    if player.c.health <= 0:
        results['game_over'] = True

    # Save game if needed
    if storage_handler and player.ticks % 100 == 0:
        storage_handler.save(player)

    return results


async def initLifeSim(websocket, oneTimePlayer=False,
                     test_mode=False, test_output=None, test_storage=None):
    """
    Main game loop with optional test mode.

    If test_mode=True, uses provided test_output and test_storage
    instead of WebSocket and MySQL.
    """
    if not test_mode and websocket.userID == 'DUMMY_USER_ID':
        return False

    # ... existing initialization ...

    while player.controller == 'active':
        # Use sync tick function
        results = run_game_tick_sync(
            player,
            output_handler=test_output if test_mode else None,
            storage_handler=test_storage if test_mode else None
        )

        # Send updates via WebSocket (unless in test mode)
        if not test_mode:
            for event in results['events_triggered']:
                await sendEventMessage(websocket, event)

        # Real-time delay (unless in test mode)
        if not test_mode:
            await asyncio.sleep(player.gameSpeed / 1000)

        # ... rest of loop ...
```

#### Task 2.2: Abstract Storage Layer

**Create `ws/storage.py`**:
```python
"""Storage abstraction for game persistence"""
from typing import Protocol, Optional
import mysql.connector
import json
import os

class IGameStorage(Protocol):
    """Interface for game storage backends"""

    def save(self, player) -> bool:
        """Save player state"""
        ...

    def load(self, user_id: str) -> Optional[dict]:
        """Load player state"""
        ...

    def exists(self, user_id: str) -> bool:
        """Check if save exists"""
        ...


class MySQLStorage:
    """Production MySQL storage"""

    def __init__(self, connection=None):
        self.connection = connection or self._get_connection()

    def _get_connection(self):
        """Get database connection"""
        # Use environment variables for credentials
        return mysql.connector.connect(
            host=os.getenv('DB_HOST', 'localhost'),
            user=os.getenv('DB_USER', 'root'),
            password=os.getenv('DB_PASSWORD', ''),
            database=os.getenv('DB_NAME', 'lifesim')
        )

    def save(self, player) -> bool:
        """Save to MySQL (existing saveGame logic)"""
        try:
            cursor = self.connection.cursor()
            # ... existing save logic from functions.py ...
            return True
        except Exception as e:
            print(f"Save failed: {e}")
            return False

    def load(self, user_id: str) -> Optional[dict]:
        """Load from MySQL (existing loadGame logic)"""
        try:
            cursor = self.connection.cursor()
            # ... existing load logic from functions.py ...
            return player_data
        except Exception:
            return None

    def exists(self, user_id: str) -> bool:
        """Check if save exists"""
        cursor = self.connection.cursor()
        cursor.execute("SELECT userID FROM users WHERE userID = %s", (user_id,))
        return cursor.fetchone() is not None


def storage_factory(test_mode: bool = False) -> IGameStorage:
    """
    Create storage backend based on mode.

    Args:
        test_mode: If True, use in-memory storage

    Returns:
        IGameStorage implementation
    """
    if test_mode:
        from tests.mocks.storage_mock import InMemoryStorage
        return InMemoryStorage()
    else:
        return MySQLStorage()
```

**Modify `ws/functions.py`**:
```python
# Add at top
TEST_MODE = os.getenv('TEST_MODE', 'false').lower() == 'true'

# Modify saveGame to use storage
def saveGame(player, storage=None):
    """Save game using provided storage or default MySQL"""
    if storage is None:
        from storage import storage_factory
        storage = storage_factory(test_mode=TEST_MODE)

    return storage.save(player)

# Similarly for loadGame
def loadGame(userID, storage=None):
    """Load game using provided storage or default MySQL"""
    if storage is None:
        from storage import storage_factory
        storage = storage_factory(test_mode=TEST_MODE)

    return storage.load(userID)
```

#### Task 2.3: Abstract Output Layer

**Create `ws/output.py`**:
```python
"""Output abstraction for game communication"""
from typing import Protocol
import json

class IGameOutput(Protocol):
    """Interface for game output"""

    async def send_event(self, event):
        """Send event to client"""
        ...

    async def send_player_update(self, player_data):
        """Send player state update"""
        ...


class WebSocketOutput:
    """Production WebSocket output"""

    def __init__(self, websocket):
        self.websocket = websocket

    async def send_event(self, event):
        """Send via WebSocket (existing logic)"""
        await self.websocket.send(json.dumps(event))

    async def send_player_update(self, player_data):
        """Send player update via WebSocket"""
        await self.websocket.send(json.dumps({
            'type': 'playerObject',
            'data': player_data
        }))
```

#### Task 2.4: Mock External Services

**Create `ws/services.py`**:
```python
"""External service abstractions"""
from typing import Protocol, List, Dict
import os

class IConversationService(Protocol):
    """Interface for conversation/AI services"""

    async def get_response(self, conversation: List[Dict],
                          character: Dict, context: Dict) -> str:
        ...


class OpenAIConversationService:
    """Production OpenAI service"""

    def __init__(self, api_key: str = None):
        self.api_key = api_key or os.getenv('OPENAI_API_KEY')
        import openai
        openai.api_key = self.api_key

    async def get_response(self, conversation: List[Dict],
                          character: Dict, context: Dict) -> str:
        """Get response from OpenAI (existing logic)"""
        import openai
        # ... existing OpenAI call logic ...
        return response


def conversation_service_factory(test_mode: bool = False):
    """Create conversation service based on mode"""
    use_mock = test_mode or os.getenv('USE_MOCK_OPENAI', 'false').lower() == 'true'

    if use_mock:
        from tests.mocks.services_mock import MockConversationService
        return MockConversationService()
    else:
        return OpenAIConversationService()
```

**Modify `ws/conversationEvents.py`**:
```python
from services import conversation_service_factory, IConversationService

# At module level
conversation_service: IConversationService = None

def init_conversation_service(test_mode=False):
    """Initialize conversation service"""
    global conversation_service
    conversation_service = conversation_service_factory(test_mode)

# In conversation functions
async def get_ai_response(conversation_history, character, context):
    """Get AI response using current service"""
    if conversation_service is None:
        init_conversation_service()

    return await conversation_service.get_response(
        conversation_history, character, context
    )
```

---

### Phase 3: CLI Test Runner (2-3 hours)

#### Task 3.1: Create Headless Game Engine

**Create `ws/cli_runner.py`**:
```python
"""
Headless game runner for testing.

Usage:
    from cli_runner import HeadlessGame

    game = HeadlessGame()
    game.tick(100)  # Run 100 ticks
    game.advance_to_date('2024-09-01')  # Fast-forward
    events = game.get_events()
"""

import sys
from pathlib import Path
from datetime import datetime, timedelta
from typing import List, Dict, Optional, Callable

# Add ws/ to path
sys.path.insert(0, str(Path(__file__).parent))

from functions import playerClass, personClass, create_character
from app import run_game_tick_sync
from tests.mocks import CollectorOutput, InMemoryStorage, ControllableGameClock
from services import init_conversation_service

class HeadlessGame:
    """
    Headless game engine for testing.

    Runs game simulation without WebSocket or browser.
    """

    def __init__(self, player: playerClass = None, user_id: str = None):
        """
        Initialize headless game.

        Args:
            player: Optional existing player (otherwise creates new)
            user_id: Optional user ID for new player
        """
        self.player = player or self._create_test_player(user_id)
        self.output = CollectorOutput()
        self.storage = InMemoryStorage()
        self.clock = ControllableGameClock(start_date=self.player.date)

        # Initialize test mode services
        init_conversation_service(test_mode=True)

        self.total_ticks = 0
        self.paused = False

    def _create_test_player(self, user_id: str = None) -> playerClass:
        """Create a test player"""
        player = playerClass()
        player.userID = user_id or f'test_user_{datetime.now().timestamp()}'
        player.controller = 'active'

        # Create character
        player.c = create_character(player, sex='Male')

        return player

    def tick(self, count: int = 1) -> Dict:
        """
        Advance game by N ticks.

        Args:
            count: Number of ticks to execute

        Returns:
            Summary of what happened
        """
        summary = {
            'ticks_executed': 0,
            'events_triggered': [],
            'questions_asked': [],
            'time_start': self.get_time(),
            'time_end': None,
            'game_over': False
        }

        for i in range(count):
            if self.paused or self.player.controller != 'active':
                break

            # Run one tick
            results = run_game_tick_sync(
                self.player,
                output_handler=self.output,
                storage_handler=self.storage
            )

            self.clock.advance_tick()
            self.total_ticks += 1
            summary['ticks_executed'] += 1

            # Collect results
            summary['events_triggered'].extend(results['events_triggered'])
            summary['questions_asked'].extend(results.get('questions_asked', []))

            if results.get('game_over'):
                summary['game_over'] = True
                break

            # Auto-pause on questions
            if results.get('questions_asked'):
                self.paused = True
                break

        summary['time_end'] = self.get_time()
        return summary

    def advance_to_date(self, date: str, max_ticks: int = 1000000) -> Dict:
        """
        Fast-forward to specific date.

        Args:
            date: Target date (format: 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM')
            max_ticks: Maximum ticks to prevent infinite loops

        Returns:
            Summary of simulation
        """
        if ' ' in date:
            target = datetime.strptime(date, '%Y-%m-%d %H:%M')
        else:
            target = datetime.strptime(date, '%Y-%m-%d')

        ticks_needed = self._calculate_ticks_to_date(target)
        ticks_needed = min(ticks_needed, max_ticks)

        return self.tick(ticks_needed)

    def advance_to_event(self, event_name: str, max_ticks: int = 10000) -> Optional[Dict]:
        """
        Run until specific event triggers.

        Args:
            event_name: Name of event to wait for
            max_ticks: Maximum ticks before giving up

        Returns:
            Event dict if found, None if not triggered
        """
        initial_event_count = len(self.output.events)

        for i in range(max_ticks):
            self.tick(1)

            # Check if event was triggered
            new_events = self.output.events[initial_event_count:]
            for event in new_events:
                if event.get('fname') == event_name:
                    return event

            if self.player.controller != 'active':
                break

        return None

    def advance_to_age(self, target_age: int, max_ticks: int = 1000000) -> Dict:
        """
        Fast-forward until character reaches age.

        Args:
            target_age: Target age in years
            max_ticks: Maximum ticks to prevent infinite loops

        Returns:
            Summary of simulation
        """
        ticks = 0
        while self.player.c.age < target_age and ticks < max_ticks:
            self.tick(1440)  # One day
            ticks += 1440

        return {
            'final_age': self.player.c.age,
            'ticks_executed': ticks,
            'time': self.get_time()
        }

    def answer_question(self, question_id: str, response: str):
        """
        Answer a pending question.

        Args:
            question_id: ID of question (fname)
            response: Answer text
        """
        # Find question in output
        questions = [q for q in self.output.questions if q.get('fname') == question_id]
        if not questions:
            raise ValueError(f"Question '{question_id}' not found")

        question = questions[-1]

        # Process answer (would normally go through consumer handler)
        # TODO: Call appropriate response handler

        # Unpause game
        self.paused = False

    def run_until(self, condition: Callable[[playerClass], bool],
                  max_ticks: int = 10000) -> Dict:
        """
        Run until custom condition is met.

        Args:
            condition: Function that takes player and returns bool
            max_ticks: Maximum ticks before giving up

        Returns:
            Summary with condition_met flag
        """
        summary = self.tick(0)  # Empty summary
        ticks = 0

        while not condition(self.player) and ticks < max_ticks:
            tick_result = self.tick(1)
            ticks += 1

            if tick_result.get('game_over'):
                break

        summary['ticks_executed'] = ticks
        summary['condition_met'] = condition(self.player)
        return summary

    # Inspection methods

    def get_events(self, event_type: str = None) -> List[Dict]:
        """Get collected events, optionally filtered by type"""
        if event_type:
            return [e for e in self.output.events if e.get('type') == event_type]
        return self.output.events.copy()

    def get_questions(self) -> List[Dict]:
        """Get all questions asked"""
        return self.output.questions.copy()

    def get_messages(self) -> List[str]:
        """Get all messages"""
        return self.output.messages.copy()

    def get_time(self) -> datetime:
        """Get current game time"""
        return self.player.date

    def get_age(self) -> int:
        """Get character age"""
        return self.player.c.age

    def get_stat(self, stat_name: str):
        """Get character stat value"""
        return getattr(self.player.c, stat_name, None)

    def save_game(self, save_id: str = None):
        """Save current game state"""
        save_id = save_id or self.player.userID
        return self.storage.save(self.player)

    def load_game(self, save_id: str):
        """Load saved game state"""
        data = self.storage.load(save_id)
        if data:
            # Reconstruct player from data
            # TODO: Implement deserialization
            pass

    def _calculate_ticks_to_date(self, target: datetime) -> int:
        """Calculate ticks needed to reach date"""
        current = self.player.date
        if target < current:
            return 0

        diff = target - current
        minutes = int(diff.total_seconds() / 60)
        return minutes

    def __repr__(self):
        return (f"<HeadlessGame user={self.player.userID} "
                f"age={self.player.c.age} "
                f"ticks={self.total_ticks}>")


# CLI interface for manual testing
if __name__ == '__main__':
    import argparse

    parser = argparse.ArgumentParser(description='Run BaoLife headless simulation')
    parser.add_argument('--ticks', type=int, default=100, help='Number of ticks to run')
    parser.add_argument('--date', type=str, help='Fast-forward to date (YYYY-MM-DD)')
    parser.add_argument('--age', type=int, help='Fast-forward to age')
    parser.add_argument('--event', type=str, help='Run until event triggers')
    parser.add_argument('--user-id', type=str, help='User ID for save/load')

    args = parser.parse_args()

    print("🎮 BaoLife Headless Runner")
    print("=" * 50)

    game = HeadlessGame(user_id=args.user_id)
    print(f"Started: {game.player.c.firstname} {game.player.c.lastname}")
    print(f"Age: {game.player.c.age} | Date: {game.get_time()}")
    print()

    if args.date:
        print(f"⏩ Fast-forwarding to {args.date}...")
        result = game.advance_to_date(args.date)
    elif args.age:
        print(f"⏩ Fast-forwarding to age {args.age}...")
        result = game.advance_to_age(args.age)
    elif args.event:
        print(f"⏩ Running until event '{args.event}'...")
        result = game.advance_to_event(args.event)
    else:
        print(f"▶️  Running {args.ticks} ticks...")
        result = game.tick(args.ticks)

    print()
    print("Results:")
    print(f"  Ticks: {result.get('ticks_executed', 0)}")
    print(f"  Events: {len(result.get('events_triggered', []))}")
    print(f"  Final age: {game.player.c.age}")
    print(f"  Final date: {game.get_time()}")
    print()

    if result.get('events_triggered'):
        print("📋 Events triggered:")
        for event in result['events_triggered'][:10]:  # Show first 10
            print(f"  - {event.get('fname', 'unknown')}: {event.get('title', '')}")
```

#### Task 3.2: Test Runner CLI

**Create `ws/run_tests.py`**:
```python
#!/usr/bin/env python3
"""
BaoLife Test Runner

Usage:
    python run_tests.py                  # Run all tests
    python run_tests.py --unit           # Unit tests only
    python run_tests.py --integration    # Integration tests only
    python run_tests.py --coverage       # With coverage report
    python run_tests.py --verbose        # Verbose output
"""

import sys
import os
import subprocess
from pathlib import Path

# Add ws/ to path
sys.path.insert(0, str(Path(__file__).parent))

def main():
    import argparse

    parser = argparse.ArgumentParser(description='Run BaoLife tests')
    parser.add_argument('--unit', action='store_true', help='Run unit tests only')
    parser.add_argument('--integration', action='store_true', help='Run integration tests only')
    parser.add_argument('--coverage', action='store_true', help='Generate coverage report')
    parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output')
    parser.add_argument('--filter', '-k', type=str, help='Filter tests by name')

    args = parser.parse_args()

    # Set environment variables
    os.environ['TEST_MODE'] = 'true'
    os.environ['USE_MOCK_OPENAI'] = 'true'

    # Build pytest command
    cmd = ['pytest']

    # Select test directory
    if args.unit:
        cmd.append('tests/unit')
    elif args.integration:
        cmd.append('tests/integration')
    else:
        cmd.append('tests/')

    # Add options
    if args.coverage:
        cmd.extend(['--cov=.', '--cov-report=html', '--cov-report=term'])

    if args.verbose:
        cmd.append('-v')

    if args.filter:
        cmd.extend(['-k', args.filter])

    # Run tests
    print(f"🧪 Running: {' '.join(cmd)}")
    print()

    result = subprocess.run(cmd, cwd=Path(__file__).parent)

    if args.coverage and result.returncode == 0:
        print()
        print("📊 Coverage report generated: htmlcov/index.html")

    sys.exit(result.returncode)

if __name__ == '__main__':
    main()
```

Make executable:
```bash
chmod +x ws/run_tests.py
```

---

### Phase 4: Test Suite Implementation (6-8 hours)

#### Task 4.1: Unit Tests

**Create `ws/tests/unit/test_events.py`**:
```python
"""Unit tests for event logic"""
import pytest
from functions import playerClass, personClass
from events import likeSchool, firstDayOfSchool, makeNewFriend

class TestSchoolEvents:
    """Test school-related events"""

    def test_like_school_triggers_for_students(self, student_player):
        """likeSchool should trigger for students"""
        result = likeSchool(student_player, type='check')
        assert result is True

    def test_like_school_not_for_non_students(self, adult_player):
        """likeSchool should not trigger for non-students"""
        result = likeSchool(adult_player, type='check')
        assert result is False

    def test_like_school_not_duplicate(self, student_player):
        """likeSchool should not trigger twice"""
        # First time should work
        result1 = likeSchool(student_player, type='check')
        assert result1 is True

        # Add to asked questions
        student_player.askedQuestions.append('likeSchool')

        # Second time should not trigger
        result2 = likeSchool(student_player, type='check')
        assert result2 is False

    def test_first_day_of_school_september_1(self, student_player):
        """First day event should trigger on Sept 1"""
        from datetime import datetime
        student_player.date = datetime(2024, 9, 1)
        student_player.dayOfWeek = 'Monday'

        result = firstDayOfSchool(student_player, type='check')
        assert result is True

    def test_first_day_of_school_wrong_date(self, student_player):
        """First day event should not trigger on other dates"""
        from datetime import datetime
        student_player.date = datetime(2024, 9, 15)

        result = firstDayOfSchool(student_player, type='check')
        assert result is False

class TestRelationshipEvents:
    """Test relationship-related events"""

    def test_make_friend_requires_classmates(self, student_player):
        """Can only make friends if classmates exist"""
        # No relationships yet
        result = makeNewFriend(student_player, type='check')
        assert result is False

        # Add a classmate
        classmate = personClass()
        classmate.firstname = 'Alice'
        classmate.relationship = 'Classmate'
        student_player.r.append(classmate)

        # Now should be possible
        result = makeNewFriend(student_player, type='check')
        assert result is True


# Run with: pytest tests/unit/test_events.py -v
```

**Create `ws/tests/unit/test_stats.py`**:
```python
"""Unit tests for stat calculations"""
import pytest
from functions import updateAge, getPeakEnergy, updateDeathChance
from datetime import datetime, timedelta

class TestAgeCalculations:
    """Test age-related calculations"""

    def test_update_age_from_birthday(self):
        """updateAge should calculate age from birthday"""
        from functions import personClass

        person = personClass()
        person.birthday = datetime.now() - timedelta(days=365*16 + 4)  # 16 years ago

        updateAge(person)

        assert person.age == 16
        assert person.ageMonths == 16 * 12

    def test_update_age_handles_leap_years(self):
        """updateAge should handle leap years correctly"""
        from functions import personClass

        person = personClass()
        person.birthday = datetime(2000, 2, 29)  # Leap year birthday
        person.ageMonths = 0

        updateAge(person)

        # Should have an age now
        assert person.age >= 0

class TestEnergyCalculations:
    """Test energy-related calculations"""

    def test_peak_energy_varies_by_age(self):
        """Peak energy should be higher for young adults"""
        energy_infant = getPeakEnergy(age=0)
        energy_young_adult = getPeakEnergy(age=25)
        energy_elderly = getPeakEnergy(age=75)

        # Young adults should have highest energy
        assert energy_young_adult > energy_infant
        assert energy_young_adult > energy_elderly

    def test_peak_energy_in_valid_range(self):
        """Peak energy should always be in valid range"""
        for age in [0, 5, 10, 20, 30, 50, 70, 90]:
            energy = getPeakEnergy(age=age)
            assert 0 <= energy <= 100

class TestDeathChance:
    """Test death probability calculations"""

    def test_death_chance_increases_with_age(self):
        """Older characters should have higher death chance"""
        chance_young = updateDeathChance(age=20, health=100)
        chance_old = updateDeathChance(age=80, health=100)

        assert chance_old > chance_young

    def test_death_chance_increases_with_poor_health(self):
        """Poor health should increase death chance"""
        chance_healthy = updateDeathChance(age=50, health=100)
        chance_sick = updateDeathChance(age=50, health=20)

        assert chance_sick > chance_healthy

    def test_death_chance_never_negative(self):
        """Death chance should never be negative"""
        for age in range(0, 100, 10):
            for health in range(0, 101, 20):
                chance = updateDeathChance(age=age, health=health)
                assert chance >= 0


# Run with: pytest tests/unit/test_stats.py -v
```

**Create `ws/tests/unit/test_characters.py`**:
```python
"""Unit tests for character creation"""
import pytest
from functions import playerClass, create_character, add_parents, add_siblings

class TestCharacterCreation:
    """Test character creation functions"""

    def test_create_character_basic(self):
        """create_character should create valid character"""
        player = playerClass()

        char = create_character(player, sex='Male')

        assert char is not None
        assert char.sex == 'Male'
        assert char.firstname != ''
        assert char.lastname != ''
        assert char.age == 0

    def test_create_character_both_sexes(self):
        """create_character should work for both sexes"""
        player = playerClass()

        male = create_character(player, sex='Male')
        female = create_character(player, sex='Female')

        assert male.sex == 'Male'
        assert female.sex == 'Female'

    def test_add_parents(self):
        """add_parents should create mother and father"""
        player = playerClass()
        player.c = create_character(player, sex='Male')

        add_parents(player)

        # Should have 2 relationships
        assert len(player.r) == 2

        # Should have mother and father
        parents = [r for r in player.r if r.relationship in ['Mother', 'Father']]
        assert len(parents) == 2

        # Parents should have appropriate sexes
        mother = next(r for r in player.r if r.relationship == 'Mother')
        father = next(r for r in player.r if r.relationship == 'Father')

        assert mother.sex == 'Female'
        assert father.sex == 'Male'

    def test_add_siblings_creates_siblings(self):
        """add_siblings should create sibling relationships"""
        player = playerClass()
        player.c = create_character(player, sex='Female')
        add_parents(player)

        initial_count = len(player.r)
        add_siblings(player, count=2)

        # Should have added siblings
        siblings = [r for r in player.r if 'Sibling' in r.relationship or
                   r.relationship in ['Brother', 'Sister']]
        assert len(siblings) >= 1  # At least one sibling


# Run with: pytest tests/unit/test_characters.py -v
```

**Create `ws/tests/unit/test_daily_plans.py`**:
```python
"""Unit tests for daily plan generation"""
import pytest
from intradayActivity import get_dailyPlan
from datetime import datetime

class TestDailyPlanGeneration:
    """Test daily schedule generation"""

    def test_student_has_school_hours(self, student_player):
        """Students should have school during weekdays"""
        student_player.dayOfWeek = 'Monday'
        student_player.weekend = False

        plan = get_dailyPlan(student_player, student_player.c)

        # Should have school-related activities
        school_activities = [a for a in plan if 'school' in a.get('activity', '').lower()]
        assert len(school_activities) > 0

    def test_student_weekend_no_school(self, student_player):
        """Students should not have school on weekends"""
        student_player.dayOfWeek = 'Saturday'
        student_player.weekend = True

        plan = get_dailyPlan(student_player, student_player.c)

        # Should not have school activities
        school_activities = [a for a in plan if 'school' in a.get('activity', '').lower()]
        assert len(school_activities) == 0

    def test_adult_has_work_hours(self, adult_player):
        """Adults with jobs should have work during weekdays"""
        adult_player.dayOfWeek = 'Wednesday'
        adult_player.weekend = False
        adult_player.c.occupation = 'Software Developer'

        plan = get_dailyPlan(adult_player, adult_player.c)

        # Should have work-related activities
        work_activities = [a for a in plan if 'work' in a.get('activity', '').lower()]
        assert len(work_activities) > 0

    def test_daily_plan_covers_24_hours(self, student_player):
        """Daily plan should account for all 24 hours"""
        plan = get_dailyPlan(student_player, student_player.c)

        # Should have activities throughout the day
        hours_covered = set([a.get('hour') for a in plan if a.get('hour') is not None])

        # Should cover multiple hours (at least morning through evening)
        assert len(hours_covered) >= 12


# Run with: pytest tests/unit/test_daily_plans.py -v
```

#### Task 4.2: Integration Tests

**Create `ws/tests/integration/test_game_loop.py`**:
```python
"""Integration tests for game loop"""
import pytest
from cli_runner import HeadlessGame
from datetime import datetime

class TestGameLoop:
    """Test full game loop execution"""

    def test_game_advances_time(self):
        """Game should advance time with each tick"""
        game = HeadlessGame()
        initial_time = game.get_time()

        game.tick(60)  # One hour

        final_time = game.get_time()
        assert final_time > initial_time

    def test_game_updates_stats(self):
        """Game should update character stats"""
        game = HeadlessGame()
        initial_energy = game.get_stat('energy')

        game.tick(60)

        final_energy = game.get_stat('energy')
        # Energy should change (likely decrease)
        # (Exact change depends on activities)

    def test_game_triggers_events(self):
        """Game should trigger events"""
        game = HeadlessGame()
        game.player.c.age = 16
        game.player.c.occupation = 'student'

        # Run for a while
        game.tick(1000)

        # Should have triggered some events
        events = game.get_events()
        assert len(events) > 0

    def test_game_stops_on_death(self):
        """Game should stop when character dies"""
        game = HeadlessGame()

        # Set health to critical
        game.player.c.health = 1
        game.player.c.age = 90

        # Run until game over
        result = game.tick(10000)

        # Should eventually stop
        assert result.get('game_over') or game.player.controller != 'active'


# Run with: pytest tests/integration/test_game_loop.py -v
```

**Create `ws/tests/integration/test_lifecycle.py`**:
```python
"""Integration tests for character lifecycle"""
import pytest
from cli_runner import HeadlessGame

class TestCharacterLifecycle:
    """Test character progression through life stages"""

    @pytest.mark.slow
    def test_infant_to_child_progression(self):
        """Character should progress from infant to child"""
        game = HeadlessGame()
        game.player.c.age = 0

        # Fast-forward to age 6
        result = game.advance_to_age(6, max_ticks=500000)

        assert game.player.c.age >= 6
        # Should have triggered childhood events

    @pytest.mark.slow
    def test_student_lifecycle(self):
        """Test student progression through school"""
        game = HeadlessGame()
        game.player.c.age = 6
        game.player.c.occupation = 'student'
        game.player.c.education = 'Elementary School'
        game.player.c.grade = 1

        # Advance to September 1 (school start)
        game.advance_to_date('2024-09-01')

        # Should trigger school events
        events = game.get_events()
        school_events = [e for e in events if 'school' in str(e).lower()]

        assert len(school_events) > 0


# Run with: pytest tests/integration/test_lifecycle.py -v -m slow
```

**Create `ws/tests/integration/test_save_load.py`**:
```python
"""Integration tests for save/load"""
import pytest
from cli_runner import HeadlessGame
from tests.mocks import InMemoryStorage

class TestSaveLoad:
    """Test game persistence"""

    def test_save_and_load_game(self):
        """Should be able to save and load game state"""
        storage = InMemoryStorage()

        # Create and advance game
        game1 = HeadlessGame()
        game1.storage = storage
        game1.tick(100)

        initial_age = game1.player.c.age
        initial_time = game1.get_time()
        user_id = game1.player.userID

        # Save game
        game1.save_game(user_id)

        # Create new game and load
        game2 = HeadlessGame()
        game2.storage = storage
        game2.load_game(user_id)

        # Should have same state
        assert game2.player.c.age == initial_age
        # (Full state comparison would go here)

    def test_save_preserves_relationships(self):
        """Saved game should preserve relationships"""
        storage = InMemoryStorage()
        game = HeadlessGame()
        game.storage = storage

        # Add relationships
        from functions import add_parents
        add_parents(game.player)

        initial_rel_count = len(game.player.r)
        user_id = game.player.userID

        # Save and load
        game.save_game(user_id)

        game2 = HeadlessGame()
        game2.storage = storage
        game2.load_game(user_id)

        # Should have same relationships
        assert len(game2.player.r) == initial_rel_count


# Run with: pytest tests/integration/test_save_load.py -v
```

---

### Phase 5: Documentation & Setup (1-2 hours)

#### Task 5.1: Testing Documentation

**Create `TESTING.md`**:
```markdown
# BaoLife Testing Guide

This guide explains how to test BaoLife using the automated test suite and CLI runner.

## Quick Start

### Install Dependencies
```bash
cd ws/
pip install -r requirements-dev.txt
```

### Run Tests
```bash
# Run all tests
python run_tests.py

# Run only unit tests (fast)
python run_tests.py --unit

# Run with coverage report
python run_tests.py --coverage

# Run specific test
python run_tests.py -k test_like_school
```

## Test Structure

```
ws/tests/
├── unit/              # Fast, isolated tests
│   ├── test_events.py
│   ├── test_stats.py
│   └── test_characters.py
├── integration/       # Full system tests
│   ├── test_game_loop.py
│   └── test_lifecycle.py
├── fixtures/          # Test data
│   └── player_fixtures.py
└── mocks/            # Mock implementations
    ├── output_mock.py
    ├── storage_mock.py
    └── services_mock.py
```

## CLI Test Runner

Run game simulation from command line:

```bash
# Run 1000 ticks
python cli_runner.py --ticks 1000

# Fast-forward to specific date
python cli_runner.py --date 2024-09-01

# Fast-forward to age 18
python cli_runner.py --age 18

# Run until event triggers
python cli_runner.py --event likeSchool
```

### Programmatic Usage

```python
from cli_runner import HeadlessGame

# Create game
game = HeadlessGame()

# Advance time
game.tick(100)
game.advance_to_date('2024-12-25')
game.advance_to_age(18)

# Inspect state
events = game.get_events()
age = game.get_age()
energy = game.get_stat('energy')

# Answer questions
game.answer_question('likeSchool', 'Yes')
```

## Writing Tests

### Unit Test Example

```python
import pytest
from events import likeSchool

def test_like_school_triggers_for_students(student_player):
    \"\"\"Test event condition\"\"\"
    result = likeSchool(student_player, type='check')
    assert result is True
```

### Integration Test Example

```python
from cli_runner import HeadlessGame

def test_school_events():
    \"\"\"Test school event sequence\"\"\"
    game = HeadlessGame()
    game.player.c.age = 16
    game.player.c.occupation = 'student'

    game.advance_to_date('2024-09-01')

    events = game.get_events()
    assert any('school' in e.get('fname', '') for e in events)
```

## Test Modes

### Unit Tests
- **Speed**: <30 seconds
- **Dependencies**: None (all mocked)
- **Database**: In-memory
- **OpenAI**: Mocked responses

### Integration Tests
- **Speed**: <2 minutes
- **Dependencies**: Optional MySQL, OpenAI
- **Database**: Switchable (in-memory or test DB)
- **OpenAI**: Switchable (mock or real API)

### Configuration

Use environment variables:

```bash
# Force test mode
export TEST_MODE=true

# Use mock OpenAI
export USE_MOCK_OPENAI=true

# Use test database
export DB_NAME=lifesim_test
```

## Common Patterns

### Testing Events

```python
def test_event_triggers(player):
    # Set up conditions
    player.c.age = 16
    player.c.occupation = 'student'

    # Check if event should trigger
    result = eventFunction(player, type='check')
    assert result is True
```

### Testing Event Chains

```python
def test_event_sequence():
    game = HeadlessGame()

    # Set initial state
    game.player.c.age = 15

    # Run simulation
    game.advance_to_age(18)

    # Verify events occurred in order
    events = game.get_events()
    event_names = [e['fname'] for e in events]

    assert 'eventA' in event_names
    assert 'eventB' in event_names
    assert event_names.index('eventA') < event_names.index('eventB')
```

### Testing Stats

```python
def test_stat_calculation():
    energy = getPeakEnergy(age=25)
    assert 0 <= energy <= 100
```

## Debugging Tests

### Verbose Output

```bash
python run_tests.py -v
```

### Run Single Test

```bash
pytest tests/unit/test_events.py::TestSchoolEvents::test_like_school_triggers_for_students -v
```

### Debug with breakpoint

```python
def test_something():
    game = HeadlessGame()
    breakpoint()  # Drops into debugger
    game.tick(100)
```

## Continuous Integration

Tests run automatically on:
- Every push (unit tests)
- Pull requests (full suite)
- Nightly (slow integration tests)

## Troubleshooting

### Tests fail with "MySQL connection error"
- Set `TEST_MODE=true` to use in-memory storage
- Or ensure MySQL test database exists

### Tests timeout
- Reduce max_ticks in advance_to_* methods
- Use unit tests instead of integration tests

### Mock OpenAI not working
- Set `USE_MOCK_OPENAI=true`
- Check conversation service initialization

## Performance

### Test Speed Targets
- Unit tests: <0.1s each
- Integration tests: <10s each
- Full suite: <2 minutes

### Optimization Tips
- Use fixtures to avoid repeated setup
- Mock external services
- Use `max_ticks` parameter to prevent infinite loops
- Run slow tests with `@pytest.mark.slow`

## Contributing Tests

When adding new features:
1. Write unit tests for business logic
2. Write integration test for feature flow
3. Add fixtures if needed
4. Update this documentation
5. Ensure tests pass: `python run_tests.py`
```

---

## Files Summary

### New Files Created (28 files)

**Infrastructure**:
- `ws/requirements-dev.txt` - Test dependencies
- `ws/tests/__init__.py` - Test package marker
- `ws/tests/conftest.py` - Pytest configuration

**Abstractions**:
- `ws/storage.py` - Storage interface + MySQL/memory implementations
- `ws/output.py` - Output interface + WebSocket implementation
- `ws/services.py` - Service interface + OpenAI/mock implementations

**Mocks**:
- `ws/tests/mocks/__init__.py`
- `ws/tests/mocks/output_mock.py` - Mock output collector
- `ws/tests/mocks/storage_mock.py` - In-memory storage
- `ws/tests/mocks/services_mock.py` - Mock OpenAI service
- `ws/tests/mocks/time_mock.py` - Controllable time

**Fixtures**:
- `ws/tests/fixtures/__init__.py`
- `ws/tests/fixtures/player_fixtures.py` - Test player factories
- `ws/tests/fixtures/scenario_fixtures.py` - Common scenarios

**CLI Tools**:
- `ws/cli_runner.py` - Headless game engine
- `ws/run_tests.py` - Test runner script

**Unit Tests**:
- `ws/tests/unit/__init__.py`
- `ws/tests/unit/test_events.py` - Event logic tests
- `ws/tests/unit/test_stats.py` - Stat calculation tests
- `ws/tests/unit/test_characters.py` - Character creation tests
- `ws/tests/unit/test_daily_plans.py` - Schedule generation tests
- `ws/tests/unit/test_relationships.py` - Relationship logic tests

**Integration Tests**:
- `ws/tests/integration/__init__.py`
- `ws/tests/integration/test_game_loop.py` - Full loop tests
- `ws/tests/integration/test_event_chains.py` - Multi-event tests
- `ws/tests/integration/test_lifecycle.py` - Birth to death tests
- `ws/tests/integration/test_save_load.py` - Persistence tests
- `ws/tests/integration/test_conversations.py` - NPC conversation tests

**Documentation**:
- `TESTING.md` - Complete testing guide

### Modified Files (3 files)

- `ws/app.py` - Add test mode, extract sync tick function
- `ws/functions.py` - Add TEST_MODE flag, use storage abstraction
- `ws/conversationEvents.py` - Use conversation service interface

---

## Success Criteria Checklist

✅ **Infrastructure**
- [ ] Test directory structure created
- [ ] Mock infrastructure implemented
- [ ] Test fixtures available

✅ **Core Refactoring**
- [ ] Game loop decoupled from WebSocket
- [ ] Storage abstraction layer created
- [ ] Output abstraction layer created
- [ ] External services mockable

✅ **CLI Tools**
- [ ] HeadlessGame class working
- [ ] CLI test runner functional
- [ ] Can run game without WebSocket

✅ **Test Suite**
- [ ] 50+ unit tests passing
- [ ] 20+ integration tests passing
- [ ] Tests run in <30s (unit), <2m (integration)
- [ ] 70%+ code coverage

✅ **Documentation**
- [ ] TESTING.md created
- [ ] Usage examples provided
- [ ] Troubleshooting guide included

✅ **Compatibility**
- [ ] Existing WebSocket server still works
- [ ] No breaking changes to production code
- [ ] Backward compatible

---

## Timeline

### Day 1 (4-6 hours)
- Phase 1: Testing infrastructure setup
- Phase 2: Core refactoring

### Day 2 (4-6 hours)
- Phase 3: CLI test runner
- Phase 4: Initial unit tests

### Day 3 (3-5 hours)
- Phase 4: Complete test suite
- Phase 5: Documentation

### Day 4 (2-3 hours)
- Testing and debugging
- CI/CD setup (optional)

**Total: 15-22 hours over 3-5 days**

---

## Dependencies

**None** - This is a foundational plan

## Risks & Mitigations

| Risk | Impact | Mitigation |
|------|--------|------------|
| Breaking existing WebSocket server | High | Maintain backward compatibility, add optional parameters |
| Tests too slow | Medium | Use mocks aggressively, set timeouts |
| Hard to test async code | Medium | Create sync wrappers, use pytest-asyncio |
| Mock diverges from real behavior | Medium | Integration tests with real services, document differences |

---

## Next Steps After Completion

Once testing infrastructure is in place:
1. **Security hardening** - Safe to refactor with test coverage
2. **Performance optimization** - Can measure with confidence
3. **Feature development** - TDD workflow enabled
4. **Refactoring** - Protected by test suite
