Spaces:
Paused
feat: refactor tests and logging
Browse files-Created centralized test infrastructure with TestLogger and TestResults classes in tests/test_utils.py for standardized logging and result tracking
-Implemented environment-based debug control using YUGA_DEBUG variable for flexible output levels (clean for CI/CD, detailed for debugging)
-Refactored test_calendar_operations.py from basic print statements to professional test structure with logging, assertions, and validation
-Enhanced test_task_composer_agent.py to maintain pytest compatibility while adding comprehensive logging and dual execution support
-Updated test_constraints.py by adding logging to setup methods and key test functions while preserving all existing pytest structure
-Transformed test_factory.py (800+ lines) by converting all print statements to appropriate logging levels with professional output structure
-Established dual execution support enabling both pytest tests/test_*.py -v and direct python tests/test_*.py execution patterns
-Integrated with existing project logging system (utils.logging_config) for consistent behavior across the entire codebase
-Maintained full pytest compatibility preserving all fixtures, async tests, and discovery while adding new debugging capabilities
-Created scalable patterns and reusable utilities that ensure long-term maintainability for future test files
- Makefile +1 -1
- src/utils/logging_config.py +190 -9
- tests/README_TESTS.md +278 -0
- tests/test_calendar_operations.py +60 -13
- tests/test_constraints.py +102 -0
- tests/test_factory.py +190 -82
- tests/test_task_composer_agent.py +48 -22
- tests/test_utils.py +210 -0
@@ -29,7 +29,7 @@ run:
|
|
29 |
$(ACTIVATE); $(PYTHON) src/app.py
|
30 |
|
31 |
test:
|
32 |
-
$(ACTIVATE); pytest
|
33 |
|
34 |
lint:
|
35 |
$(ACTIVATE); pre-commit run --all-files
|
|
|
29 |
$(ACTIVATE); $(PYTHON) src/app.py
|
30 |
|
31 |
test:
|
32 |
+
$(ACTIVATE); pytest -v -s
|
33 |
|
34 |
lint:
|
35 |
$(ACTIVATE); pre-commit run --all-files
|
@@ -34,10 +34,150 @@ Migration from old logging:
|
|
34 |
logger = get_logger(__name__)
|
35 |
"""
|
36 |
|
37 |
-
import logging
|
38 |
-
|
39 |
from typing import Optional
|
40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
41 |
|
42 |
def setup_logging(level: Optional[str] = None) -> None:
|
43 |
"""
|
@@ -46,20 +186,46 @@ def setup_logging(level: Optional[str] = None) -> None:
|
|
46 |
Args:
|
47 |
level: Override the logging level. If None, uses YUGA_DEBUG environment variable.
|
48 |
"""
|
|
|
|
|
49 |
# Determine logging level
|
50 |
if level is not None:
|
51 |
log_level = getattr(logging, level.upper(), logging.INFO)
|
52 |
-
|
53 |
else:
|
54 |
debug_enabled = os.getenv("YUGA_DEBUG", "false").lower() == "true"
|
55 |
log_level = logging.DEBUG if debug_enabled else logging.INFO
|
56 |
|
57 |
-
#
|
58 |
-
logging.
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
63 |
|
64 |
# Log the configuration
|
65 |
logger = logging.getLogger(__name__)
|
@@ -82,3 +248,18 @@ def get_logger(name: str) -> logging.Logger:
|
|
82 |
def is_debug_enabled() -> bool:
|
83 |
"""Check if debug logging is enabled via environment variable."""
|
84 |
return os.getenv("YUGA_DEBUG", "false").lower() == "true"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
34 |
logger = get_logger(__name__)
|
35 |
"""
|
36 |
|
37 |
+
import os, sys, logging, threading, time
|
38 |
+
|
39 |
from typing import Optional
|
40 |
|
41 |
+
from collections import deque
|
42 |
+
|
43 |
+
|
44 |
+
class LogCapture:
|
45 |
+
"""Capture logs for real-time streaming to UI"""
|
46 |
+
|
47 |
+
def __init__(self, max_lines: int = 1000):
|
48 |
+
self.max_lines = max_lines
|
49 |
+
self.log_buffer = deque(maxlen=max_lines)
|
50 |
+
self.session_buffer = deque(maxlen=max_lines) # Current session logs
|
51 |
+
self.lock = threading.Lock()
|
52 |
+
self.session_start_time = None
|
53 |
+
|
54 |
+
def add_log(self, record: logging.LogRecord):
|
55 |
+
"""Add a log record to the UI streaming buffer (filtered for essential logs only)"""
|
56 |
+
# This only affects UI streaming - console logs are handled separately
|
57 |
+
logger_name = record.name
|
58 |
+
message = record.getMessage()
|
59 |
+
|
60 |
+
# Skip all UI, gradio, httpx, and other system logs for UI streaming
|
61 |
+
skip_loggers = [
|
62 |
+
"gradio",
|
63 |
+
"httpx",
|
64 |
+
"uvicorn",
|
65 |
+
"fastapi",
|
66 |
+
"urllib3",
|
67 |
+
"ui.pages.chat",
|
68 |
+
"ui.",
|
69 |
+
"asyncio",
|
70 |
+
"websockets",
|
71 |
+
"handlers.tool_call_handler",
|
72 |
+
"services.mcp_client",
|
73 |
+
]
|
74 |
+
|
75 |
+
# Skip if it's a system logger
|
76 |
+
if any(skip in logger_name for skip in skip_loggers):
|
77 |
+
return
|
78 |
+
|
79 |
+
# Only include essential task splitting and constraint solver logs for UI
|
80 |
+
essential_patterns = [
|
81 |
+
"=== Step 1: Task Breakdown ===",
|
82 |
+
"=== Step 2: Time Estimation ===",
|
83 |
+
"=== Step 3: Skill Matching ===",
|
84 |
+
"Processing",
|
85 |
+
"tasks for time estimation",
|
86 |
+
"Completed time estimation",
|
87 |
+
"Completed skill matching",
|
88 |
+
"Generated",
|
89 |
+
"tasks with skills",
|
90 |
+
"Starting solve process",
|
91 |
+
"Preparing schedule for solving",
|
92 |
+
"Starting schedule solver",
|
93 |
+
"solving",
|
94 |
+
"constraint",
|
95 |
+
"optimization",
|
96 |
+
]
|
97 |
+
|
98 |
+
# Check if this log message contains essential information
|
99 |
+
is_essential = any(
|
100 |
+
pattern.lower() in message.lower() for pattern in essential_patterns
|
101 |
+
)
|
102 |
+
|
103 |
+
# Only include essential logs from factory and handler modules for UI
|
104 |
+
allowed_modules = ["factory.", "handlers.mcp_backend", "services.schedule"]
|
105 |
+
module_allowed = any(
|
106 |
+
logger_name.startswith(module) for module in allowed_modules
|
107 |
+
)
|
108 |
+
|
109 |
+
if not (module_allowed and is_essential):
|
110 |
+
return
|
111 |
+
|
112 |
+
# Format for clean streaming display in UI
|
113 |
+
timestamp = time.strftime("%H:%M:%S", time.localtime(record.created))
|
114 |
+
|
115 |
+
# Clean up the message for better display
|
116 |
+
match message:
|
117 |
+
case msg if "===" in msg:
|
118 |
+
# Task breakdown steps
|
119 |
+
formatted_log = f"β³ {msg.replace('===', '').strip()}"
|
120 |
+
|
121 |
+
case msg if "Processing" in msg and "time estimation" in msg:
|
122 |
+
formatted_log = f"β±οΈ {msg}"
|
123 |
+
|
124 |
+
case msg if "Completed" in msg:
|
125 |
+
formatted_log = f"β
{msg}"
|
126 |
+
|
127 |
+
case msg if "Generated" in msg and "tasks" in msg:
|
128 |
+
formatted_log = f"π― {msg}"
|
129 |
+
|
130 |
+
case msg if "Starting solve process" in msg or "Starting schedule solver" in msg:
|
131 |
+
formatted_log = f"β‘ {msg}"
|
132 |
+
|
133 |
+
case msg if "Preparing schedule" in msg:
|
134 |
+
formatted_log = f"π {msg}"
|
135 |
+
|
136 |
+
case _:
|
137 |
+
formatted_log = f"π§ {message}"
|
138 |
+
|
139 |
+
with self.lock:
|
140 |
+
self.log_buffer.append(formatted_log)
|
141 |
+
|
142 |
+
# Add to session buffer if session is active
|
143 |
+
if self.session_start_time and record.created >= self.session_start_time:
|
144 |
+
self.session_buffer.append(formatted_log)
|
145 |
+
|
146 |
+
def start_session(self):
|
147 |
+
"""Start capturing logs for current session"""
|
148 |
+
with self.lock:
|
149 |
+
self.session_start_time = time.time()
|
150 |
+
self.session_buffer.clear()
|
151 |
+
|
152 |
+
def get_session_logs(self) -> list:
|
153 |
+
"""Get all logs from current session"""
|
154 |
+
with self.lock:
|
155 |
+
return list(self.session_buffer)
|
156 |
+
|
157 |
+
def get_recent_logs(self, count: int = 50) -> list:
|
158 |
+
"""Get recent logs"""
|
159 |
+
with self.lock:
|
160 |
+
return list(self.log_buffer)[-count:]
|
161 |
+
|
162 |
+
|
163 |
+
class StreamingLogHandler(logging.Handler):
|
164 |
+
"""Custom log handler that captures logs for streaming"""
|
165 |
+
|
166 |
+
def __init__(self, log_capture: LogCapture):
|
167 |
+
super().__init__()
|
168 |
+
self.log_capture = log_capture
|
169 |
+
|
170 |
+
def emit(self, record):
|
171 |
+
try:
|
172 |
+
self.log_capture.add_log(record)
|
173 |
+
except Exception:
|
174 |
+
self.handleError(record)
|
175 |
+
|
176 |
+
|
177 |
+
# Global log capture instance
|
178 |
+
_log_capture = LogCapture()
|
179 |
+
_streaming_handler = None
|
180 |
+
|
181 |
|
182 |
def setup_logging(level: Optional[str] = None) -> None:
|
183 |
"""
|
|
|
186 |
Args:
|
187 |
level: Override the logging level. If None, uses YUGA_DEBUG environment variable.
|
188 |
"""
|
189 |
+
global _streaming_handler
|
190 |
+
|
191 |
# Determine logging level
|
192 |
if level is not None:
|
193 |
log_level = getattr(logging, level.upper(), logging.INFO)
|
|
|
194 |
else:
|
195 |
debug_enabled = os.getenv("YUGA_DEBUG", "false").lower() == "true"
|
196 |
log_level = logging.DEBUG if debug_enabled else logging.INFO
|
197 |
|
198 |
+
# Get root logger
|
199 |
+
root_logger = logging.getLogger()
|
200 |
+
|
201 |
+
# Only configure if not already configured
|
202 |
+
if not root_logger.handlers or _streaming_handler is None:
|
203 |
+
# Clear existing handlers to avoid duplicates
|
204 |
+
root_logger.handlers.clear()
|
205 |
+
|
206 |
+
# Create formatter
|
207 |
+
formatter = logging.Formatter(
|
208 |
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
209 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
210 |
+
)
|
211 |
+
|
212 |
+
# Console handler for terminal output (shows ALL logs)
|
213 |
+
console_handler = logging.StreamHandler(sys.stdout)
|
214 |
+
console_handler.setLevel(log_level)
|
215 |
+
console_handler.setFormatter(formatter)
|
216 |
+
|
217 |
+
# Streaming handler for UI capture (filtered to essential logs only)
|
218 |
+
_streaming_handler = StreamingLogHandler(_log_capture)
|
219 |
+
_streaming_handler.setLevel(
|
220 |
+
logging.DEBUG
|
221 |
+
) # Capture all levels, but filter in handler
|
222 |
+
|
223 |
+
# Configure root logger
|
224 |
+
root_logger.setLevel(logging.DEBUG)
|
225 |
+
|
226 |
+
# Add both handlers
|
227 |
+
root_logger.addHandler(console_handler)
|
228 |
+
root_logger.addHandler(_streaming_handler)
|
229 |
|
230 |
# Log the configuration
|
231 |
logger = logging.getLogger(__name__)
|
|
|
248 |
def is_debug_enabled() -> bool:
|
249 |
"""Check if debug logging is enabled via environment variable."""
|
250 |
return os.getenv("YUGA_DEBUG", "false").lower() == "true"
|
251 |
+
|
252 |
+
|
253 |
+
def get_log_capture() -> LogCapture:
|
254 |
+
"""Get the global log capture instance for UI streaming"""
|
255 |
+
return _log_capture
|
256 |
+
|
257 |
+
|
258 |
+
def start_session_logging():
|
259 |
+
"""Start capturing logs for the current chat session"""
|
260 |
+
_log_capture.start_session()
|
261 |
+
|
262 |
+
|
263 |
+
def get_session_logs() -> list:
|
264 |
+
"""Get all logs from the current session for streaming to UI"""
|
265 |
+
return _log_capture.get_session_logs()
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Yuga Planner Test Framework Instructions
|
2 |
+
|
3 |
+
## Overview
|
4 |
+
This document provides instructions for writing, running, and maintaining tests in the Yuga Planner project using our standardized test framework.
|
5 |
+
|
6 |
+
## Quick Start
|
7 |
+
|
8 |
+
### Running Tests
|
9 |
+
|
10 |
+
#### Standard Testing (recommended for CI/CD)
|
11 |
+
```bash
|
12 |
+
pytest tests/test_*.py -v
|
13 |
+
```
|
14 |
+
|
15 |
+
#### Debug Mode (detailed output for troubleshooting)
|
16 |
+
```bash
|
17 |
+
YUGA_DEBUG=true pytest tests/test_*.py -v -s
|
18 |
+
```
|
19 |
+
|
20 |
+
#### Direct Execution (individual test files)
|
21 |
+
```bash
|
22 |
+
python tests/test_specific_file.py
|
23 |
+
YUGA_DEBUG=true python tests/test_specific_file.py # with debug output
|
24 |
+
```
|
25 |
+
|
26 |
+
## Writing Tests
|
27 |
+
|
28 |
+
### 1. Basic Test Structure
|
29 |
+
|
30 |
+
Every test file should follow this pattern:
|
31 |
+
|
32 |
+
```python
|
33 |
+
import sys
|
34 |
+
from tests.test_utils import get_test_logger, create_test_results
|
35 |
+
|
36 |
+
# Initialize logging
|
37 |
+
logger = get_test_logger(__name__)
|
38 |
+
|
39 |
+
def test_your_feature():
|
40 |
+
"""Test function that works with both pytest and direct execution."""
|
41 |
+
logger.start_test("Description of what you're testing")
|
42 |
+
|
43 |
+
try:
|
44 |
+
# Your test logic here
|
45 |
+
result = your_function_to_test()
|
46 |
+
|
47 |
+
# Use assertions for validation
|
48 |
+
assert result is not None, "Result should not be None"
|
49 |
+
assert result.status == "success", f"Expected success, got {result.status}"
|
50 |
+
|
51 |
+
logger.pass_test("Feature works correctly")
|
52 |
+
|
53 |
+
except Exception as e:
|
54 |
+
logger.fail_test(f"Test failed: {str(e)}")
|
55 |
+
raise
|
56 |
+
|
57 |
+
# Direct execution support
|
58 |
+
if __name__ == "__main__":
|
59 |
+
results = create_test_results(logger)
|
60 |
+
results.run_test('test_your_feature', test_your_feature)
|
61 |
+
all_passed = results.summary()
|
62 |
+
sys.exit(0 if all_passed else 1)
|
63 |
+
```
|
64 |
+
|
65 |
+
### 2. Test Utilities Reference
|
66 |
+
|
67 |
+
#### TestLogger Methods
|
68 |
+
|
69 |
+
```python
|
70 |
+
from tests.test_utils import get_test_logger
|
71 |
+
logger = get_test_logger(__name__)
|
72 |
+
|
73 |
+
# Test lifecycle
|
74 |
+
logger.start_test("Test description") # Mark test beginning
|
75 |
+
logger.pass_test("Success message") # Log successful completion
|
76 |
+
logger.fail_test("Error message") # Log test failure
|
77 |
+
|
78 |
+
# Organization
|
79 |
+
logger.section("Section Title") # Create visual separators
|
80 |
+
|
81 |
+
# Standard logging levels
|
82 |
+
logger.debug("Detailed debug information")
|
83 |
+
logger.info("General information")
|
84 |
+
logger.warning("Warning message")
|
85 |
+
logger.error("Error message")
|
86 |
+
```
|
87 |
+
|
88 |
+
#### TestResults Methods
|
89 |
+
|
90 |
+
```python
|
91 |
+
from tests.test_utils import create_test_results
|
92 |
+
|
93 |
+
results = create_test_results(logger)
|
94 |
+
|
95 |
+
# Run tests with automatic error handling
|
96 |
+
results.run_test('test_name', test_function)
|
97 |
+
|
98 |
+
# Generate summary and get overall result
|
99 |
+
all_passed = results.summary() # Returns True if all tests passed
|
100 |
+
|
101 |
+
# Use for exit codes
|
102 |
+
sys.exit(0 if all_passed else 1)
|
103 |
+
```
|
104 |
+
|
105 |
+
### 3. Async Test Pattern
|
106 |
+
|
107 |
+
For async tests, use this pattern:
|
108 |
+
|
109 |
+
```python
|
110 |
+
import asyncio
|
111 |
+
import pytest
|
112 |
+
|
113 |
+
@pytest.mark.asyncio
|
114 |
+
async def test_async_feature():
|
115 |
+
"""Async test that works with pytest."""
|
116 |
+
logger.start_test("Testing async functionality")
|
117 |
+
|
118 |
+
try:
|
119 |
+
result = await your_async_function()
|
120 |
+
assert result.is_valid(), "Async result should be valid"
|
121 |
+
logger.pass_test("Async functionality works")
|
122 |
+
except Exception as e:
|
123 |
+
logger.fail_test(f"Async test failed: {str(e)}")
|
124 |
+
raise
|
125 |
+
|
126 |
+
# For direct execution of async tests
|
127 |
+
async def run_async_tests():
|
128 |
+
"""Helper for running async tests directly."""
|
129 |
+
logger.section("Async Tests")
|
130 |
+
await test_async_feature()
|
131 |
+
|
132 |
+
if __name__ == "__main__":
|
133 |
+
results = create_test_results(logger)
|
134 |
+
# Use asyncio.run for async test execution
|
135 |
+
results.run_test('async_tests', lambda: asyncio.run(run_async_tests()))
|
136 |
+
all_passed = results.summary()
|
137 |
+
sys.exit(0 if all_passed else 1)
|
138 |
+
```
|
139 |
+
|
140 |
+
### 4. Complex Test Files
|
141 |
+
|
142 |
+
For files with multiple test functions:
|
143 |
+
|
144 |
+
```python
|
145 |
+
def test_feature_one():
|
146 |
+
logger.start_test("Testing feature one")
|
147 |
+
# ... test logic ...
|
148 |
+
logger.pass_test("Feature one works")
|
149 |
+
|
150 |
+
def test_feature_two():
|
151 |
+
logger.start_test("Testing feature two")
|
152 |
+
# ... test logic ...
|
153 |
+
logger.pass_test("Feature two works")
|
154 |
+
|
155 |
+
def test_integration():
|
156 |
+
logger.start_test("Testing integration")
|
157 |
+
# ... test logic ...
|
158 |
+
logger.pass_test("Integration works")
|
159 |
+
|
160 |
+
if __name__ == "__main__":
|
161 |
+
results = create_test_results(logger)
|
162 |
+
|
163 |
+
# Run all tests
|
164 |
+
results.run_test('feature_one', test_feature_one)
|
165 |
+
results.run_test('feature_two', test_feature_two)
|
166 |
+
results.run_test('integration', test_integration)
|
167 |
+
|
168 |
+
# Generate summary
|
169 |
+
all_passed = results.summary()
|
170 |
+
sys.exit(0 if all_passed else 1)
|
171 |
+
```
|
172 |
+
|
173 |
+
## Environment Control
|
174 |
+
|
175 |
+
### Debug Output Control
|
176 |
+
|
177 |
+
The framework respects the `YUGA_DEBUG` environment variable:
|
178 |
+
|
179 |
+
- **`YUGA_DEBUG=false` or unset**: Minimal output suitable for CI/CD
|
180 |
+
- **`YUGA_DEBUG=true`**: Detailed debug output for troubleshooting
|
181 |
+
|
182 |
+
### Usage Examples
|
183 |
+
|
184 |
+
```bash
|
185 |
+
# Quiet mode (default)
|
186 |
+
pytest tests/test_factory.py -v
|
187 |
+
|
188 |
+
# Debug mode
|
189 |
+
YUGA_DEBUG=true pytest tests/test_factory.py -v -s
|
190 |
+
|
191 |
+
# Direct execution with debug
|
192 |
+
YUGA_DEBUG=true python tests/test_constraints.py
|
193 |
+
```
|
194 |
+
|
195 |
+
## Best Practices
|
196 |
+
|
197 |
+
### 1. Test Organization
|
198 |
+
|
199 |
+
- Use descriptive test function names: `test_calendar_event_creation_with_constraints`
|
200 |
+
- Group related tests in the same file
|
201 |
+
- Use `logger.section()` to separate different test groups within a file
|
202 |
+
|
203 |
+
### 2. Error Messages
|
204 |
+
|
205 |
+
- Always provide clear assertion messages:
|
206 |
+
```python
|
207 |
+
assert result.count == 5, f"Expected 5 items, got {result.count}"
|
208 |
+
```
|
209 |
+
|
210 |
+
### 3. Test Lifecycle
|
211 |
+
|
212 |
+
- Always use `logger.start_test()` at the beginning of each test
|
213 |
+
- Use `logger.pass_test()` or `logger.fail_test()` to mark completion
|
214 |
+
- Let exceptions propagate for pytest compatibility
|
215 |
+
|
216 |
+
### 4. Output Structure
|
217 |
+
|
218 |
+
- Use sections to organize output:
|
219 |
+
```python
|
220 |
+
logger.section("Calendar Operations Tests")
|
221 |
+
# ... run calendar tests ...
|
222 |
+
|
223 |
+
logger.section("Task Management Tests")
|
224 |
+
# ... run task tests ...
|
225 |
+
```
|
226 |
+
|
227 |
+
## Integration with Existing Code
|
228 |
+
|
229 |
+
### Pytest Compatibility
|
230 |
+
|
231 |
+
The framework is fully compatible with existing pytest features:
|
232 |
+
|
233 |
+
- Test discovery works without changes
|
234 |
+
- Fixtures continue to work normally
|
235 |
+
- Async tests work with `@pytest.mark.asyncio`
|
236 |
+
- All pytest command-line options are supported
|
237 |
+
|
238 |
+
### Logging Integration
|
239 |
+
|
240 |
+
- Integrates with project's `utils.logging_config`
|
241 |
+
- Respects existing logging configuration
|
242 |
+
- No interference with application logging
|
243 |
+
|
244 |
+
## Troubleshooting
|
245 |
+
|
246 |
+
### Common Issues
|
247 |
+
|
248 |
+
1. **Tests run but no output**: Ensure you're using `-s` flag with pytest in debug mode
|
249 |
+
2. **Import errors**: Make sure `tests/test_utils.py` is accessible
|
250 |
+
3. **Async tests failing**: Use `@pytest.mark.asyncio` for pytest, `asyncio.run()` for direct execution
|
251 |
+
|
252 |
+
### Debug Mode Benefits
|
253 |
+
|
254 |
+
When `YUGA_DEBUG=true`:
|
255 |
+
- Detailed function entry/exit logging
|
256 |
+
- Variable state information
|
257 |
+
- Extended error messages
|
258 |
+
- Test timing information
|
259 |
+
|
260 |
+
## Example Test Files
|
261 |
+
|
262 |
+
Refer to these existing test files for patterns:
|
263 |
+
|
264 |
+
- `tests/test_calendar_operations.py` - Basic synchronous tests
|
265 |
+
- `tests/test_task_composer_agent.py` - Async test patterns
|
266 |
+
- `tests/test_constraints.py` - Large pytest-based test suite
|
267 |
+
- `tests/test_factory.py` - Complex test file with multiple test types
|
268 |
+
|
269 |
+
## Summary
|
270 |
+
|
271 |
+
This test framework provides:
|
272 |
+
- **Consistency** across all test files
|
273 |
+
- **Flexibility** for different execution modes
|
274 |
+
- **Professional** output suitable for development and CI/CD
|
275 |
+
- **Maintainability** through centralized utilities
|
276 |
+
- **Compatibility** with existing pytest workflows
|
277 |
+
|
278 |
+
Follow these patterns for all new tests to maintain consistency and leverage the full power of the test framework.
|
@@ -1,23 +1,70 @@
|
|
1 |
import icalendar
|
2 |
-
|
3 |
from pathlib import Path
|
4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
5 |
|
6 |
def test_calendar_operations():
|
|
|
|
|
|
|
|
|
7 |
ics_path = Path("tests/data/calendar.ics")
|
8 |
|
|
|
|
|
|
|
|
|
9 |
calendar = icalendar.Calendar.from_ical(ics_path.read_bytes())
|
10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
for event in calendar.events:
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
import icalendar
|
2 |
+
import sys
|
3 |
from pathlib import Path
|
4 |
|
5 |
+
# Import standardized test utilities
|
6 |
+
from tests.test_utils import get_test_logger, create_test_results
|
7 |
+
|
8 |
+
# Initialize standardized test logger
|
9 |
+
logger = get_test_logger(__name__)
|
10 |
+
|
11 |
|
12 |
def test_calendar_operations():
|
13 |
+
"""Test basic calendar operations and parsing"""
|
14 |
+
|
15 |
+
logger.start_test("Testing calendar operations and parsing")
|
16 |
+
|
17 |
ics_path = Path("tests/data/calendar.ics")
|
18 |
|
19 |
+
# Verify test data exists
|
20 |
+
assert ics_path.exists(), f"Test calendar file not found: {ics_path}"
|
21 |
+
logger.debug(f"Reading calendar from: {ics_path}")
|
22 |
+
|
23 |
calendar = icalendar.Calendar.from_ical(ics_path.read_bytes())
|
24 |
|
25 |
+
def to_iso(val):
|
26 |
+
if hasattr(val, "dt"):
|
27 |
+
dt = val.dt
|
28 |
+
if hasattr(dt, "isoformat"):
|
29 |
+
return dt.isoformat()
|
30 |
+
return str(dt)
|
31 |
+
return str(val)
|
32 |
+
|
33 |
+
event_count = 0
|
34 |
+
|
35 |
for event in calendar.events:
|
36 |
+
event_count += 1
|
37 |
+
summary = event.get("summary")
|
38 |
+
start_time = to_iso(event.get("dtstart"))
|
39 |
+
end_time = to_iso(event.get("dtend"))
|
40 |
+
|
41 |
+
logger.debug(f"Event {event_count}: {summary}")
|
42 |
+
logger.debug(f" Start: {start_time}")
|
43 |
+
logger.debug(f" End: {end_time}")
|
44 |
+
|
45 |
+
# Basic validation
|
46 |
+
assert summary is not None, f"Event {event_count} should have a summary"
|
47 |
+
assert start_time is not None, f"Event {event_count} should have a start time"
|
48 |
+
|
49 |
+
logger.info(f"β
Successfully parsed {event_count} calendar events")
|
50 |
+
|
51 |
+
# Verify we found some events
|
52 |
+
assert event_count > 0, "Calendar should contain at least one event"
|
53 |
+
|
54 |
+
logger.pass_test(
|
55 |
+
f"Calendar operations work correctly - parsed {event_count} events"
|
56 |
+
)
|
57 |
+
|
58 |
+
|
59 |
+
if __name__ == "__main__":
|
60 |
+
logger.section("Calendar Operations Tests")
|
61 |
+
|
62 |
+
# Create test results tracker
|
63 |
+
results = create_test_results(logger)
|
64 |
+
|
65 |
+
# Run the test
|
66 |
+
results.run_test("calendar_operations", test_calendar_operations)
|
67 |
+
|
68 |
+
# Generate summary and exit with appropriate code
|
69 |
+
all_passed = results.summary()
|
70 |
+
sys.exit(0 if all_passed else 1)
|
@@ -1,9 +1,16 @@
|
|
1 |
import pytest
|
|
|
2 |
from datetime import date, timedelta
|
3 |
from decimal import Decimal
|
4 |
from timefold.solver.test import ConstraintVerifier
|
5 |
from timefold.solver.score import HardSoftDecimalScore
|
6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
7 |
from src.constraint_solvers.timetable.constraints import (
|
8 |
define_constraints,
|
9 |
required_skill,
|
@@ -36,6 +43,8 @@ class TestConstraints:
|
|
36 |
|
37 |
def setup_method(self):
|
38 |
"""Set up common test data and ConstraintVerifier instance."""
|
|
|
|
|
39 |
self.constraint_verifier = ConstraintVerifier.build(
|
40 |
define_constraints, EmployeeSchedule, Task
|
41 |
)
|
@@ -50,10 +59,14 @@ class TestConstraints:
|
|
50 |
self.employee_bob = self.employees["bob"]
|
51 |
self.employee_charlie = self.employees["charlie"]
|
52 |
|
|
|
|
|
53 |
# ==================== HARD CONSTRAINT TESTS ====================
|
54 |
|
55 |
def test_required_skill_constraint_violation(self):
|
56 |
"""Test that tasks requiring skills not possessed by assigned employee are penalized."""
|
|
|
|
|
57 |
task = create_task(
|
58 |
task_id="task1",
|
59 |
description="Python Development",
|
@@ -67,8 +80,12 @@ class TestConstraints:
|
|
67 |
.penalizes_by(1)
|
68 |
)
|
69 |
|
|
|
|
|
70 |
def test_required_skill_constraint_satisfied(self):
|
71 |
"""Test that tasks assigned to employees with required skills are not penalized."""
|
|
|
|
|
72 |
task = create_task(
|
73 |
task_id="task1",
|
74 |
description="Python Development",
|
@@ -82,8 +99,12 @@ class TestConstraints:
|
|
82 |
.penalizes_by(0)
|
83 |
)
|
84 |
|
|
|
|
|
85 |
def test_required_skill_constraint_unassigned_task(self):
|
86 |
"""Test that unassigned tasks don't trigger required skill constraint."""
|
|
|
|
|
87 |
task = create_task(
|
88 |
task_id="task1",
|
89 |
description="Python Development",
|
@@ -97,8 +118,12 @@ class TestConstraints:
|
|
97 |
.penalizes_by(0)
|
98 |
)
|
99 |
|
|
|
|
|
100 |
def test_no_overlapping_tasks_constraint_violation(self):
|
101 |
"""Test that overlapping tasks for the same employee are penalized."""
|
|
|
|
|
102 |
task1 = create_task(
|
103 |
task_id="task1",
|
104 |
description="Task 1",
|
@@ -124,8 +149,12 @@ class TestConstraints:
|
|
124 |
.penalizes_by(2)
|
125 |
)
|
126 |
|
|
|
|
|
127 |
def test_no_overlapping_tasks_constraint_different_employees(self):
|
128 |
"""Test that overlapping tasks for different employees are not penalized."""
|
|
|
|
|
129 |
task1 = create_task(
|
130 |
task_id="task1",
|
131 |
description="Task 1",
|
@@ -152,8 +181,12 @@ class TestConstraints:
|
|
152 |
.penalizes_by(0)
|
153 |
)
|
154 |
|
|
|
|
|
155 |
def test_no_overlapping_tasks_constraint_adjacent_tasks(self):
|
156 |
"""Test that adjacent (non-overlapping) tasks for the same employee are not penalized."""
|
|
|
|
|
157 |
task1 = create_task(
|
158 |
task_id="task1",
|
159 |
description="Task 1",
|
@@ -178,8 +211,12 @@ class TestConstraints:
|
|
178 |
.penalizes_by(0)
|
179 |
)
|
180 |
|
|
|
|
|
181 |
def test_task_within_schedule_constraint_violation(self):
|
182 |
"""Test that tasks starting before slot 0 are penalized."""
|
|
|
|
|
183 |
task = create_task(
|
184 |
task_id="task1",
|
185 |
description="Invalid Task",
|
@@ -194,8 +231,12 @@ class TestConstraints:
|
|
194 |
.penalizes_by(1)
|
195 |
)
|
196 |
|
|
|
|
|
197 |
def test_task_within_schedule_constraint_satisfied(self):
|
198 |
"""Test that tasks starting at valid slots are not penalized."""
|
|
|
|
|
199 |
task = create_task(
|
200 |
task_id="task1",
|
201 |
description="Valid Task",
|
@@ -210,6 +251,8 @@ class TestConstraints:
|
|
210 |
.penalizes_by(0)
|
211 |
)
|
212 |
|
|
|
|
|
213 |
def test_task_fits_in_schedule_constraint_violation(self):
|
214 |
"""Test that tasks extending beyond schedule end are penalized."""
|
215 |
task = create_task(
|
@@ -738,3 +781,62 @@ def create_standard_employees(dates):
|
|
738 |
skills={"Python", "Testing", "DevOps"},
|
739 |
),
|
740 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
import pytest
|
2 |
+
import sys
|
3 |
from datetime import date, timedelta
|
4 |
from decimal import Decimal
|
5 |
from timefold.solver.test import ConstraintVerifier
|
6 |
from timefold.solver.score import HardSoftDecimalScore
|
7 |
|
8 |
+
# Import standardized test utilities
|
9 |
+
from tests.test_utils import get_test_logger, create_test_results
|
10 |
+
|
11 |
+
# Initialize standardized test logger
|
12 |
+
logger = get_test_logger(__name__)
|
13 |
+
|
14 |
from src.constraint_solvers.timetable.constraints import (
|
15 |
define_constraints,
|
16 |
required_skill,
|
|
|
43 |
|
44 |
def setup_method(self):
|
45 |
"""Set up common test data and ConstraintVerifier instance."""
|
46 |
+
logger.debug("Setting up test constraints and data...")
|
47 |
+
|
48 |
self.constraint_verifier = ConstraintVerifier.build(
|
49 |
define_constraints, EmployeeSchedule, Task
|
50 |
)
|
|
|
59 |
self.employee_bob = self.employees["bob"]
|
60 |
self.employee_charlie = self.employees["charlie"]
|
61 |
|
62 |
+
logger.debug(f"Created {len(self.employees)} test employees and schedule info")
|
63 |
+
|
64 |
# ==================== HARD CONSTRAINT TESTS ====================
|
65 |
|
66 |
def test_required_skill_constraint_violation(self):
|
67 |
"""Test that tasks requiring skills not possessed by assigned employee are penalized."""
|
68 |
+
logger.debug("Testing required skill constraint violation...")
|
69 |
+
|
70 |
task = create_task(
|
71 |
task_id="task1",
|
72 |
description="Python Development",
|
|
|
80 |
.penalizes_by(1)
|
81 |
)
|
82 |
|
83 |
+
logger.debug("β
Required skill constraint violation test passed")
|
84 |
+
|
85 |
def test_required_skill_constraint_satisfied(self):
|
86 |
"""Test that tasks assigned to employees with required skills are not penalized."""
|
87 |
+
logger.debug("Testing required skill constraint satisfaction...")
|
88 |
+
|
89 |
task = create_task(
|
90 |
task_id="task1",
|
91 |
description="Python Development",
|
|
|
99 |
.penalizes_by(0)
|
100 |
)
|
101 |
|
102 |
+
logger.debug("β
Required skill constraint satisfaction test passed")
|
103 |
+
|
104 |
def test_required_skill_constraint_unassigned_task(self):
|
105 |
"""Test that unassigned tasks don't trigger required skill constraint."""
|
106 |
+
logger.debug("Testing required skill constraint with unassigned task...")
|
107 |
+
|
108 |
task = create_task(
|
109 |
task_id="task1",
|
110 |
description="Python Development",
|
|
|
118 |
.penalizes_by(0)
|
119 |
)
|
120 |
|
121 |
+
logger.debug("β
Required skill constraint unassigned task test passed")
|
122 |
+
|
123 |
def test_no_overlapping_tasks_constraint_violation(self):
|
124 |
"""Test that overlapping tasks for the same employee are penalized."""
|
125 |
+
logger.debug("Testing no overlapping tasks constraint violation...")
|
126 |
+
|
127 |
task1 = create_task(
|
128 |
task_id="task1",
|
129 |
description="Task 1",
|
|
|
149 |
.penalizes_by(2)
|
150 |
)
|
151 |
|
152 |
+
logger.debug("β
No overlapping tasks constraint violation test passed")
|
153 |
+
|
154 |
def test_no_overlapping_tasks_constraint_different_employees(self):
|
155 |
"""Test that overlapping tasks for different employees are not penalized."""
|
156 |
+
logger.debug("Testing no overlapping tasks with different employees...")
|
157 |
+
|
158 |
task1 = create_task(
|
159 |
task_id="task1",
|
160 |
description="Task 1",
|
|
|
181 |
.penalizes_by(0)
|
182 |
)
|
183 |
|
184 |
+
logger.debug("β
No overlapping tasks different employees test passed")
|
185 |
+
|
186 |
def test_no_overlapping_tasks_constraint_adjacent_tasks(self):
|
187 |
"""Test that adjacent (non-overlapping) tasks for the same employee are not penalized."""
|
188 |
+
logger.debug("Testing no overlapping tasks with adjacent tasks...")
|
189 |
+
|
190 |
task1 = create_task(
|
191 |
task_id="task1",
|
192 |
description="Task 1",
|
|
|
211 |
.penalizes_by(0)
|
212 |
)
|
213 |
|
214 |
+
logger.debug("β
No overlapping tasks adjacent tasks test passed")
|
215 |
+
|
216 |
def test_task_within_schedule_constraint_violation(self):
|
217 |
"""Test that tasks starting before slot 0 are penalized."""
|
218 |
+
logger.debug("Testing task within schedule constraint violation...")
|
219 |
+
|
220 |
task = create_task(
|
221 |
task_id="task1",
|
222 |
description="Invalid Task",
|
|
|
231 |
.penalizes_by(1)
|
232 |
)
|
233 |
|
234 |
+
logger.debug("β
Task within schedule constraint violation test passed")
|
235 |
+
|
236 |
def test_task_within_schedule_constraint_satisfied(self):
|
237 |
"""Test that tasks starting at valid slots are not penalized."""
|
238 |
+
logger.debug("Testing task within schedule constraint satisfaction...")
|
239 |
+
|
240 |
task = create_task(
|
241 |
task_id="task1",
|
242 |
description="Valid Task",
|
|
|
251 |
.penalizes_by(0)
|
252 |
)
|
253 |
|
254 |
+
logger.debug("β
Task within schedule constraint satisfaction test passed")
|
255 |
+
|
256 |
def test_task_fits_in_schedule_constraint_violation(self):
|
257 |
"""Test that tasks extending beyond schedule end are penalized."""
|
258 |
task = create_task(
|
|
|
781 |
skills={"Python", "Testing", "DevOps"},
|
782 |
),
|
783 |
}
|
784 |
+
|
785 |
+
|
786 |
+
if __name__ == "__main__":
|
787 |
+
"""Direct execution for non-pytest testing"""
|
788 |
+
logger.section("Constraint Solver Tests")
|
789 |
+
logger.info(
|
790 |
+
"Note: This test suite is designed for pytest. For best results, run with:"
|
791 |
+
)
|
792 |
+
logger.info(" pytest tests/test_constraints.py -v")
|
793 |
+
logger.info(" YUGA_DEBUG=true pytest tests/test_constraints.py -v -s")
|
794 |
+
|
795 |
+
# Create test results tracker
|
796 |
+
results = create_test_results(logger)
|
797 |
+
|
798 |
+
try:
|
799 |
+
# Create test instance
|
800 |
+
test_instance = TestConstraints()
|
801 |
+
test_instance.setup_method()
|
802 |
+
|
803 |
+
# Run a few sample tests
|
804 |
+
logger.info("Running sample constraint tests...")
|
805 |
+
|
806 |
+
sample_tests = [
|
807 |
+
(
|
808 |
+
"required_skill_violation",
|
809 |
+
test_instance.test_required_skill_constraint_violation,
|
810 |
+
),
|
811 |
+
(
|
812 |
+
"required_skill_satisfied",
|
813 |
+
test_instance.test_required_skill_constraint_satisfied,
|
814 |
+
),
|
815 |
+
(
|
816 |
+
"no_overlapping_violation",
|
817 |
+
test_instance.test_no_overlapping_tasks_constraint_violation,
|
818 |
+
),
|
819 |
+
(
|
820 |
+
"task_within_schedule",
|
821 |
+
test_instance.test_task_within_schedule_constraint_satisfied,
|
822 |
+
),
|
823 |
+
]
|
824 |
+
|
825 |
+
for test_name, test_func in sample_tests:
|
826 |
+
results.run_test(test_name, test_func)
|
827 |
+
|
828 |
+
logger.info(f"β
Completed {len(sample_tests)} sample constraint tests")
|
829 |
+
|
830 |
+
except Exception as e:
|
831 |
+
logger.error(f"Failed to run constraint tests: {e}")
|
832 |
+
results.add_result("constraint_tests_setup", False, str(e))
|
833 |
+
|
834 |
+
# Generate summary and exit with appropriate code
|
835 |
+
all_passed = results.summary()
|
836 |
+
|
837 |
+
if not all_passed:
|
838 |
+
logger.info(
|
839 |
+
"π‘ Hint: Use 'pytest tests/test_constraints.py' for full test coverage"
|
840 |
+
)
|
841 |
+
|
842 |
+
sys.exit(0 if all_passed else 1)
|
@@ -2,12 +2,19 @@ import pytest
|
|
2 |
import time
|
3 |
import pandas as pd
|
4 |
import traceback
|
|
|
5 |
from io import StringIO
|
6 |
from datetime import datetime, date, timedelta
|
7 |
from typing import List, Dict, Tuple, Optional, Any
|
8 |
|
9 |
from src.utils.load_secrets import load_secrets
|
10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
# Load environment variables for agent (if needed)
|
12 |
load_secrets("tests/secrets/creds.py")
|
13 |
|
@@ -26,9 +33,12 @@ def cleanup_solver():
|
|
26 |
|
27 |
# Cleanup: Terminate all active solver jobs and shutdown solver manager
|
28 |
try:
|
|
|
29 |
from constraint_solvers.timetable.solver import solver_manager
|
30 |
from src.state import app_state
|
31 |
|
|
|
|
|
32 |
# Clear all stored schedules first
|
33 |
app_state.clear_solved_schedules()
|
34 |
|
@@ -37,24 +47,43 @@ def cleanup_solver():
|
|
37 |
# According to Timefold docs, terminateEarly() affects all jobs for this manager
|
38 |
try:
|
39 |
solver_manager.terminateEarly()
|
40 |
-
|
|
|
|
|
|
|
|
|
41 |
except Exception as e:
|
42 |
-
|
43 |
|
44 |
# Try additional cleanup methods if available
|
45 |
if hasattr(solver_manager, "close"):
|
46 |
-
|
47 |
-
|
|
|
|
|
|
|
|
|
|
|
48 |
elif hasattr(solver_manager, "shutdown"):
|
49 |
-
|
50 |
-
|
|
|
|
|
|
|
|
|
|
|
51 |
else:
|
52 |
-
|
|
|
|
|
53 |
|
54 |
-
|
|
|
|
|
|
|
55 |
|
56 |
except Exception as e:
|
57 |
-
|
58 |
# Don't fail tests if cleanup fails, but log it
|
59 |
|
60 |
|
@@ -97,11 +126,11 @@ def load_calendar_entries(file_path: str) -> List[Dict]:
|
|
97 |
|
98 |
def print_calendar_entries(entries: List[Dict], title: str = "Calendar Entries"):
|
99 |
"""Print calendar entries in a formatted way."""
|
100 |
-
|
101 |
for i, entry in enumerate(entries):
|
102 |
start_dt = entry.get("start_datetime")
|
103 |
end_dt = entry.get("end_datetime")
|
104 |
-
|
105 |
|
106 |
|
107 |
def calculate_required_schedule_days(
|
@@ -183,8 +212,8 @@ async def solve_schedule_with_polling(
|
|
183 |
state_data=state_data, job_id=None, debug=True
|
184 |
)
|
185 |
|
186 |
-
|
187 |
-
|
188 |
|
189 |
# Poll for solution using the correct StateService methods
|
190 |
max_polls = TEST_CONFIG["solver_max_polls"]
|
@@ -194,7 +223,7 @@ async def solve_schedule_with_polling(
|
|
194 |
|
195 |
try:
|
196 |
for poll_count in range(1, max_polls + 1):
|
197 |
-
|
198 |
time.sleep(poll_interval)
|
199 |
|
200 |
# Use StateService to check for completed solution
|
@@ -202,7 +231,7 @@ async def solve_schedule_with_polling(
|
|
202 |
solved_schedule = StateService.get_solved_schedule(job_id)
|
203 |
|
204 |
if solved_schedule is not None:
|
205 |
-
|
206 |
|
207 |
# Convert solved schedule to DataFrame
|
208 |
final_df = schedule_to_dataframe(solved_schedule)
|
@@ -213,15 +242,15 @@ async def solve_schedule_with_polling(
|
|
213 |
)
|
214 |
|
215 |
if "CONSTRAINTS VIOLATED" in status_message:
|
216 |
-
|
217 |
final_df = None
|
218 |
else:
|
219 |
-
|
220 |
|
221 |
break
|
222 |
|
223 |
if final_df is None:
|
224 |
-
|
225 |
|
226 |
finally:
|
227 |
# Clean up: Ensure solver job is terminated
|
@@ -232,21 +261,24 @@ async def solve_schedule_with_polling(
|
|
232 |
if hasattr(solver_manager, "terminateEarly"):
|
233 |
try:
|
234 |
solver_manager.terminateEarly(job_id)
|
235 |
-
|
236 |
except Exception as e:
|
237 |
# If specific job termination fails, try to terminate all jobs
|
238 |
-
|
239 |
try:
|
240 |
solver_manager.terminateEarly()
|
241 |
-
|
242 |
f"π§Ή Terminated all solver jobs after specific termination failed"
|
243 |
)
|
244 |
except Exception as e2:
|
245 |
-
|
246 |
else:
|
247 |
-
|
|
|
|
|
|
|
248 |
except Exception as e:
|
249 |
-
|
250 |
|
251 |
return final_df
|
252 |
|
@@ -261,23 +293,29 @@ def calculate_required_schedule_days_from_df(
|
|
261 |
for _, row in pinned_df.iterrows():
|
262 |
for date_col in ["Start", "End"]:
|
263 |
date_val = row.get(date_col)
|
|
|
264 |
if date_val is not None:
|
265 |
try:
|
266 |
if isinstance(date_val, str):
|
267 |
dt = datetime.fromisoformat(date_val.replace("Z", "+00:00"))
|
|
|
268 |
else:
|
269 |
dt = pd.to_datetime(date_val).to_pydatetime()
|
270 |
|
271 |
if earliest_date is None or dt.date() < earliest_date:
|
272 |
earliest_date = dt.date()
|
|
|
273 |
if latest_date is None or dt.date() > latest_date:
|
274 |
latest_date = dt.date()
|
|
|
275 |
except:
|
276 |
continue
|
277 |
|
278 |
if earliest_date and latest_date:
|
279 |
calendar_span = (latest_date - earliest_date).days + 1
|
|
|
280 |
return calendar_span + buffer_days
|
|
|
281 |
else:
|
282 |
return 60 # Default
|
283 |
|
@@ -297,28 +335,28 @@ def analyze_schedule_dataframe(
|
|
297 |
"project_df": project_tasks,
|
298 |
}
|
299 |
|
300 |
-
|
301 |
-
|
302 |
-
|
303 |
|
304 |
return analysis
|
305 |
|
306 |
|
307 |
def verify_calendar_tasks_pinned(existing_tasks_df: pd.DataFrame) -> bool:
|
308 |
"""Verify that all calendar tasks are pinned."""
|
309 |
-
|
310 |
all_pinned = True
|
311 |
|
312 |
for _, task in existing_tasks_df.iterrows():
|
313 |
is_pinned = task.get("Pinned", False)
|
314 |
task_name = task["Task"]
|
315 |
-
|
316 |
|
317 |
if not is_pinned:
|
318 |
all_pinned = False
|
319 |
-
|
320 |
else:
|
321 |
-
|
322 |
|
323 |
return all_pinned
|
324 |
|
@@ -327,7 +365,7 @@ def verify_time_preservation(
|
|
327 |
original_times: Dict, final_tasks_df: pd.DataFrame
|
328 |
) -> bool:
|
329 |
"""Verify that calendar tasks preserved their original times."""
|
330 |
-
|
331 |
time_preserved = True
|
332 |
|
333 |
for _, task in final_tasks_df.iterrows():
|
@@ -336,17 +374,17 @@ def verify_time_preservation(
|
|
336 |
|
337 |
original = original_times.get(task_name)
|
338 |
if original is None:
|
339 |
-
|
340 |
time_preserved = False
|
341 |
continue
|
342 |
|
343 |
# Normalize and compare times
|
344 |
preserved = compare_datetime_values(original["start"], final_start)
|
345 |
|
346 |
-
|
347 |
-
|
348 |
-
|
349 |
-
|
350 |
|
351 |
if not preserved:
|
352 |
time_preserved = False
|
@@ -369,10 +407,12 @@ def compare_datetime_values(dt1: Any, dt2: Any, tolerance_seconds: int = None) -
|
|
369 |
# Normalize timezones for comparison
|
370 |
if dt1.tzinfo is not None and dt2.tzinfo is None:
|
371 |
dt1 = dt1.replace(tzinfo=None)
|
|
|
372 |
elif dt1.tzinfo is None and dt2.tzinfo is not None:
|
373 |
dt2 = dt2.replace(tzinfo=None)
|
374 |
|
375 |
return abs((dt1 - dt2).total_seconds()) < tolerance
|
|
|
376 |
except:
|
377 |
return False
|
378 |
|
@@ -388,9 +428,9 @@ def store_original_calendar_times(existing_tasks_df: pd.DataFrame) -> Dict[str,
|
|
388 |
"pinned": task.get("Pinned", False),
|
389 |
}
|
390 |
|
391 |
-
|
392 |
for task_name, times in original_times.items():
|
393 |
-
|
394 |
f" - {task_name}: {times['start']} β {times['end']} (pinned: {times['pinned']})"
|
395 |
)
|
396 |
|
@@ -399,7 +439,7 @@ def store_original_calendar_times(existing_tasks_df: pd.DataFrame) -> Dict[str,
|
|
399 |
|
400 |
def verify_llm_tasks_scheduled(project_tasks_df: pd.DataFrame) -> bool:
|
401 |
"""Verify that LLM tasks are properly scheduled and not pinned."""
|
402 |
-
|
403 |
all_scheduled = True
|
404 |
|
405 |
for _, task in project_tasks_df.iterrows():
|
@@ -407,23 +447,25 @@ def verify_llm_tasks_scheduled(project_tasks_df: pd.DataFrame) -> bool:
|
|
407 |
start_time = task["Start"]
|
408 |
is_pinned = task.get("Pinned", False)
|
409 |
|
410 |
-
|
411 |
-
|
412 |
-
|
413 |
|
414 |
# LLM tasks should not be pinned
|
415 |
if is_pinned:
|
416 |
all_scheduled = False
|
417 |
-
|
|
|
418 |
else:
|
419 |
-
|
420 |
|
421 |
# LLM tasks should have been scheduled to actual times
|
422 |
if start_time is None or start_time == "":
|
423 |
all_scheduled = False
|
424 |
-
|
|
|
425 |
else:
|
426 |
-
|
427 |
|
428 |
return all_scheduled
|
429 |
|
@@ -458,9 +500,9 @@ async def test_factory_demo_agent():
|
|
458 |
assert hasattr(task, "project_id")
|
459 |
|
460 |
# Print schedule details for debugging
|
461 |
-
|
462 |
-
|
463 |
-
|
464 |
|
465 |
|
466 |
@pytest.mark.asyncio
|
@@ -478,7 +520,7 @@ async def test_factory_mcp(valid_calendar_entries):
|
|
478 |
assert not df.empty
|
479 |
|
480 |
# Print the DataFrame for debug
|
481 |
-
|
482 |
|
483 |
|
484 |
@pytest.mark.asyncio
|
@@ -487,9 +529,9 @@ async def test_mcp_workflow_calendar_pinning(valid_calendar_entries):
|
|
487 |
Test that verifies calendar tasks (EXISTING) remain pinned to their original times
|
488 |
while LLM tasks (PROJECT) are rescheduled around them in the MCP workflow.
|
489 |
"""
|
490 |
-
|
491 |
-
|
492 |
-
|
493 |
|
494 |
print_calendar_entries(valid_calendar_entries, "Loaded Calendar Entries")
|
495 |
|
@@ -506,12 +548,12 @@ async def test_mcp_workflow_calendar_pinning(valid_calendar_entries):
|
|
506 |
assert calendar_pinned, "Calendar tasks should be pinned!"
|
507 |
|
508 |
# Solve the schedule
|
509 |
-
|
510 |
solved_schedule_df = await solve_schedule_with_polling(initial_df)
|
511 |
|
512 |
if solved_schedule_df is None:
|
513 |
-
|
514 |
-
|
515 |
return
|
516 |
|
517 |
# Analyze final schedule (solved_schedule_df is already a DataFrame)
|
@@ -529,10 +571,10 @@ async def test_mcp_workflow_calendar_pinning(valid_calendar_entries):
|
|
529 |
assert time_preserved, "Calendar tasks did not preserve their original times!"
|
530 |
assert llm_scheduled, "LLM tasks were not properly scheduled!"
|
531 |
|
532 |
-
|
533 |
-
|
534 |
-
|
535 |
-
|
536 |
"π― MCP workflow test passed! Calendar tasks are pinned, LLM tasks are flexible."
|
537 |
)
|
538 |
|
@@ -542,23 +584,25 @@ async def test_calendar_validation_rejects_invalid_entries(invalid_calendar_entr
|
|
542 |
"""
|
543 |
Test that calendar validation properly rejects entries that violate working hours constraints.
|
544 |
"""
|
545 |
-
|
546 |
-
|
547 |
-
|
548 |
|
549 |
print_calendar_entries(invalid_calendar_entries, "Invalid Calendar Entries")
|
550 |
|
551 |
# Test that generate_mcp_data raises an error due to validation failure
|
552 |
user_message = "Simple test task"
|
553 |
|
554 |
-
|
|
|
|
|
555 |
|
556 |
with pytest.raises(ValueError) as exc_info:
|
557 |
await generate_mcp_data_helper(invalid_calendar_entries, user_message)
|
558 |
|
559 |
error_message = str(exc_info.value)
|
560 |
-
|
561 |
-
|
562 |
|
563 |
# Verify the error message contains expected constraint violations
|
564 |
assert "Calendar entries violate working constraints" in error_message
|
@@ -577,7 +621,7 @@ async def test_calendar_validation_rejects_invalid_entries(invalid_calendar_entr
|
|
577 |
"Very Late Meeting" in error_message or "22:00" in error_message
|
578 |
), f"Should detect very late violation in: {error_message}"
|
579 |
|
580 |
-
|
581 |
|
582 |
|
583 |
@pytest.mark.asyncio
|
@@ -585,16 +629,16 @@ async def test_calendar_validation_accepts_valid_entries(valid_calendar_entries)
|
|
585 |
"""
|
586 |
Test that calendar validation accepts valid entries and processing continues normally.
|
587 |
"""
|
588 |
-
|
589 |
-
|
590 |
-
|
591 |
|
592 |
print_calendar_entries(valid_calendar_entries, "Valid Calendar Entries")
|
593 |
|
594 |
# Test that generate_mcp_data succeeds with valid calendar
|
595 |
user_message = "Simple test task"
|
596 |
|
597 |
-
|
598 |
f"\nβ
Attempting to generate MCP data with valid calendar (should succeed)..."
|
599 |
)
|
600 |
|
@@ -603,7 +647,9 @@ async def test_calendar_validation_accepts_valid_entries(valid_calendar_entries)
|
|
603 |
valid_calendar_entries, user_message
|
604 |
)
|
605 |
|
606 |
-
|
|
|
|
|
607 |
|
608 |
# Analyze and verify the result
|
609 |
analysis = analyze_schedule_dataframe(initial_df, "Generated Schedule")
|
@@ -625,19 +671,24 @@ async def test_mcp_backend_end_to_end():
|
|
625 |
Test the complete MCP backend workflow using the actual handler function.
|
626 |
This tests the full process_message_and_attached_file flow.
|
627 |
"""
|
628 |
-
|
629 |
-
|
630 |
-
|
631 |
|
632 |
# Test message for LLM tasks
|
633 |
message_body = "Implement user authentication and setup database migrations"
|
634 |
file_path = TEST_CONFIG["valid_calendar"]
|
635 |
|
|
|
|
|
|
|
|
|
636 |
# Run the MCP backend handler
|
637 |
-
|
638 |
-
|
|
|
639 |
|
640 |
-
result = await process_message_and_attached_file(
|
641 |
|
642 |
# Verify the result structure
|
643 |
assert isinstance(result, dict), "Result should be a dictionary"
|
@@ -647,7 +698,7 @@ async def test_mcp_backend_end_to_end():
|
|
647 |
], f"Unexpected status: {result.get('status')}"
|
648 |
|
649 |
if result.get("status") == "success":
|
650 |
-
|
651 |
|
652 |
# Verify result contains expected fields
|
653 |
assert "schedule" in result, "Result should contain schedule data"
|
@@ -657,8 +708,8 @@ async def test_mcp_backend_end_to_end():
|
|
657 |
schedule = result["schedule"]
|
658 |
calendar_entries = result["calendar_entries"]
|
659 |
|
660 |
-
|
661 |
-
|
662 |
|
663 |
# Analyze the schedule
|
664 |
existing_tasks = [t for t in schedule if t.get("Project") == "EXISTING"]
|
@@ -773,3 +824,60 @@ async def test_mcp_datetime_debug(valid_calendar_entries):
|
|
773 |
raise
|
774 |
|
775 |
print("π― MCP datetime debug test completed!")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
import time
|
3 |
import pandas as pd
|
4 |
import traceback
|
5 |
+
import sys
|
6 |
from io import StringIO
|
7 |
from datetime import datetime, date, timedelta
|
8 |
from typing import List, Dict, Tuple, Optional, Any
|
9 |
|
10 |
from src.utils.load_secrets import load_secrets
|
11 |
|
12 |
+
# Import standardized test utilities
|
13 |
+
from tests.test_utils import get_test_logger, create_test_results
|
14 |
+
|
15 |
+
# Initialize standardized test logger
|
16 |
+
logger = get_test_logger(__name__)
|
17 |
+
|
18 |
# Load environment variables for agent (if needed)
|
19 |
load_secrets("tests/secrets/creds.py")
|
20 |
|
|
|
33 |
|
34 |
# Cleanup: Terminate all active solver jobs and shutdown solver manager
|
35 |
try:
|
36 |
+
import time
|
37 |
from constraint_solvers.timetable.solver import solver_manager
|
38 |
from src.state import app_state
|
39 |
|
40 |
+
logger.info("π§Ή Starting solver cleanup...")
|
41 |
+
|
42 |
# Clear all stored schedules first
|
43 |
app_state.clear_solved_schedules()
|
44 |
|
|
|
47 |
# According to Timefold docs, terminateEarly() affects all jobs for this manager
|
48 |
try:
|
49 |
solver_manager.terminateEarly()
|
50 |
+
logger.info("π§Ή Terminated all active solver jobs")
|
51 |
+
|
52 |
+
# Give some time for the termination to complete
|
53 |
+
time.sleep(0.5)
|
54 |
+
|
55 |
except Exception as e:
|
56 |
+
logger.warning(f"β οΈ Error terminating solver jobs: {e}")
|
57 |
|
58 |
# Try additional cleanup methods if available
|
59 |
if hasattr(solver_manager, "close"):
|
60 |
+
try:
|
61 |
+
solver_manager.close()
|
62 |
+
logger.info("π Closed solver manager")
|
63 |
+
|
64 |
+
except Exception as e:
|
65 |
+
logger.warning(f"β οΈ Error closing solver manager: {e}")
|
66 |
+
|
67 |
elif hasattr(solver_manager, "shutdown"):
|
68 |
+
try:
|
69 |
+
solver_manager.shutdown()
|
70 |
+
logger.info("π Shutdown solver manager")
|
71 |
+
|
72 |
+
except Exception as e:
|
73 |
+
logger.warning(f"β οΈ Error shutting down solver manager: {e}")
|
74 |
+
|
75 |
else:
|
76 |
+
logger.warning(
|
77 |
+
"β οΈ No explicit close/shutdown method found on solver manager"
|
78 |
+
)
|
79 |
|
80 |
+
# Additional small delay to allow cleanup to complete
|
81 |
+
time.sleep(0.2)
|
82 |
+
|
83 |
+
logger.info("β
Solver cleanup completed successfully")
|
84 |
|
85 |
except Exception as e:
|
86 |
+
logger.warning(f"β οΈ Error during solver cleanup: {e}")
|
87 |
# Don't fail tests if cleanup fails, but log it
|
88 |
|
89 |
|
|
|
126 |
|
127 |
def print_calendar_entries(entries: List[Dict], title: str = "Calendar Entries"):
|
128 |
"""Print calendar entries in a formatted way."""
|
129 |
+
logger.debug(f"π
{title} ({len(entries)} entries):")
|
130 |
for i, entry in enumerate(entries):
|
131 |
start_dt = entry.get("start_datetime")
|
132 |
end_dt = entry.get("end_datetime")
|
133 |
+
logger.debug(f" {i+1}. {entry['summary']}: {start_dt} β {end_dt}")
|
134 |
|
135 |
|
136 |
def calculate_required_schedule_days(
|
|
|
212 |
state_data=state_data, job_id=None, debug=True
|
213 |
)
|
214 |
|
215 |
+
logger.info(f"Solver started with job_id: {job_id}")
|
216 |
+
logger.debug(f"Initial status: {status}")
|
217 |
|
218 |
# Poll for solution using the correct StateService methods
|
219 |
max_polls = TEST_CONFIG["solver_max_polls"]
|
|
|
223 |
|
224 |
try:
|
225 |
for poll_count in range(1, max_polls + 1):
|
226 |
+
logger.debug(f" Polling {poll_count}/{max_polls}...")
|
227 |
time.sleep(poll_interval)
|
228 |
|
229 |
# Use StateService to check for completed solution
|
|
|
231 |
solved_schedule = StateService.get_solved_schedule(job_id)
|
232 |
|
233 |
if solved_schedule is not None:
|
234 |
+
logger.info(f"β
Schedule solved after {poll_count} polls!")
|
235 |
|
236 |
# Convert solved schedule to DataFrame
|
237 |
final_df = schedule_to_dataframe(solved_schedule)
|
|
|
242 |
)
|
243 |
|
244 |
if "CONSTRAINTS VIOLATED" in status_message:
|
245 |
+
logger.warning(f"β Solver failed: {status_message}")
|
246 |
final_df = None
|
247 |
else:
|
248 |
+
logger.info(f"β
Solver succeeded: {status_message}")
|
249 |
|
250 |
break
|
251 |
|
252 |
if final_df is None:
|
253 |
+
logger.warning("β° Solver timed out after max polls")
|
254 |
|
255 |
finally:
|
256 |
# Clean up: Ensure solver job is terminated
|
|
|
261 |
if hasattr(solver_manager, "terminateEarly"):
|
262 |
try:
|
263 |
solver_manager.terminateEarly(job_id)
|
264 |
+
logger.info(f"π§Ή Terminated solver job: {job_id}")
|
265 |
except Exception as e:
|
266 |
# If specific job termination fails, try to terminate all jobs
|
267 |
+
logger.warning(f"β οΈ Error terminating specific job {job_id}: {e}")
|
268 |
try:
|
269 |
solver_manager.terminateEarly()
|
270 |
+
logger.info(
|
271 |
f"π§Ή Terminated all solver jobs after specific termination failed"
|
272 |
)
|
273 |
except Exception as e2:
|
274 |
+
logger.warning(f"β οΈ Could not terminate any solver jobs: {e2}")
|
275 |
else:
|
276 |
+
logger.warning(
|
277 |
+
f"β οΈ terminateEarly method not available on solver_manager"
|
278 |
+
)
|
279 |
+
|
280 |
except Exception as e:
|
281 |
+
logger.warning(f"β οΈ Could not access solver_manager for cleanup: {e}")
|
282 |
|
283 |
return final_df
|
284 |
|
|
|
293 |
for _, row in pinned_df.iterrows():
|
294 |
for date_col in ["Start", "End"]:
|
295 |
date_val = row.get(date_col)
|
296 |
+
|
297 |
if date_val is not None:
|
298 |
try:
|
299 |
if isinstance(date_val, str):
|
300 |
dt = datetime.fromisoformat(date_val.replace("Z", "+00:00"))
|
301 |
+
|
302 |
else:
|
303 |
dt = pd.to_datetime(date_val).to_pydatetime()
|
304 |
|
305 |
if earliest_date is None or dt.date() < earliest_date:
|
306 |
earliest_date = dt.date()
|
307 |
+
|
308 |
if latest_date is None or dt.date() > latest_date:
|
309 |
latest_date = dt.date()
|
310 |
+
|
311 |
except:
|
312 |
continue
|
313 |
|
314 |
if earliest_date and latest_date:
|
315 |
calendar_span = (latest_date - earliest_date).days + 1
|
316 |
+
|
317 |
return calendar_span + buffer_days
|
318 |
+
|
319 |
else:
|
320 |
return 60 # Default
|
321 |
|
|
|
335 |
"project_df": project_tasks,
|
336 |
}
|
337 |
|
338 |
+
logger.debug(f"\nπ {title} ({analysis['total_tasks']} tasks):")
|
339 |
+
logger.debug(f" - EXISTING (calendar): {analysis['existing_tasks']} tasks")
|
340 |
+
logger.debug(f" - PROJECT (LLM): {analysis['project_tasks']} tasks")
|
341 |
|
342 |
return analysis
|
343 |
|
344 |
|
345 |
def verify_calendar_tasks_pinned(existing_tasks_df: pd.DataFrame) -> bool:
|
346 |
"""Verify that all calendar tasks are pinned."""
|
347 |
+
logger.debug(f"\nπ Verifying calendar tasks are pinned:")
|
348 |
all_pinned = True
|
349 |
|
350 |
for _, task in existing_tasks_df.iterrows():
|
351 |
is_pinned = task.get("Pinned", False)
|
352 |
task_name = task["Task"]
|
353 |
+
logger.debug(f" - {task_name}: pinned = {is_pinned}")
|
354 |
|
355 |
if not is_pinned:
|
356 |
all_pinned = False
|
357 |
+
logger.warning(f" β Calendar task should be pinned!")
|
358 |
else:
|
359 |
+
logger.info(f" β
Calendar task properly pinned")
|
360 |
|
361 |
return all_pinned
|
362 |
|
|
|
365 |
original_times: Dict, final_tasks_df: pd.DataFrame
|
366 |
) -> bool:
|
367 |
"""Verify that calendar tasks preserved their original times."""
|
368 |
+
logger.debug(f"\nπ Verifying calendar tasks preserved their original times:")
|
369 |
time_preserved = True
|
370 |
|
371 |
for _, task in final_tasks_df.iterrows():
|
|
|
374 |
|
375 |
original = original_times.get(task_name)
|
376 |
if original is None:
|
377 |
+
logger.warning(f" - {task_name}: β Not found in original data")
|
378 |
time_preserved = False
|
379 |
continue
|
380 |
|
381 |
# Normalize and compare times
|
382 |
preserved = compare_datetime_values(original["start"], final_start)
|
383 |
|
384 |
+
logger.debug(f" - {task_name}:")
|
385 |
+
logger.debug(f" Original: {original['start']}")
|
386 |
+
logger.debug(f" Final: {final_start}")
|
387 |
+
logger.debug(f" Preserved: {'β
' if preserved else 'β'}")
|
388 |
|
389 |
if not preserved:
|
390 |
time_preserved = False
|
|
|
407 |
# Normalize timezones for comparison
|
408 |
if dt1.tzinfo is not None and dt2.tzinfo is None:
|
409 |
dt1 = dt1.replace(tzinfo=None)
|
410 |
+
|
411 |
elif dt1.tzinfo is None and dt2.tzinfo is not None:
|
412 |
dt2 = dt2.replace(tzinfo=None)
|
413 |
|
414 |
return abs((dt1 - dt2).total_seconds()) < tolerance
|
415 |
+
|
416 |
except:
|
417 |
return False
|
418 |
|
|
|
428 |
"pinned": task.get("Pinned", False),
|
429 |
}
|
430 |
|
431 |
+
logger.debug("\nπ Original calendar task times:")
|
432 |
for task_name, times in original_times.items():
|
433 |
+
logger.debug(
|
434 |
f" - {task_name}: {times['start']} β {times['end']} (pinned: {times['pinned']})"
|
435 |
)
|
436 |
|
|
|
439 |
|
440 |
def verify_llm_tasks_scheduled(project_tasks_df: pd.DataFrame) -> bool:
|
441 |
"""Verify that LLM tasks are properly scheduled and not pinned."""
|
442 |
+
logger.debug(f"\nπ Verifying LLM tasks were properly scheduled:")
|
443 |
all_scheduled = True
|
444 |
|
445 |
for _, task in project_tasks_df.iterrows():
|
|
|
447 |
start_time = task["Start"]
|
448 |
is_pinned = task.get("Pinned", False)
|
449 |
|
450 |
+
logger.debug(f" - {task_name}:")
|
451 |
+
logger.debug(f" Scheduled at: {start_time}")
|
452 |
+
logger.debug(f" Pinned: {is_pinned}")
|
453 |
|
454 |
# LLM tasks should not be pinned
|
455 |
if is_pinned:
|
456 |
all_scheduled = False
|
457 |
+
logger.warning(f" β LLM task should not be pinned!")
|
458 |
+
|
459 |
else:
|
460 |
+
logger.info(f" β
LLM task properly unpinned")
|
461 |
|
462 |
# LLM tasks should have been scheduled to actual times
|
463 |
if start_time is None or start_time == "":
|
464 |
all_scheduled = False
|
465 |
+
logger.warning(f" β LLM task was not scheduled!")
|
466 |
+
|
467 |
else:
|
468 |
+
logger.info(f" β
LLM task was scheduled")
|
469 |
|
470 |
return all_scheduled
|
471 |
|
|
|
500 |
assert hasattr(task, "project_id")
|
501 |
|
502 |
# Print schedule details for debugging
|
503 |
+
logger.info(f"Employee names: {[e.name for e in schedule.employees]}")
|
504 |
+
logger.info(f"Tasks count: {len(schedule.tasks)}")
|
505 |
+
logger.info(f"Total slots: {schedule.schedule_info.total_slots}")
|
506 |
|
507 |
|
508 |
@pytest.mark.asyncio
|
|
|
520 |
assert not df.empty
|
521 |
|
522 |
# Print the DataFrame for debug
|
523 |
+
logger.debug(df)
|
524 |
|
525 |
|
526 |
@pytest.mark.asyncio
|
|
|
529 |
Test that verifies calendar tasks (EXISTING) remain pinned to their original times
|
530 |
while LLM tasks (PROJECT) are rescheduled around them in the MCP workflow.
|
531 |
"""
|
532 |
+
logger.debug("\n" + "=" * 60)
|
533 |
+
logger.debug("Testing MCP Workflow: Calendar Task Pinning vs LLM Task Scheduling")
|
534 |
+
logger.debug("=" * 60)
|
535 |
|
536 |
print_calendar_entries(valid_calendar_entries, "Loaded Calendar Entries")
|
537 |
|
|
|
548 |
assert calendar_pinned, "Calendar tasks should be pinned!"
|
549 |
|
550 |
# Solve the schedule
|
551 |
+
logger.debug(f"\nπ§ Running MCP workflow to solve schedule...")
|
552 |
solved_schedule_df = await solve_schedule_with_polling(initial_df)
|
553 |
|
554 |
if solved_schedule_df is None:
|
555 |
+
logger.warning("β° Solver timed out - this might be due to complex constraints")
|
556 |
+
logger.warning("β οΈ Skipping verification steps for timeout case")
|
557 |
return
|
558 |
|
559 |
# Analyze final schedule (solved_schedule_df is already a DataFrame)
|
|
|
571 |
assert time_preserved, "Calendar tasks did not preserve their original times!"
|
572 |
assert llm_scheduled, "LLM tasks were not properly scheduled!"
|
573 |
|
574 |
+
logger.info(f"\nπ MCP Workflow Test Results:")
|
575 |
+
logger.info(f"β
Calendar tasks preserved original times: {time_preserved}")
|
576 |
+
logger.info(f"β
LLM tasks were properly scheduled: {llm_scheduled}")
|
577 |
+
logger.info(
|
578 |
"π― MCP workflow test passed! Calendar tasks are pinned, LLM tasks are flexible."
|
579 |
)
|
580 |
|
|
|
584 |
"""
|
585 |
Test that calendar validation properly rejects entries that violate working hours constraints.
|
586 |
"""
|
587 |
+
logger.debug("\n" + "=" * 60)
|
588 |
+
logger.debug("Testing Calendar Validation: Constraint Violations")
|
589 |
+
logger.debug("=" * 60)
|
590 |
|
591 |
print_calendar_entries(invalid_calendar_entries, "Invalid Calendar Entries")
|
592 |
|
593 |
# Test that generate_mcp_data raises an error due to validation failure
|
594 |
user_message = "Simple test task"
|
595 |
|
596 |
+
logger.debug(
|
597 |
+
f"\nβ Attempting to generate MCP data with invalid calendar (should fail)..."
|
598 |
+
)
|
599 |
|
600 |
with pytest.raises(ValueError) as exc_info:
|
601 |
await generate_mcp_data_helper(invalid_calendar_entries, user_message)
|
602 |
|
603 |
error_message = str(exc_info.value)
|
604 |
+
logger.debug(f"\nβ
Validation correctly rejected invalid calendar:")
|
605 |
+
logger.debug(f"Error: {error_message}")
|
606 |
|
607 |
# Verify the error message contains expected constraint violations
|
608 |
assert "Calendar entries violate working constraints" in error_message
|
|
|
621 |
"Very Late Meeting" in error_message or "22:00" in error_message
|
622 |
), f"Should detect very late violation in: {error_message}"
|
623 |
|
624 |
+
logger.info("β
All expected constraint violations were detected!")
|
625 |
|
626 |
|
627 |
@pytest.mark.asyncio
|
|
|
629 |
"""
|
630 |
Test that calendar validation accepts valid entries and processing continues normally.
|
631 |
"""
|
632 |
+
logger.debug("\n" + "=" * 60)
|
633 |
+
logger.debug("Testing Calendar Validation: Valid Entries")
|
634 |
+
logger.debug("=" * 60)
|
635 |
|
636 |
print_calendar_entries(valid_calendar_entries, "Valid Calendar Entries")
|
637 |
|
638 |
# Test that generate_mcp_data succeeds with valid calendar
|
639 |
user_message = "Simple test task"
|
640 |
|
641 |
+
logger.debug(
|
642 |
f"\nβ
Attempting to generate MCP data with valid calendar (should succeed)..."
|
643 |
)
|
644 |
|
|
|
647 |
valid_calendar_entries, user_message
|
648 |
)
|
649 |
|
650 |
+
logger.debug(
|
651 |
+
f"β
Validation passed! Generated {len(initial_df)} tasks successfully"
|
652 |
+
)
|
653 |
|
654 |
# Analyze and verify the result
|
655 |
analysis = analyze_schedule_dataframe(initial_df, "Generated Schedule")
|
|
|
671 |
Test the complete MCP backend workflow using the actual handler function.
|
672 |
This tests the full process_message_and_attached_file flow.
|
673 |
"""
|
674 |
+
logger.debug("\n" + "=" * 50)
|
675 |
+
logger.debug("Testing MCP Backend End-to-End")
|
676 |
+
logger.debug("=" * 50)
|
677 |
|
678 |
# Test message for LLM tasks
|
679 |
message_body = "Implement user authentication and setup database migrations"
|
680 |
file_path = TEST_CONFIG["valid_calendar"]
|
681 |
|
682 |
+
# Read the actual file content as bytes (MCP backend expects bytes, not file path)
|
683 |
+
with open(file_path, "rb") as f:
|
684 |
+
file_content = f.read()
|
685 |
+
|
686 |
# Run the MCP backend handler
|
687 |
+
logger.debug(f"π¨ Processing message: '{message_body}'")
|
688 |
+
logger.debug(f"π Using calendar file: {file_path}")
|
689 |
+
logger.debug(f"π File content size: {len(file_content)} bytes")
|
690 |
|
691 |
+
result = await process_message_and_attached_file(file_content, message_body)
|
692 |
|
693 |
# Verify the result structure
|
694 |
assert isinstance(result, dict), "Result should be a dictionary"
|
|
|
698 |
], f"Unexpected status: {result.get('status')}"
|
699 |
|
700 |
if result.get("status") == "success":
|
701 |
+
logger.info("β
MCP backend completed successfully!")
|
702 |
|
703 |
# Verify result contains expected fields
|
704 |
assert "schedule" in result, "Result should contain schedule data"
|
|
|
708 |
schedule = result["schedule"]
|
709 |
calendar_entries = result["calendar_entries"]
|
710 |
|
711 |
+
logger.info(f"π
Calendar entries processed: {len(calendar_entries)}")
|
712 |
+
logger.info(f"π Total scheduled tasks: {len(schedule)}")
|
713 |
|
714 |
# Analyze the schedule
|
715 |
existing_tasks = [t for t in schedule if t.get("Project") == "EXISTING"]
|
|
|
824 |
raise
|
825 |
|
826 |
print("π― MCP datetime debug test completed!")
|
827 |
+
|
828 |
+
|
829 |
+
if __name__ == "__main__":
|
830 |
+
"""Direct execution for non-pytest testing"""
|
831 |
+
import asyncio
|
832 |
+
|
833 |
+
logger.section("Factory Integration Tests")
|
834 |
+
logger.info(
|
835 |
+
"Note: This test suite is designed for pytest. For best results, run with:"
|
836 |
+
)
|
837 |
+
logger.info(" pytest tests/test_factory.py -v")
|
838 |
+
logger.info(" YUGA_DEBUG=true pytest tests/test_factory.py -v -s")
|
839 |
+
|
840 |
+
# Create test results tracker
|
841 |
+
results = create_test_results(logger)
|
842 |
+
|
843 |
+
try:
|
844 |
+
# Load test data
|
845 |
+
logger.info("Loading test calendar data...")
|
846 |
+
calendar_entries = load_calendar_entries(TEST_CONFIG["valid_calendar"])
|
847 |
+
logger.info(f"β
Loaded {len(calendar_entries)} calendar entries")
|
848 |
+
|
849 |
+
# Run a sample factory test
|
850 |
+
logger.info("Running sample factory tests...")
|
851 |
+
|
852 |
+
async def run_sample_tests():
|
853 |
+
# Test MCP data generation
|
854 |
+
try:
|
855 |
+
logger.info("Testing MCP data generation...")
|
856 |
+
df = await generate_mcp_data_helper(
|
857 |
+
calendar_entries=calendar_entries,
|
858 |
+
user_message="Create sample tasks for testing",
|
859 |
+
)
|
860 |
+
logger.info(f"β
Generated MCP data with {len(df)} tasks")
|
861 |
+
return True
|
862 |
+
|
863 |
+
except Exception as e:
|
864 |
+
logger.error(f"β MCP data generation failed: {e}")
|
865 |
+
return False
|
866 |
+
|
867 |
+
# Run the async test
|
868 |
+
success = asyncio.run(run_sample_tests())
|
869 |
+
results.add_result("mcp_data_generation", success)
|
870 |
+
|
871 |
+
logger.info(f"β
Completed sample factory tests")
|
872 |
+
|
873 |
+
except Exception as e:
|
874 |
+
logger.error(f"Failed to run factory tests: {e}")
|
875 |
+
results.add_result("factory_tests_setup", False, str(e))
|
876 |
+
|
877 |
+
# Generate summary and exit with appropriate code
|
878 |
+
all_passed = results.summary()
|
879 |
+
|
880 |
+
if not all_passed:
|
881 |
+
logger.info("π‘ Hint: Use 'pytest tests/test_factory.py' for full test coverage")
|
882 |
+
|
883 |
+
sys.exit(0 if all_passed else 1)
|
@@ -1,10 +1,13 @@
|
|
1 |
-
import pytest
|
|
|
2 |
|
3 |
from src.utils.load_secrets import load_secrets
|
4 |
|
5 |
-
#
|
6 |
-
|
7 |
-
|
|
|
|
|
8 |
|
9 |
# Load environment variables
|
10 |
load_secrets("tests/secrets/creds.py")
|
@@ -14,46 +17,69 @@ from src.factory.agents.task_composer_agent import TaskComposerAgent
|
|
14 |
|
15 |
@pytest.mark.asyncio
|
16 |
async def test_task_composer_agent():
|
17 |
-
|
18 |
|
19 |
-
logger.
|
20 |
|
21 |
# Create agent
|
22 |
-
logger.
|
23 |
agent = TaskComposerAgent()
|
24 |
|
25 |
# Test input
|
26 |
test_input = "Plan a weekend trip to Paris"
|
27 |
-
logger.info(f"
|
28 |
-
logger.info(f"Task: {test_input}")
|
29 |
|
30 |
# Run workflow
|
31 |
-
logger.
|
32 |
result = await agent.run_workflow(test_input)
|
33 |
|
34 |
-
#
|
35 |
-
logger.
|
36 |
-
logger.info("Task breakdown with estimated times:")
|
37 |
for task, duration, skill in result:
|
38 |
-
logger.
|
39 |
|
40 |
# Calculate total time
|
41 |
total_time = sum(
|
42 |
int(time) if str(time).isdigit() and str(time) != "" else 0
|
43 |
for _, time, _ in result
|
44 |
)
|
45 |
-
logger.info(
|
46 |
-
f"\nTotal estimated time: {total_time} units ({total_time * 30} minutes)"
|
47 |
-
)
|
48 |
|
49 |
# Verify the result is a list of 3-tuples
|
50 |
assert isinstance(result, list), f"Expected a list, got {type(result)}"
|
51 |
assert all(
|
52 |
isinstance(item, tuple) and len(item) == 3 for item in result
|
53 |
), "Expected a list of (task, duration, skill) tuples"
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
|
|
59 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import pytest
|
2 |
+
import sys
|
3 |
|
4 |
from src.utils.load_secrets import load_secrets
|
5 |
|
6 |
+
# Import standardized test utilities
|
7 |
+
from tests.test_utils import get_test_logger, create_test_results
|
8 |
+
|
9 |
+
# Initialize standardized test logger
|
10 |
+
logger = get_test_logger(__name__)
|
11 |
|
12 |
# Load environment variables
|
13 |
load_secrets("tests/secrets/creds.py")
|
|
|
17 |
|
18 |
@pytest.mark.asyncio
|
19 |
async def test_task_composer_agent():
|
20 |
+
"""Test the task composer agent workflow"""
|
21 |
|
22 |
+
logger.start_test("Testing task composer agent workflow")
|
23 |
|
24 |
# Create agent
|
25 |
+
logger.debug("Initializing task_composer_agent...")
|
26 |
agent = TaskComposerAgent()
|
27 |
|
28 |
# Test input
|
29 |
test_input = "Plan a weekend trip to Paris"
|
30 |
+
logger.info(f"Test Input: {test_input}")
|
|
|
31 |
|
32 |
# Run workflow
|
33 |
+
logger.debug("Running agent workflow...")
|
34 |
result = await agent.run_workflow(test_input)
|
35 |
|
36 |
+
# Analyze results
|
37 |
+
logger.debug("Task breakdown with estimated times:")
|
|
|
38 |
for task, duration, skill in result:
|
39 |
+
logger.debug(f"- {task}: {duration} units (Skill: {skill})")
|
40 |
|
41 |
# Calculate total time
|
42 |
total_time = sum(
|
43 |
int(time) if str(time).isdigit() and str(time) != "" else 0
|
44 |
for _, time, _ in result
|
45 |
)
|
46 |
+
logger.info(f"Total estimated time: {total_time} units ({total_time * 30} minutes)")
|
|
|
|
|
47 |
|
48 |
# Verify the result is a list of 3-tuples
|
49 |
assert isinstance(result, list), f"Expected a list, got {type(result)}"
|
50 |
assert all(
|
51 |
isinstance(item, tuple) and len(item) == 3 for item in result
|
52 |
), "Expected a list of (task, duration, skill) tuples"
|
53 |
+
|
54 |
+
# Verify we got some tasks
|
55 |
+
assert len(result) > 0, "Agent should return at least one task"
|
56 |
+
|
57 |
+
logger.pass_test(
|
58 |
+
f"Agent workflow completed - generated {len(result)} tasks, total time: {total_time} units"
|
59 |
)
|
60 |
+
|
61 |
+
|
62 |
+
if __name__ == "__main__":
|
63 |
+
"""Direct execution for non-pytest testing"""
|
64 |
+
import asyncio
|
65 |
+
|
66 |
+
logger.section("Task Composer Agent Tests")
|
67 |
+
|
68 |
+
# Create test results tracker
|
69 |
+
results = create_test_results(logger)
|
70 |
+
|
71 |
+
# Run the async test
|
72 |
+
async def run_test():
|
73 |
+
try:
|
74 |
+
await test_task_composer_agent()
|
75 |
+
return True
|
76 |
+
except Exception as e:
|
77 |
+
logger.fail_test("Task composer agent test", e)
|
78 |
+
return False
|
79 |
+
|
80 |
+
success = asyncio.run(run_test())
|
81 |
+
results.add_result("task_composer_agent", success)
|
82 |
+
|
83 |
+
# Generate summary and exit with appropriate code
|
84 |
+
all_passed = results.summary()
|
85 |
+
sys.exit(0 if all_passed else 1)
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Test Utilities for Yuga Planner Tests
|
3 |
+
|
4 |
+
This module provides standardized logging and common functionality for all test files.
|
5 |
+
It ensures consistent logging patterns and reduces boilerplate across the test suite.
|
6 |
+
|
7 |
+
Usage:
|
8 |
+
from tests.test_utils import TestLogger, test_config
|
9 |
+
|
10 |
+
# At the top of any test file
|
11 |
+
logger = TestLogger(__name__)
|
12 |
+
|
13 |
+
# In test functions
|
14 |
+
def test_something():
|
15 |
+
logger.start_test("Testing important functionality")
|
16 |
+
logger.info("β
Test step passed")
|
17 |
+
logger.debug("Debug details...")
|
18 |
+
logger.pass_test("Important functionality works correctly")
|
19 |
+
|
20 |
+
Environment Variables:
|
21 |
+
YUGA_DEBUG: Set to "true" to enable detailed debug logging in tests
|
22 |
+
PYTEST_CURRENT_TEST: Automatically set by pytest with current test info
|
23 |
+
"""
|
24 |
+
|
25 |
+
import os
|
26 |
+
import sys
|
27 |
+
from typing import Optional, Dict, Any
|
28 |
+
|
29 |
+
# Add src to path to import our modules (for tests that use this utility)
|
30 |
+
if "src" not in [p.split("/")[-1] for p in sys.path]:
|
31 |
+
sys.path.insert(0, "src")
|
32 |
+
|
33 |
+
from utils.logging_config import setup_logging, get_logger, is_debug_enabled
|
34 |
+
|
35 |
+
# Initialize logging early for all tests
|
36 |
+
setup_logging()
|
37 |
+
|
38 |
+
|
39 |
+
class TestLogger:
|
40 |
+
"""
|
41 |
+
Standardized logger for test files with test-specific formatting and methods.
|
42 |
+
|
43 |
+
Provides consistent logging patterns across all test files with special
|
44 |
+
methods for test lifecycle events.
|
45 |
+
"""
|
46 |
+
|
47 |
+
def __init__(self, name: str):
|
48 |
+
"""
|
49 |
+
Initialize test logger for a specific test module.
|
50 |
+
|
51 |
+
Args:
|
52 |
+
name: Usually __name__ from the test file
|
53 |
+
"""
|
54 |
+
self.logger = get_logger(name)
|
55 |
+
self.current_test = None
|
56 |
+
|
57 |
+
# Log test module initialization
|
58 |
+
module_name = name.split(".")[-1] if "." in name else name
|
59 |
+
self.logger.debug(f"π§ͺ Initialized test logger for {module_name}")
|
60 |
+
|
61 |
+
def start_test(self, test_description: str) -> None:
|
62 |
+
"""Mark the start of a test with description."""
|
63 |
+
self.current_test = test_description
|
64 |
+
self.logger.info(f"π§ͺ {test_description}")
|
65 |
+
|
66 |
+
def pass_test(self, message: str = None) -> None:
|
67 |
+
"""Mark a test as passed with optional message."""
|
68 |
+
msg = message or self.current_test or "Test"
|
69 |
+
self.logger.info(f"β
SUCCESS: {msg}")
|
70 |
+
|
71 |
+
def fail_test(self, message: str, exception: Exception = None) -> None:
|
72 |
+
"""Mark a test as failed with message and optional exception."""
|
73 |
+
if exception:
|
74 |
+
self.logger.error(f"β FAILED: {message} - {exception}")
|
75 |
+
else:
|
76 |
+
self.logger.error(f"β FAILED: {message}")
|
77 |
+
|
78 |
+
def skip_test(self, reason: str) -> None:
|
79 |
+
"""Mark a test as skipped with reason."""
|
80 |
+
self.logger.warning(f"βοΈ SKIPPED: {reason}")
|
81 |
+
|
82 |
+
def info(self, message: str) -> None:
|
83 |
+
"""Log an info message."""
|
84 |
+
self.logger.info(message)
|
85 |
+
|
86 |
+
def debug(self, message: str) -> None:
|
87 |
+
"""Log a debug message (only shown when YUGA_DEBUG=true)."""
|
88 |
+
self.logger.debug(message)
|
89 |
+
|
90 |
+
def warning(self, message: str) -> None:
|
91 |
+
"""Log a warning message."""
|
92 |
+
self.logger.warning(message)
|
93 |
+
|
94 |
+
def error(self, message: str) -> None:
|
95 |
+
"""Log an error message."""
|
96 |
+
self.logger.error(message)
|
97 |
+
|
98 |
+
def section(self, title: str) -> None:
|
99 |
+
"""Log a section header for organizing test output."""
|
100 |
+
separator = "=" * 60
|
101 |
+
self.logger.info(separator)
|
102 |
+
self.logger.info(f"π {title}")
|
103 |
+
self.logger.info(separator)
|
104 |
+
|
105 |
+
def subsection(self, title: str) -> None:
|
106 |
+
"""Log a subsection header."""
|
107 |
+
self.logger.info(f"\nπ {title}")
|
108 |
+
self.logger.info("-" * 40)
|
109 |
+
|
110 |
+
|
111 |
+
class TestResults:
|
112 |
+
"""
|
113 |
+
Track and report test results consistently across test files.
|
114 |
+
|
115 |
+
Provides methods to track pass/fail status and generate summary reports.
|
116 |
+
"""
|
117 |
+
|
118 |
+
def __init__(self, logger: TestLogger):
|
119 |
+
self.logger = logger
|
120 |
+
self.results: Dict[str, bool] = {}
|
121 |
+
self.details: Dict[str, str] = {}
|
122 |
+
|
123 |
+
def add_result(self, test_name: str, passed: bool, details: str = None) -> None:
|
124 |
+
"""Add a test result."""
|
125 |
+
self.results[test_name] = passed
|
126 |
+
if details:
|
127 |
+
self.details[test_name] = details
|
128 |
+
|
129 |
+
status = "β
PASS" if passed else "β FAIL"
|
130 |
+
self.logger.info(f" {test_name.replace('_', ' ').title()}: {status}")
|
131 |
+
if details and not passed:
|
132 |
+
self.logger.debug(f" Details: {details}")
|
133 |
+
|
134 |
+
def run_test(self, test_name: str, test_func, *args, **kwargs) -> bool:
|
135 |
+
"""
|
136 |
+
Run a test function and automatically track results.
|
137 |
+
|
138 |
+
Args:
|
139 |
+
test_name: Name for result tracking
|
140 |
+
test_func: Test function to execute
|
141 |
+
*args, **kwargs: Arguments for test function
|
142 |
+
|
143 |
+
Returns:
|
144 |
+
bool: True if test passed, False if failed
|
145 |
+
"""
|
146 |
+
try:
|
147 |
+
test_func(*args, **kwargs)
|
148 |
+
self.add_result(test_name, True)
|
149 |
+
return True
|
150 |
+
except Exception as e:
|
151 |
+
self.add_result(test_name, False, str(e))
|
152 |
+
return False
|
153 |
+
|
154 |
+
def summary(self) -> bool:
|
155 |
+
"""
|
156 |
+
Generate and log test summary.
|
157 |
+
|
158 |
+
Returns:
|
159 |
+
bool: True if all tests passed, False otherwise
|
160 |
+
"""
|
161 |
+
total_tests = len(self.results)
|
162 |
+
passed_tests = sum(1 for passed in self.results.values() if passed)
|
163 |
+
|
164 |
+
self.logger.section("Test Results Summary")
|
165 |
+
self.logger.info(f"π Tests Run: {total_tests}")
|
166 |
+
self.logger.info(f"β
Passed: {passed_tests}")
|
167 |
+
self.logger.info(f"β Failed: {total_tests - passed_tests}")
|
168 |
+
|
169 |
+
# Log individual results
|
170 |
+
for test_name, passed in self.results.items():
|
171 |
+
status = "β
PASS" if passed else "β FAIL"
|
172 |
+
self.logger.info(f" {test_name.replace('_', ' ').title()}: {status}")
|
173 |
+
|
174 |
+
# Show failure details if available
|
175 |
+
if not passed and test_name in self.details:
|
176 |
+
self.logger.debug(f" Error: {self.details[test_name]}")
|
177 |
+
|
178 |
+
all_passed = all(self.results.values())
|
179 |
+
if all_passed:
|
180 |
+
self.logger.info("π ALL TESTS PASSED!")
|
181 |
+
else:
|
182 |
+
self.logger.error("β SOME TESTS FAILED!")
|
183 |
+
|
184 |
+
return all_passed
|
185 |
+
|
186 |
+
|
187 |
+
# Global test configuration
|
188 |
+
test_config = {
|
189 |
+
"debug_enabled": is_debug_enabled(),
|
190 |
+
"pytest_running": "PYTEST_CURRENT_TEST" in os.environ,
|
191 |
+
"log_level": "DEBUG" if is_debug_enabled() else "INFO",
|
192 |
+
}
|
193 |
+
|
194 |
+
# Convenience functions for quick access
|
195 |
+
def get_test_logger(name: str) -> TestLogger:
|
196 |
+
"""Get a standardized test logger."""
|
197 |
+
return TestLogger(name)
|
198 |
+
|
199 |
+
|
200 |
+
def create_test_results(logger: TestLogger) -> TestResults:
|
201 |
+
"""Create a test results tracker."""
|
202 |
+
return TestResults(logger)
|
203 |
+
|
204 |
+
|
205 |
+
def log_test_environment() -> None:
|
206 |
+
"""Log information about the test environment."""
|
207 |
+
logger = get_test_logger(__name__)
|
208 |
+
logger.debug(f"π§ Test environment - Debug: {test_config['debug_enabled']}")
|
209 |
+
logger.debug(f"π§ Running under pytest: {test_config['pytest_running']}")
|
210 |
+
logger.debug(f"π§ Log level: {test_config['log_level']}")
|