""" Tests for Dynamic Business Rules Engine """ import pytest import pandas as pd import numpy as np from datetime import datetime, timedelta from app.ml.dynamic_rules_engine import DynamicRulesEngine @pytest.fixture def sample_sales_data(): """Generate sample sales data for testing.""" dates = pd.date_range(start='2024-01-01', end='2024-12-31', freq='D') # Base demand with day-of-week pattern base = 100 quantities = [] for date in dates: # Day of week pattern (weekends higher) dow_multiplier = 1.3 if date.dayofweek >= 5 else 1.0 # Monthly seasonality (summer higher) month_multiplier = 1.2 if date.month in [6, 7, 8] else 1.0 # Random noise noise = np.random.normal(1.0, 0.1) quantity = base * dow_multiplier * month_multiplier * noise quantities.append(quantity) return pd.DataFrame({ 'date': dates, 'ds': dates, 'quantity': quantities, 'y': quantities }) @pytest.fixture def sample_weather_data(): """Generate sample weather data for testing.""" dates = pd.date_range(start='2024-01-01', end='2024-12-31', freq='D') weather_conditions = [] temperatures = [] precipitation = [] for date in dates: # Simulate weather patterns if np.random.random() < 0.1: # 10% rainy days weather_conditions.append('rain') precipitation.append(np.random.uniform(5, 20)) elif np.random.random() < 0.05: # 5% snow weather_conditions.append('snow') precipitation.append(np.random.uniform(2, 10)) else: weather_conditions.append('clear') precipitation.append(0) # Temperature varies by month base_temp = 10 + (date.month - 1) * 2 temperatures.append(base_temp + np.random.normal(0, 5)) return pd.DataFrame({ 'date': dates, 'weather_condition': weather_conditions, 'temperature': temperatures, 'precipitation': precipitation }) @pytest.fixture def sample_holiday_data(): """Generate sample holiday data for testing.""" dates = pd.date_range(start='2024-01-01', end='2024-12-31', freq='D') holidays = [] # Add some holidays holiday_dates = { '2024-01-01': ('New Year', 'national'), '2024-03-29': ('Good Friday', 'religious'), '2024-04-01': ('Easter Monday', 'religious'), '2024-12-25': ('Christmas', 'religious'), '2024-12-26': ('Boxing Day', 'national') } for date in dates: date_str = date.strftime('%Y-%m-%d') if date_str in holiday_dates: name, htype = holiday_dates[date_str] holidays.append({ 'date': date, 'is_holiday': True, 'holiday_name': name, 'holiday_type': htype }) else: holidays.append({ 'date': date, 'is_holiday': False, 'holiday_name': None, 'holiday_type': None }) return pd.DataFrame(holidays) @pytest.fixture def sales_with_weather_impact(sample_sales_data, sample_weather_data): """Generate sales data with weather impact.""" merged = sample_sales_data.merge(sample_weather_data, on='date') # Apply weather impact for idx, row in merged.iterrows(): if row['weather_condition'] == 'rain': merged.at[idx, 'quantity'] *= 0.85 # -15% for rain merged.at[idx, 'y'] *= 0.85 elif row['weather_condition'] == 'snow': merged.at[idx, 'quantity'] *= 0.75 # -25% for snow merged.at[idx, 'y'] *= 0.75 return merged @pytest.fixture def sales_with_holiday_impact(sample_sales_data, sample_holiday_data): """Generate sales data with holiday impact.""" merged = sample_sales_data.merge(sample_holiday_data, on='date') # Apply holiday impact for idx, row in merged.iterrows(): if row['is_holiday'] and row['holiday_type'] == 'religious': merged.at[idx, 'quantity'] *= 1.6 # +60% for religious holidays merged.at[idx, 'y'] *= 1.6 elif row['is_holiday']: merged.at[idx, 'quantity'] *= 1.3 # +30% for national holidays merged.at[idx, 'y'] *= 1.3 return merged @pytest.mark.asyncio async def test_learn_weather_rules(sales_with_weather_impact, sample_weather_data): """Test weather rules learning.""" engine = DynamicRulesEngine() results = await engine.learn_all_rules( tenant_id='test-tenant', inventory_product_id='test-product', sales_data=sales_with_weather_impact, external_data=sample_weather_data, min_samples=5 ) # Check weather rules were learned assert 'weather' in results['rules'] assert 'baseline_avg' in results['rules']['weather'] assert 'conditions' in results['rules']['weather'] # Check rain rule learned if 'rain' in results['rules']['weather']['conditions']: rain_rule = results['rules']['weather']['conditions']['rain'] assert 'learned_multiplier' in rain_rule assert 'learned_impact_pct' in rain_rule assert rain_rule['sample_size'] >= 5 # Learned multiplier should be close to 0.85 (we applied -15% impact) assert 0.75 < rain_rule['learned_multiplier'] < 0.95 # Check insights generated assert 'insights' in results assert len(results['insights']) > 0 @pytest.mark.asyncio async def test_learn_holiday_rules(sales_with_holiday_impact, sample_holiday_data): """Test holiday rules learning.""" engine = DynamicRulesEngine() results = await engine.learn_all_rules( tenant_id='test-tenant', inventory_product_id='test-product', sales_data=sales_with_holiday_impact, external_data=sample_holiday_data, min_samples=2 ) # Check holiday rules were learned assert 'holidays' in results['rules'] assert 'baseline_avg' in results['rules']['holidays'] if 'holiday_types' in results['rules']['holidays']: holiday_types = results['rules']['holidays']['holiday_types'] # Check religious holidays learned higher impact than national if 'religious' in holiday_types and 'national' in holiday_types: religious_mult = holiday_types['religious']['learned_multiplier'] national_mult = holiday_types['national']['learned_multiplier'] # Religious should have higher multiplier (we applied 1.6 vs 1.3) assert religious_mult > national_mult @pytest.mark.asyncio async def test_learn_day_of_week_rules(sample_sales_data): """Test day-of-week pattern learning.""" engine = DynamicRulesEngine() results = await engine.learn_all_rules( tenant_id='test-tenant', inventory_product_id='test-product', sales_data=sample_sales_data, external_data=None, min_samples=10 ) # Check day-of-week rules learned assert 'day_of_week' in results['rules'] assert 'days' in results['rules']['day_of_week'] days = results['rules']['day_of_week']['days'] # Weekend should have higher multipliers (we applied 1.3x) if 'Saturday' in days and 'Monday' in days: saturday_mult = days['Saturday']['learned_multiplier'] monday_mult = days['Monday']['learned_multiplier'] assert saturday_mult > monday_mult @pytest.mark.asyncio async def test_learn_month_rules(sample_sales_data): """Test monthly seasonality learning.""" engine = DynamicRulesEngine() results = await engine.learn_all_rules( tenant_id='test-tenant', inventory_product_id='test-product', sales_data=sample_sales_data, external_data=None, min_samples=10 ) # Check month rules learned assert 'months' in results['rules'] assert 'months' in results['rules']['months'] months = results['rules']['months']['months'] # Summer months (June, July, August) should have higher multipliers if 'July' in months and 'January' in months: july_mult = months['July']['learned_multiplier'] january_mult = months['January']['learned_multiplier'] assert july_mult > january_mult @pytest.mark.asyncio async def test_insight_generation_weather_mismatch(sales_with_weather_impact, sample_weather_data): """Test that insights are generated when learned rules differ from hardcoded.""" engine = DynamicRulesEngine() results = await engine.learn_all_rules( tenant_id='test-tenant', inventory_product_id='test-product', sales_data=sales_with_weather_impact, external_data=sample_weather_data, min_samples=5 ) # Should generate insights comparing learned vs hardcoded insights = results['insights'] # Check for weather-related insights weather_insights = [i for i in insights if 'weather' in i.get('title', '').lower()] if weather_insights: insight = weather_insights[0] assert 'type' in insight assert 'priority' in insight assert 'confidence' in insight assert 'metrics_json' in insight assert 'actionable' in insight assert 'recommendation_actions' in insight @pytest.mark.asyncio async def test_confidence_calculation(): """Test confidence score calculation.""" engine = DynamicRulesEngine() # High confidence: large sample, low p-value high_conf = engine._calculate_confidence(sample_size=150, p_value=0.001) assert high_conf >= 90 # Medium confidence: moderate sample, moderate p-value med_conf = engine._calculate_confidence(sample_size=50, p_value=0.03) assert 60 <= med_conf < 90 # Low confidence: small sample, high p-value low_conf = engine._calculate_confidence(sample_size=15, p_value=0.12) assert low_conf < 60 def test_get_rule(): """Test getting learned rules.""" engine = DynamicRulesEngine() # Manually set some rules for testing engine.weather_rules['product-1'] = { 'conditions': { 'rain': { 'learned_multiplier': 0.85 } } } engine.dow_rules['product-1'] = { 'days': { 'Saturday': { 'learned_multiplier': 1.25 } } } # Test retrieval rain_mult = engine.get_rule('product-1', 'weather', 'rain') assert rain_mult == 0.85 saturday_mult = engine.get_rule('product-1', 'day_of_week', 'Saturday') assert saturday_mult == 1.25 # Test non-existent rule unknown = engine.get_rule('product-1', 'weather', 'tornado') assert unknown is None def test_export_rules_for_prophet(): """Test exporting rules for Prophet integration.""" engine = DynamicRulesEngine() # Set up some test rules engine.weather_rules['product-1'] = {'conditions': {'rain': {'learned_multiplier': 0.85}}} engine.holiday_rules['product-1'] = {'holiday_types': {'Christmas': {'learned_multiplier': 1.7}}} # Export exported = engine.export_rules_for_prophet('product-1') assert 'weather' in exported assert 'holidays' in exported assert 'events' in exported assert 'day_of_week' in exported assert 'months' in exported @pytest.mark.asyncio async def test_no_external_data(sample_sales_data): """Test that engine works with sales data only (no external data).""" engine = DynamicRulesEngine() results = await engine.learn_all_rules( tenant_id='test-tenant', inventory_product_id='test-product', sales_data=sample_sales_data, external_data=None, min_samples=10 ) # Should still learn DOW and month patterns assert 'day_of_week' in results['rules'] assert 'months' in results['rules'] # Weather/holiday/event rules should not be present assert 'weather' not in results['rules'] or len(results['rules']['weather'].get('conditions', {})) == 0 @pytest.mark.asyncio async def test_insufficient_samples(sample_sales_data): """Test handling of insufficient sample sizes.""" # Use only 30 days of data small_data = sample_sales_data.head(30) engine = DynamicRulesEngine() results = await engine.learn_all_rules( tenant_id='test-tenant', inventory_product_id='test-product', sales_data=small_data, external_data=None, min_samples=50 # Require more samples than available ) # Should still return results but with fewer learned rules assert 'rules' in results assert 'insights' in results