import pytest from django.contrib.auth import get_user_model from rest_framework import status from rest_framework.test import APIClient from apps.analysis.services import ( LEVEL_THRESHOLDS, MANDATORY_CAP, compute_gap_report, ) from apps.roles.models import Role, RoleSkill, UserTargetRole from apps.skills.models import Skill, UserSkill User = get_user_model() pytestmark = pytest.mark.django_db ANALYSIS_URL = '/api/analysis/' @pytest.fixture def user(): return User.objects.create_user( username='analysis@example.com', email='analysis@example.com', password='password123', name='Analysis Tester', ) @pytest.fixture def auth_client(user): client = APIClient() client.force_authenticate(user=user) return client def _skill(name, category='Programming'): return Skill.objects.create(skill_name=name, category=category, difficulty_level='BEGINNER') def _role_with(skill_specs): """skill_specs = [(skill_name, required_level, weight, is_mandatory), ...]""" role = Role.objects.create(role_name=f'Role-{id(skill_specs)}', industry='Tech', is_active=True) for name, level, weight, mandatory in skill_specs: RoleSkill.objects.create( role=role, skill=_skill(name), required_level=level, weight=weight, is_mandatory=mandatory, ) return role def _set_prof(user, skill_name, proficiency, level='BEGINNER'): skill = Skill.objects.get(skill_name=skill_name) UserSkill.objects.update_or_create( user=user, skill=skill, defaults={'proficiency': proficiency, 'user_level': level}, ) class TestReportToDict: def test_to_dict_exposes_mandatory_cap_value(self, user): # F18: the numeric cap value is exposed so the UI need not hardcode "60". role = _role_with([('Python', 'INTERMEDIATE', 1.0, False)]) _set_prof(user, 'Python', 60) d = compute_gap_report(user, role).to_dict() assert d['mandatory_cap'] == MANDATORY_CAP class TestClassification: def test_met_when_proficiency_equals_threshold(self, user): role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) _set_prof(user, 'Python', 60) report = compute_gap_report(user, role) assert report.gaps[0].gap_type == 'MET' assert report.gaps[0].severity is None assert report.gaps[0].gap == 0 assert report.gaps[0].satisfaction == 1.0 def test_missing_mandatory_is_critical(self, user): role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) report = compute_gap_report(user, role) g = report.gaps[0] assert g.gap_type == 'MISSING' assert g.severity == 'CRITICAL' assert g.user_proficiency == 0 def test_missing_optional_is_medium(self, user): role = _role_with([('Python', 'INTERMEDIATE', 1.0, False)]) report = compute_gap_report(user, role) assert report.gaps[0].gap_type == 'MISSING' assert report.gaps[0].severity == 'MEDIUM' def test_insufficient_mandatory_forces_high(self, user): # Ratio 0.17 (low in absolute terms), but mandatory promotes to HIGH. role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) _set_prof(user, 'Python', 50) g = compute_gap_report(user, role).gaps[0] assert g.gap_type == 'INSUFFICIENT' assert g.severity == 'HIGH' def test_insufficient_ratio_high(self, user): # U=20, T=60 → ratio = 40/60 ≈ 0.67 ≥ 0.5 → HIGH role = _role_with([('Python', 'INTERMEDIATE', 1.0, False)]) _set_prof(user, 'Python', 20) assert compute_gap_report(user, role).gaps[0].severity == 'HIGH' def test_insufficient_ratio_medium(self, user): # U=60, T=100 → ratio = 40/100 = 0.4 → MEDIUM role = _role_with([('Python', 'ADVANCED', 1.0, False)]) _set_prof(user, 'Python', 60) assert compute_gap_report(user, role).gaps[0].severity == 'MEDIUM' def test_insufficient_ratio_low(self, user): # U=85, T=100 → ratio = 0.15 < 0.25 → LOW role = _role_with([('Python', 'ADVANCED', 1.0, False)]) _set_prof(user, 'Python', 85) assert compute_gap_report(user, role).gaps[0].severity == 'LOW' def test_thresholds_match_spec(self): assert LEVEL_THRESHOLDS == {'BEGINNER': 40, 'INTERMEDIATE': 60, 'ADVANCED': 100} class TestReadiness: def test_all_met_yields_100(self, user): role = _role_with([ ('Python', 'INTERMEDIATE', 1.0, True), ('SQL', 'BEGINNER', 1.0, False), ]) _set_prof(user, 'Python', 60) _set_prof(user, 'SQL', 40) report = compute_gap_report(user, role) assert report.readiness == 100.0 assert report.band == 'STRONG' assert report.mandatory_cap_applied is False def test_weighted_aggregation(self, user): # Python weight 3, fully met (S=1). SQL weight 1, unmet (U=0, S=0). # Raw = (1*3 + 0*1)/(3+1) * 100 = 75. SQL is optional, no cap. role = _role_with([ ('Python', 'INTERMEDIATE', 3.0, False), ('SQL', 'INTERMEDIATE', 1.0, False), ]) _set_prof(user, 'Python', 60) report = compute_gap_report(user, role) assert report.readiness == 75.0 assert report.band == 'GOOD' def test_mandatory_cap_applies(self, user): # Three skills, two met heavily weighted, one mandatory at 0 → raw would # be high but cap pins readiness at 60. role = _role_with([ ('Python', 'INTERMEDIATE', 5.0, False), ('SQL', 'INTERMEDIATE', 5.0, False), ('Docker', 'INTERMEDIATE', 1.0, True), # mandatory, unmet ]) _set_prof(user, 'Python', 60) _set_prof(user, 'SQL', 60) report = compute_gap_report(user, role) assert report.mandatory_cap_applied is True assert report.readiness == MANDATORY_CAP assert report.band == 'PARTIAL' def test_mandatory_cap_not_applied_when_all_mandatory_met(self, user): role = _role_with([ ('Python', 'INTERMEDIATE', 1.0, True), ('Docker', 'BEGINNER', 1.0, False), # optional, unmet ]) _set_prof(user, 'Python', 60) report = compute_gap_report(user, role) assert report.mandatory_cap_applied is False # Raw = (1.0 + 0)/2 * 100 = 50 → PARTIAL (no cap needed) assert report.readiness == 50.0 assert report.band == 'PARTIAL' def test_cap_not_applied_when_raw_below_cap(self, user): # Mandatory unmet AND raw already below 60 — cap flag stays False. role = _role_with([ ('Python', 'INTERMEDIATE', 1.0, True), ('SQL', 'INTERMEDIATE', 1.0, True), ]) _set_prof(user, 'Python', 30) # SQL unmet → 0 sat report = compute_gap_report(user, role) # Raw = (30/60 + 0)/2 * 100 = 25 → WEAK assert report.readiness == 25.0 assert report.mandatory_cap_applied is False assert report.band == 'WEAK' def test_no_requirements_returns_100(self, user): role = Role.objects.create(role_name='Empty', industry='Tech', is_active=True) report = compute_gap_report(user, role) assert report.readiness == 100.0 assert report.no_requirements is True assert report.gaps == [] def test_zero_weight_sum_returns_100(self, user): role = _role_with([ ('Python', 'INTERMEDIATE', 0.0, False), ('SQL', 'BEGINNER', 0.0, False), ]) report = compute_gap_report(user, role) assert report.readiness == 100.0 assert report.no_requirements is True assert len(report.gaps) == 2 class TestBands: # With a single ADVANCED non-mandatory skill (T=100, W=1), the readiness # score equals the proficiency exactly — so proficiency is the knob that # lands us on each side of every band boundary. @pytest.mark.parametrize('prof, expected_readiness, expected_band', [ (100, 100.0, 'STRONG'), (85, 85.0, 'STRONG'), # lower edge of STRONG (≥85) (84, 84.0, 'GOOD'), # just below STRONG (65, 65.0, 'GOOD'), # lower edge of GOOD (≥65) (64, 64.0, 'PARTIAL'), # just below GOOD (45, 45.0, 'PARTIAL'), # lower edge of PARTIAL (≥45) (44, 44.0, 'WEAK'), # just below PARTIAL (0, 0.0, 'WEAK'), ]) def test_band_boundaries(self, user, prof, expected_readiness, expected_band): role = _role_with([('Python', 'ADVANCED', 1.0, False)]) _set_prof(user, 'Python', prof) report = compute_gap_report(user, role) assert report.readiness == pytest.approx(expected_readiness, abs=0.01) assert report.band == expected_band class TestGapSorting: def test_gaps_sorted_by_severity_then_weight(self, user): role = _role_with([ ('Python', 'INTERMEDIATE', 1.0, True), # MISSING CRITICAL ('SQL', 'INTERMEDIATE', 5.0, False), # MET ('Docker', 'INTERMEDIATE', 2.0, False), # MISSING MEDIUM ]) _set_prof(user, 'SQL', 60) report = compute_gap_report(user, role) names = [g.skill_name for g in report.gaps] # CRITICAL > MEDIUM > MET assert names == ['Python', 'Docker', 'SQL'] class TestAPI: def test_requires_auth(self): response = APIClient().get(ANALYSIS_URL) assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_400_when_no_target_role(self, auth_client): response = auth_client.get(ANALYSIS_URL) assert response.status_code == status.HTTP_400_BAD_REQUEST def test_200_with_active_target_role(self, auth_client, user): role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) UserTargetRole.objects.create(user=user, role=role, is_active=True) _set_prof(user, 'Python', 60) response = auth_client.get(ANALYSIS_URL) assert response.status_code == status.HTTP_200_OK assert response.data['role_id'] == role.id assert response.data['readiness'] == 100.0 assert response.data['band'] == 'STRONG' assert len(response.data['gaps']) == 1 assert response.data['gaps'][0]['gap_type'] == 'MET' def test_role_id_override(self, auth_client, user): role = _role_with([('Python', 'BEGINNER', 1.0, False)]) response = auth_client.get(f'{ANALYSIS_URL}?role_id={role.id}') assert response.status_code == status.HTTP_200_OK assert response.data['role_id'] == role.id def test_role_id_not_found(self, auth_client): response = auth_client.get(f'{ANALYSIS_URL}?role_id=99999') assert response.status_code == status.HTTP_404_NOT_FOUND def test_inactive_role_id_rejected(self, auth_client): role = Role.objects.create(role_name='Old', industry='X', is_active=False) response = auth_client.get(f'{ANALYSIS_URL}?role_id={role.id}') assert response.status_code == status.HTTP_404_NOT_FOUND