gapguide-api / apps /analysis /tests /test_gap_analysis.py
arifRB's picture
Deploy GapGuide backend (Docker)
ffd36e0 verified
Raw
History Blame Contribute Delete
11.4 kB
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