Spaces:
Sleeping
Sleeping
| """Cross-module test: complete resource → upgrade suggestion → apply → gap | |
| report shows improved readiness. Exercises progress + analysis seam. | |
| """ | |
| import pytest | |
| from django.contrib.auth import get_user_model | |
| from rest_framework.test import APIClient | |
| from apps.progress.models import UserProgress | |
| from apps.resources.models import Resource, ResourceCheckpoint, SkillResource | |
| from apps.roles.models import Role, RoleSkill, UserTargetRole | |
| from apps.skills.models import Skill, UserSkill | |
| User = get_user_model() | |
| pytestmark = pytest.mark.django_db | |
| REGISTER = '/api/auth/register/' | |
| USER_SKILLS = '/api/user-skills/' | |
| TARGET_ROLE = '/api/target-role/' | |
| ANALYSIS = '/api/analysis/' | |
| UPGRADES = '/api/progress/upgrade-suggestions/' | |
| def _auth_client(email='closed@x.com'): | |
| c = APIClient() | |
| r = c.post(REGISTER, data={ | |
| 'name': 'Closed Loop', 'email': email, | |
| 'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!', | |
| }, format='json') | |
| assert r.status_code == 201, r.data | |
| c.credentials(HTTP_AUTHORIZATION=f'Bearer {r.data["access"]}') | |
| return c | |
| def loop_fixture(db): | |
| """Data Scientist gap scenario: Python mandatory ADVANCED (short), SQL | |
| MET (pushes raw readiness above the 60 cap threshold), ML optional MISSING | |
| (the thing we'll upgrade).""" | |
| py = Skill.objects.create( | |
| skill_name='Python', category='Programming', difficulty_level='BEGINNER', | |
| ) | |
| sql = Skill.objects.create( | |
| skill_name='SQL', category='Database', difficulty_level='BEGINNER', | |
| ) | |
| ml = Skill.objects.create( | |
| skill_name='ML', category='AI', difficulty_level='INTERMEDIATE', | |
| ) | |
| role = Role.objects.create( | |
| role_name='Data Scientist', industry='DS', is_active=True, | |
| ) | |
| RoleSkill.objects.create( | |
| role=role, skill=py, required_level='ADVANCED', | |
| weight=1.0, is_mandatory=True, | |
| ) | |
| RoleSkill.objects.create( | |
| role=role, skill=sql, required_level='INTERMEDIATE', | |
| weight=2.0, is_mandatory=False, | |
| ) | |
| RoleSkill.objects.create( | |
| role=role, skill=ml, required_level='INTERMEDIATE', | |
| weight=0.5, is_mandatory=False, | |
| ) | |
| ml_course = Resource.objects.create( | |
| title='ML Course', provider='Coursera', | |
| url='https://coursera.org/ml', | |
| difficulty_level='INTERMEDIATE', duration=600, type='COURSE', rating=4.8, | |
| ) | |
| for i, t in enumerate(['W1', 'W2', 'W3', 'W4'], start=1): | |
| ResourceCheckpoint.objects.create( | |
| resource=ml_course, order_index=i, title=t, source='manual', | |
| ) | |
| SkillResource.objects.create(skill=ml, resource=ml_course, relevance_score=1.0) | |
| py_course = Resource.objects.create( | |
| title='Python Advanced', provider='Docs', | |
| url='https://docs.python.org/3/', | |
| difficulty_level='ADVANCED', duration=0, type='DOCS', rating=4.6, | |
| ) | |
| SkillResource.objects.create(skill=py, resource=py_course, relevance_score=1.0) | |
| return {'role': role, 'py': py, 'sql': sql, 'ml': ml, | |
| 'ml_course': ml_course, 'py_course': py_course} | |
| def test_complete_resource_then_gap_improves(loop_fixture): | |
| c = _auth_client() | |
| c.post(USER_SKILLS, | |
| data={'skill_id': loop_fixture['py'].id, 'proficiency': 70}, | |
| format='json') | |
| c.post(USER_SKILLS, | |
| data={'skill_id': loop_fixture['sql'].id, 'proficiency': 100}, | |
| format='json') | |
| c.post(TARGET_ROLE, | |
| data={'role_id': loop_fixture['role'].id}, format='json') | |
| before = c.get(ANALYSIS).data | |
| # Raw readiness = (0.7*1.0 + 1.0*2.0 + 0*0.5)/3.5 = 77.14 → capped to 60. | |
| assert before['mandatory_cap_applied'] is True | |
| assert before['readiness'] == 60.0 | |
| # Complete every ML checkpoint. | |
| for cp in loop_fixture['ml_course'].checkpoints.all(): | |
| c.post(f'/api/progress/checkpoint/{cp.id}/toggle/') | |
| # Upgrade surfaces. | |
| r = c.get(UPGRADES) | |
| suggestions = r.data['suggestions'] | |
| ml_sug = next(s for s in suggestions if s['skill_id'] == loop_fixture['ml'].id) | |
| assert ml_sug['suggested_proficiency'] == 60 | |
| # Apply the bump. | |
| r = c.post(f"{UPGRADES}{loop_fixture['ml'].id}/apply/") | |
| assert r.status_code == 200 | |
| assert r.data['applied'] is True | |
| after = c.get(ANALYSIS).data | |
| # Still capped by Python shortfall, but ML gap is now satisfied — | |
| # the raw (uncapped) readiness must go up, and the ML gap is MET. | |
| ml_gap = next(g for g in after['gaps'] | |
| if g['skill_id'] == loop_fixture['ml'].id) | |
| assert ml_gap['gap_type'] == 'MET' | |
| assert after['mandatory_cap_applied'] is True | |
| assert after['readiness'] == 60.0 | |
| def test_mandatory_cap_lifts_after_python_upgrade(loop_fixture): | |
| """Drive Python to ADVANCED threshold — mandatory cap drops, readiness | |
| uncaps. Each completion unlocks one rung; we start at 85 so a single | |
| apply takes us to 100. | |
| """ | |
| c = _auth_client(email='cap@x.com') | |
| c.post(USER_SKILLS, | |
| data={'skill_id': loop_fixture['py'].id, 'proficiency': 85}, | |
| format='json') | |
| c.post(USER_SKILLS, | |
| data={'skill_id': loop_fixture['sql'].id, 'proficiency': 100}, | |
| format='json') | |
| c.post(TARGET_ROLE, | |
| data={'role_id': loop_fixture['role'].id}, format='json') | |
| # Mark the DOCS resource (no checkpoints) COMPLETED via manual slider. | |
| r = c.post( | |
| f"/api/progress/resource/{loop_fixture['py_course'].id}/", | |
| data={'progress': 100}, format='json', | |
| ) | |
| assert r.status_code == 200 | |
| assert r.data['status'] == 'COMPLETED' | |
| res = c.post(f"{UPGRADES}{loop_fixture['py'].id}/apply/") | |
| assert res.status_code == 200 | |
| assert res.data['applied'] is True | |
| # A repeat call without a new completion must be a no-op. | |
| res2 = c.post(f"{UPGRADES}{loop_fixture['py'].id}/apply/") | |
| assert res2.data['already_applied'] is True | |
| us = UserSkill.objects.get(user__email='cap@x.com', | |
| skill=loop_fixture['py']) | |
| assert us.proficiency == 100 | |
| after = c.get(ANALYSIS).data | |
| assert after['mandatory_cap_applied'] is False | |
| def test_completed_via_manual_slider_triggers_suggestion(loop_fixture): | |
| """Slider path must reach the same state as checkpoint path.""" | |
| c = _auth_client(email='slider@x.com') | |
| c.post(TARGET_ROLE, | |
| data={'role_id': loop_fixture['role'].id}, format='json') | |
| r = c.post( | |
| f"/api/progress/resource/{loop_fixture['py_course'].id}/", | |
| data={'progress': 100}, format='json', | |
| ) | |
| assert r.status_code == 200 | |
| suggestions = c.get(UPGRADES).data['suggestions'] | |
| py_ids = {s['skill_id'] for s in suggestions} | |
| assert loop_fixture['py'].id in py_ids | |
| def test_completed_via_checkpoints_triggers_suggestion(loop_fixture): | |
| """Checkpoint path must reach the same state as slider path.""" | |
| c = _auth_client(email='cp@x.com') | |
| c.post(TARGET_ROLE, | |
| data={'role_id': loop_fixture['role'].id}, format='json') | |
| for cp in loop_fixture['ml_course'].checkpoints.all(): | |
| c.post(f'/api/progress/checkpoint/{cp.id}/toggle/') | |
| # Status should be COMPLETED after the last checkpoint toggle. | |
| up = UserProgress.objects.get( | |
| resource=loop_fixture['ml_course'], user__email='cp@x.com', | |
| ) | |
| assert up.status == 'COMPLETED' | |
| suggestions = c.get(UPGRADES).data['suggestions'] | |
| assert any(s['skill_id'] == loop_fixture['ml'].id for s in suggestions) | |