Spaces:
Sleeping
Sleeping
| 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/' | |
| def user(): | |
| return User.objects.create_user( | |
| username='analysis@example.com', | |
| email='analysis@example.com', | |
| password='password123', | |
| name='Analysis Tester', | |
| ) | |
| 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. | |
| 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 | |