494 lines
17 KiB
Python
494 lines
17 KiB
Python
"""
|
|
Unit tests for Smart Scheduling Engine (Phase 10)
|
|
Tests for SmartScheduler class and scoring algorithms
|
|
"""
|
|
|
|
import pytest
|
|
from datetime import datetime, timedelta, time
|
|
from django.test import TestCase
|
|
from django.utils import timezone
|
|
from django.contrib.auth import get_user_model
|
|
|
|
from appointments.scheduling.smart_scheduler import SmartScheduler
|
|
from appointments.models import (
|
|
AppointmentRequest, SchedulingPreference, AppointmentPriorityRule,
|
|
SchedulingMetrics, AppointmentTemplate
|
|
)
|
|
from patients.models import PatientProfile
|
|
from core.models import Tenant
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
class SmartSchedulerTestCase(TestCase):
|
|
"""Test cases for SmartScheduler class"""
|
|
|
|
def setUp(self):
|
|
"""Set up test data"""
|
|
# Create tenant
|
|
self.tenant = Tenant.objects.create(
|
|
name="Test Hospital",
|
|
display_name="Test Hospital",
|
|
address_line1="123 Test St",
|
|
city="Test City",
|
|
state="Test State",
|
|
postal_code="12345",
|
|
phone_number="+966501234567",
|
|
email="test@hospital.com"
|
|
)
|
|
|
|
# Create provider
|
|
self.provider = User.objects.create_user(
|
|
email="provider@test.com",
|
|
first_name="Test",
|
|
last_name="Provider",
|
|
tenant=self.tenant,
|
|
role="PHYSICIAN"
|
|
)
|
|
|
|
# Create patient
|
|
self.patient = PatientProfile.objects.create(
|
|
tenant=self.tenant,
|
|
first_name="Test",
|
|
last_name="Patient",
|
|
date_of_birth=datetime(1990, 1, 1).date(),
|
|
gender="M",
|
|
mrn="TEST001"
|
|
)
|
|
|
|
# Create appointment template
|
|
self.appointment_type = AppointmentTemplate.objects.create(
|
|
tenant=self.tenant,
|
|
name="General Consultation",
|
|
appointment_type="CONSULTATION",
|
|
specialty="FAMILY_MEDICINE",
|
|
duration_minutes=30,
|
|
is_active=True
|
|
)
|
|
|
|
# Create scheduling preference for patient
|
|
self.preference = SchedulingPreference.objects.create(
|
|
tenant=self.tenant,
|
|
patient=self.patient,
|
|
preferred_days_of_week=[1, 2, 3], # Mon, Tue, Wed
|
|
preferred_time_of_day="MORNING",
|
|
no_show_rate=5.0,
|
|
completion_rate=95.0
|
|
)
|
|
|
|
# Initialize scheduler
|
|
self.scheduler = SmartScheduler(self.tenant)
|
|
|
|
def test_scheduler_initialization(self):
|
|
"""Test SmartScheduler initialization"""
|
|
self.assertIsNotNone(self.scheduler)
|
|
self.assertEqual(self.scheduler.tenant, self.tenant)
|
|
|
|
def test_calculate_provider_availability_score(self):
|
|
"""Test provider availability scoring"""
|
|
# Create a slot
|
|
slot_datetime = timezone.now() + timedelta(days=1, hours=10)
|
|
|
|
# Test with available slot
|
|
score = self.scheduler._calculate_provider_availability_score(
|
|
self.provider,
|
|
slot_datetime,
|
|
30
|
|
)
|
|
|
|
# Score should be between 0 and 1
|
|
self.assertGreaterEqual(score, 0.0)
|
|
self.assertLessEqual(score, 1.0)
|
|
|
|
def test_calculate_patient_priority_score(self):
|
|
"""Test patient priority scoring"""
|
|
# Create priority rule
|
|
priority_rule = AppointmentPriorityRule.objects.create(
|
|
tenant=self.tenant,
|
|
name="Urgent Care",
|
|
priority_level="URGENT",
|
|
icd10_codes=["R07.9"], # Chest pain
|
|
urgency_multiplier=2.0,
|
|
max_wait_days=7
|
|
)
|
|
|
|
# Test priority scoring
|
|
score = self.scheduler._calculate_patient_priority_score(
|
|
self.patient,
|
|
self.appointment_type,
|
|
icd10_codes=["R07.9"]
|
|
)
|
|
|
|
# Score should be elevated due to priority rule
|
|
self.assertGreater(score, 0.5)
|
|
|
|
def test_calculate_no_show_risk_score(self):
|
|
"""Test no-show risk calculation"""
|
|
# Test with patient who has low no-show rate
|
|
score = self.scheduler._calculate_no_show_risk_score(
|
|
self.patient,
|
|
timezone.now() + timedelta(days=1, hours=10)
|
|
)
|
|
|
|
# Score should be high (low risk)
|
|
self.assertGreater(score, 0.8)
|
|
|
|
# Update preference with high no-show rate
|
|
self.preference.no_show_rate = 50.0
|
|
self.preference.save()
|
|
|
|
# Recalculate
|
|
score = self.scheduler._calculate_no_show_risk_score(
|
|
self.patient,
|
|
timezone.now() + timedelta(days=1, hours=10)
|
|
)
|
|
|
|
# Score should be lower (higher risk)
|
|
self.assertLess(score, 0.6)
|
|
|
|
def test_calculate_geographic_score(self):
|
|
"""Test geographic proximity scoring"""
|
|
# Set patient location
|
|
self.preference.patient_latitude = 24.7136
|
|
self.preference.patient_longitude = 46.6753
|
|
self.preference.save()
|
|
|
|
# Test with nearby location
|
|
score = self.scheduler._calculate_geographic_score(
|
|
self.patient,
|
|
24.7200, # Nearby latitude
|
|
46.6800 # Nearby longitude
|
|
)
|
|
|
|
# Score should be high (close proximity)
|
|
self.assertGreater(score, 0.7)
|
|
|
|
def test_calculate_patient_preference_score(self):
|
|
"""Test patient preference scoring"""
|
|
# Test with preferred day and time
|
|
preferred_datetime = timezone.now().replace(
|
|
hour=9, minute=0, second=0, microsecond=0
|
|
)
|
|
# Make it a Monday
|
|
days_ahead = (0 - preferred_datetime.weekday()) % 7
|
|
if days_ahead == 0:
|
|
days_ahead = 7
|
|
preferred_datetime += timedelta(days=days_ahead)
|
|
|
|
score = self.scheduler._calculate_patient_preference_score(
|
|
self.patient,
|
|
preferred_datetime
|
|
)
|
|
|
|
# Score should be high (matches preferences)
|
|
self.assertGreater(score, 0.7)
|
|
|
|
# Test with non-preferred day (Sunday)
|
|
non_preferred_datetime = preferred_datetime + timedelta(days=6)
|
|
|
|
score = self.scheduler._calculate_patient_preference_score(
|
|
self.patient,
|
|
non_preferred_datetime
|
|
)
|
|
|
|
# Score should be lower
|
|
self.assertLess(score, 0.5)
|
|
|
|
def test_calculate_slot_score(self):
|
|
"""Test overall slot scoring"""
|
|
slot_datetime = timezone.now() + timedelta(days=1, hours=10)
|
|
|
|
score = self.scheduler._calculate_slot_score(
|
|
patient=self.patient,
|
|
provider=self.provider,
|
|
slot_datetime=slot_datetime,
|
|
appointment_type=self.appointment_type,
|
|
duration_minutes=30
|
|
)
|
|
|
|
# Score should be a dictionary with all components
|
|
self.assertIn('total_score', score)
|
|
self.assertIn('provider_availability', score)
|
|
self.assertIn('patient_priority', score)
|
|
self.assertIn('no_show_risk', score)
|
|
self.assertIn('geographic_proximity', score)
|
|
self.assertIn('patient_preference', score)
|
|
|
|
# Total score should be between 0 and 100
|
|
self.assertGreaterEqual(score['total_score'], 0)
|
|
self.assertLessEqual(score['total_score'], 100)
|
|
|
|
def test_find_optimal_slots_basic(self):
|
|
"""Test basic optimal slot finding"""
|
|
# Create date range
|
|
start_date = timezone.now() + timedelta(days=1)
|
|
date_range = [start_date + timedelta(days=i) for i in range(7)]
|
|
|
|
# Find slots
|
|
slots = self.scheduler.find_optimal_slots(
|
|
patient=self.patient,
|
|
provider=self.provider,
|
|
appointment_type=self.appointment_type,
|
|
preferred_dates=date_range,
|
|
duration_minutes=30,
|
|
max_results=5
|
|
)
|
|
|
|
# Should return a list
|
|
self.assertIsInstance(slots, list)
|
|
|
|
# Each slot should have required fields
|
|
if slots:
|
|
slot = slots[0]
|
|
self.assertIn('datetime', slot)
|
|
self.assertIn('score', slot)
|
|
self.assertIn('score_breakdown', slot)
|
|
|
|
def test_find_optimal_slots_with_constraints(self):
|
|
"""Test slot finding with time constraints"""
|
|
start_date = timezone.now() + timedelta(days=1)
|
|
date_range = [start_date + timedelta(days=i) for i in range(3)]
|
|
|
|
# Find slots with morning preference
|
|
slots = self.scheduler.find_optimal_slots(
|
|
patient=self.patient,
|
|
provider=self.provider,
|
|
appointment_type=self.appointment_type,
|
|
preferred_dates=date_range,
|
|
duration_minutes=30,
|
|
time_preferences=['MORNING'],
|
|
max_results=10
|
|
)
|
|
|
|
# All slots should be in morning hours (before 12:00)
|
|
for slot in slots:
|
|
slot_time = slot['datetime'].time()
|
|
self.assertLess(slot_time.hour, 12)
|
|
|
|
def test_scoring_weights(self):
|
|
"""Test that scoring weights sum to 1.0"""
|
|
weights = self.scheduler.scoring_weights
|
|
|
|
total_weight = sum(weights.values())
|
|
|
|
# Weights should sum to 1.0 (with small tolerance for floating point)
|
|
self.assertAlmostEqual(total_weight, 1.0, places=2)
|
|
|
|
def test_no_show_prediction_time_factors(self):
|
|
"""Test no-show prediction with time-based factors"""
|
|
# Early morning appointment (should have higher risk)
|
|
early_morning = timezone.now().replace(hour=7, minute=0)
|
|
score_early = self.scheduler._calculate_no_show_risk_score(
|
|
self.patient,
|
|
early_morning
|
|
)
|
|
|
|
# Mid-morning appointment (should have lower risk)
|
|
mid_morning = timezone.now().replace(hour=10, minute=0)
|
|
score_mid = self.scheduler._calculate_no_show_risk_score(
|
|
self.patient,
|
|
mid_morning
|
|
)
|
|
|
|
# Early morning should have higher risk (lower score)
|
|
self.assertLess(score_early, score_mid)
|
|
|
|
def test_priority_routing(self):
|
|
"""Test priority-based routing for urgent appointments"""
|
|
# Create urgent priority rule
|
|
urgent_rule = AppointmentPriorityRule.objects.create(
|
|
tenant=self.tenant,
|
|
name="Emergency",
|
|
priority_level="EMERGENCY",
|
|
icd10_codes=["I21.9"], # Acute MI
|
|
urgency_multiplier=5.0,
|
|
max_wait_days=1
|
|
)
|
|
|
|
# Calculate priority score
|
|
score = self.scheduler._calculate_patient_priority_score(
|
|
self.patient,
|
|
self.appointment_type,
|
|
icd10_codes=["I21.9"]
|
|
)
|
|
|
|
# Should have very high priority score
|
|
self.assertGreater(score, 0.9)
|
|
|
|
|
|
class ScoringAlgorithmTestCase(TestCase):
|
|
"""Test cases for individual scoring algorithms"""
|
|
|
|
def setUp(self):
|
|
"""Set up test data"""
|
|
self.tenant = Tenant.objects.create(
|
|
name="Test Hospital",
|
|
display_name="Test Hospital",
|
|
address_line1="123 Test St",
|
|
city="Test City",
|
|
state="Test State",
|
|
postal_code="12345",
|
|
phone_number="+966501234567",
|
|
email="test@hospital.com"
|
|
)
|
|
|
|
self.scheduler = SmartScheduler(self.tenant)
|
|
|
|
def test_time_of_day_scoring(self):
|
|
"""Test time of day preference scoring"""
|
|
# Morning preference
|
|
morning_time = time(9, 0)
|
|
afternoon_time = time(14, 0)
|
|
evening_time = time(18, 0)
|
|
|
|
# Test morning preference
|
|
score_morning = self.scheduler._score_time_of_day(morning_time, "MORNING")
|
|
score_afternoon = self.scheduler._score_time_of_day(afternoon_time, "MORNING")
|
|
|
|
# Morning time should score higher for morning preference
|
|
self.assertGreater(score_morning, score_afternoon)
|
|
|
|
def test_day_of_week_scoring(self):
|
|
"""Test day of week preference scoring"""
|
|
# Preferred days: Monday, Wednesday, Friday (0, 2, 4)
|
|
preferred_days = [0, 2, 4]
|
|
|
|
# Monday (preferred)
|
|
score_preferred = self.scheduler._score_day_of_week(0, preferred_days)
|
|
|
|
# Tuesday (not preferred)
|
|
score_not_preferred = self.scheduler._score_day_of_week(1, preferred_days)
|
|
|
|
# Preferred day should score higher
|
|
self.assertGreater(score_preferred, score_not_preferred)
|
|
|
|
def test_distance_scoring(self):
|
|
"""Test geographic distance scoring"""
|
|
# Test various distances
|
|
score_0km = self.scheduler._score_distance(0)
|
|
score_5km = self.scheduler._score_distance(5)
|
|
score_20km = self.scheduler._score_distance(20)
|
|
score_50km = self.scheduler._score_distance(50)
|
|
|
|
# Closer distances should score higher
|
|
self.assertGreater(score_0km, score_5km)
|
|
self.assertGreater(score_5km, score_20km)
|
|
self.assertGreater(score_20km, score_50km)
|
|
|
|
def test_no_show_rate_scoring(self):
|
|
"""Test no-show rate scoring"""
|
|
# Low no-show rate (good)
|
|
score_low = self.scheduler._score_no_show_rate(5.0)
|
|
|
|
# High no-show rate (bad)
|
|
score_high = self.scheduler._score_no_show_rate(50.0)
|
|
|
|
# Lower no-show rate should score higher
|
|
self.assertGreater(score_low, score_high)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class SmartSchedulerPerformanceTest(TestCase):
|
|
"""Performance tests for SmartScheduler"""
|
|
|
|
def setUp(self):
|
|
"""Set up test data"""
|
|
self.tenant = Tenant.objects.create(
|
|
name="Test Hospital",
|
|
display_name="Test Hospital",
|
|
address_line1="123 Test St",
|
|
city="Test City",
|
|
state="Test State",
|
|
postal_code="12345",
|
|
phone_number="+966501234567",
|
|
email="test@hospital.com"
|
|
)
|
|
|
|
self.scheduler = SmartScheduler(self.tenant)
|
|
|
|
# Create multiple providers
|
|
self.providers = []
|
|
for i in range(5):
|
|
provider = User.objects.create_user(
|
|
email=f"provider{i}@test.com",
|
|
first_name=f"Provider{i}",
|
|
last_name="Test",
|
|
tenant=self.tenant,
|
|
role="PHYSICIAN"
|
|
)
|
|
self.providers.append(provider)
|
|
|
|
# Create patient
|
|
self.patient = PatientProfile.objects.create(
|
|
tenant=self.tenant,
|
|
first_name="Test",
|
|
last_name="Patient",
|
|
date_of_birth=datetime(1990, 1, 1).date(),
|
|
gender="M",
|
|
mrn="TEST001"
|
|
)
|
|
|
|
# Create appointment template
|
|
self.appointment_type = AppointmentTemplate.objects.create(
|
|
tenant=self.tenant,
|
|
name="General Consultation",
|
|
appointment_type="CONSULTATION",
|
|
specialty="FAMILY_MEDICINE",
|
|
duration_minutes=30,
|
|
is_active=True
|
|
)
|
|
|
|
def test_slot_finding_performance(self):
|
|
"""Test performance of slot finding for 30-day range"""
|
|
import time
|
|
|
|
# Create 30-day date range
|
|
start_date = timezone.now() + timedelta(days=1)
|
|
date_range = [start_date + timedelta(days=i) for i in range(30)]
|
|
|
|
# Measure time
|
|
start_time = time.time()
|
|
|
|
slots = self.scheduler.find_optimal_slots(
|
|
patient=self.patient,
|
|
provider=self.providers[0],
|
|
appointment_type=self.appointment_type,
|
|
preferred_dates=date_range,
|
|
duration_minutes=30,
|
|
max_results=20
|
|
)
|
|
|
|
end_time = time.time()
|
|
execution_time = end_time - start_time
|
|
|
|
# Should complete in less than 2 seconds
|
|
self.assertLess(execution_time, 2.0)
|
|
|
|
print(f"\nSlot finding for 30 days completed in {execution_time:.3f} seconds")
|
|
|
|
def test_scoring_performance(self):
|
|
"""Test performance of scoring algorithm"""
|
|
import time
|
|
|
|
slot_datetime = timezone.now() + timedelta(days=1, hours=10)
|
|
|
|
# Measure time for 100 score calculations
|
|
start_time = time.time()
|
|
|
|
for _ in range(100):
|
|
self.scheduler._calculate_slot_score(
|
|
patient=self.patient,
|
|
provider=self.providers[0],
|
|
slot_datetime=slot_datetime,
|
|
appointment_type=self.appointment_type,
|
|
duration_minutes=30
|
|
)
|
|
|
|
end_time = time.time()
|
|
execution_time = end_time - start_time
|
|
|
|
# 100 calculations should complete in less than 1 second
|
|
self.assertLess(execution_time, 1.0)
|
|
|
|
print(f"\n100 score calculations completed in {execution_time:.3f} seconds")
|