Spaces:
Sleeping
Sleeping
| """Unit tests for the skill-upgrade / closed-learning-loop service. | |
| See research/08-next-modules-build-plan.md §Module B. | |
| """ | |
| import pytest | |
| from django.contrib.auth import get_user_model | |
| from django.utils import timezone | |
| from rest_framework.test import APIClient | |
| from apps.progress.models import UserProgress | |
| from apps.progress.services import ( | |
| _suggested_proficiency, | |
| apply_upgrade, | |
| compute_upgrade_suggestions, | |
| dismiss_upgrade, | |
| ) | |
| from apps.resources.models import Resource, SkillResource | |
| from apps.skills.models import Skill, UserSkill | |
| User = get_user_model() | |
| pytestmark = pytest.mark.django_db | |
| UPGRADE_LIST = '/api/progress/upgrade-suggestions/' | |
| def user(): | |
| return User.objects.create_user( | |
| username='upg@x.com', email='upg@x.com', password='pw', name='U', | |
| ) | |
| def auth_client(user): | |
| c = APIClient() | |
| c.force_authenticate(user=user) | |
| return c | |
| def _skill(name='Python', category='Programming'): | |
| return Skill.objects.create( | |
| skill_name=name, category=category, difficulty_level='BEGINNER', | |
| ) | |
| def _resource(title='ML Course', r_type='COURSE', url=None): | |
| return Resource.objects.create( | |
| title=title, provider='X', url=url or f'https://e.com/{title}', | |
| difficulty_level='INTERMEDIATE', duration=100, type=r_type, rating=4.0, | |
| ) | |
| def _complete(user, resource, when=None): | |
| up = UserProgress.objects.create( | |
| user=user, resource=resource, | |
| status='COMPLETED', | |
| progress=100, | |
| completed_at=when or timezone.now(), | |
| ) | |
| return up | |
| def _set_skill(user, skill, proficiency, level='BEGINNER', | |
| dismissed_upgrade_at=None): | |
| return UserSkill.objects.create( | |
| user=user, skill=skill, proficiency=proficiency, user_level=level, | |
| dismissed_upgrade_at=dismissed_upgrade_at, | |
| ) | |
| class TestBumpMap: | |
| def test_zero_to_60(self): | |
| assert _suggested_proficiency(0) == 60 | |
| def test_30_to_60(self): | |
| assert _suggested_proficiency(30) == 60 | |
| def test_50_to_70(self): | |
| assert _suggested_proficiency(50) == 70 | |
| def test_70_to_85(self): | |
| assert _suggested_proficiency(70) == 85 | |
| def test_90_to_100(self): | |
| assert _suggested_proficiency(90) == 100 | |
| def test_100_suppressed(self): | |
| assert _suggested_proficiency(100) is None | |
| class TestComputeSuggestions: | |
| def test_completed_resource_creates_suggestion_when_user_skill_absent( | |
| self, user): | |
| ml = _skill('Machine Learning') | |
| r = _resource() | |
| SkillResource.objects.create(skill=ml, resource=r, relevance_score=1.0) | |
| _complete(user, r) | |
| suggestions = compute_upgrade_suggestions(user) | |
| assert len(suggestions) == 1 | |
| s = suggestions[0] | |
| assert s['skill_id'] == ml.id | |
| assert s['current_proficiency'] == 0 | |
| assert s['suggested_proficiency'] == 60 | |
| assert s['suggested_level'] == 'INTERMEDIATE' | |
| def test_only_completed_status_triggers(self, user): | |
| py = _skill('Python') | |
| r = _resource() | |
| SkillResource.objects.create(skill=py, resource=r) | |
| UserProgress.objects.create( | |
| user=user, resource=r, status='IN_PROGRESS', progress=50, | |
| ) | |
| assert compute_upgrade_suggestions(user) == [] | |
| def test_suggestion_suppressed_at_100(self, user): | |
| py = _skill('Python') | |
| r = _resource() | |
| SkillResource.objects.create(skill=py, resource=r) | |
| _set_skill(user, py, proficiency=100, level='ADVANCED') | |
| _complete(user, r) | |
| assert compute_upgrade_suggestions(user) == [] | |
| def test_never_downgrade(self, user): | |
| """User at 85 → bump map suggests 100. But if current >= target, | |
| suppress. (Table avoids downgrade by design; we also verify the | |
| never-downgrade guard.)""" | |
| py = _skill('Python') | |
| r = _resource() | |
| SkillResource.objects.create(skill=py, resource=r) | |
| _set_skill(user, py, proficiency=95, level='ADVANCED') | |
| _complete(user, r) | |
| suggestions = compute_upgrade_suggestions(user) | |
| assert len(suggestions) == 1 | |
| assert suggestions[0]['suggested_proficiency'] == 100 | |
| def test_multiple_skills_linked_to_one_resource(self, user): | |
| py = _skill('Python') | |
| ml = _skill('Machine Learning') | |
| r = _resource() | |
| SkillResource.objects.create(skill=py, resource=r, relevance_score=0.7) | |
| SkillResource.objects.create(skill=ml, resource=r, relevance_score=1.0) | |
| _complete(user, r) | |
| suggestions = compute_upgrade_suggestions(user) | |
| assert {s['skill_id'] for s in suggestions} == {py.id, ml.id} | |
| def test_dismissal_hides_suggestion(self, user): | |
| py = _skill('Python') | |
| r = _resource() | |
| SkillResource.objects.create(skill=py, resource=r) | |
| # Completion first, then dismissal AFTER → suppresses. | |
| _complete(user, r, when=timezone.now() - timezone.timedelta(days=2)) | |
| _set_skill(user, py, proficiency=0, | |
| dismissed_upgrade_at=timezone.now()) | |
| assert compute_upgrade_suggestions(user) == [] | |
| def test_dismissal_self_heals_on_newer_completion(self, user): | |
| """Dismissing a prior suggestion does not block a later completion | |
| from resurfacing the suggestion.""" | |
| py = _skill('Python') | |
| r1 = _resource(title='First', url='https://e.com/first') | |
| r2 = _resource(title='Second', url='https://e.com/second') | |
| SkillResource.objects.create(skill=py, resource=r1) | |
| SkillResource.objects.create(skill=py, resource=r2) | |
| earlier = timezone.now() - timezone.timedelta(days=5) | |
| _complete(user, r1, when=earlier) | |
| _set_skill(user, py, proficiency=0, | |
| dismissed_upgrade_at=timezone.now() | |
| - timezone.timedelta(days=2)) | |
| # A NEW completion post-dismissal re-surfaces the suggestion. | |
| _complete(user, r2, when=timezone.now()) | |
| suggestions = compute_upgrade_suggestions(user) | |
| assert len(suggestions) == 1 | |
| assert suggestions[0]['skill_id'] == py.id | |
| class TestApply: | |
| def test_apply_creates_user_skill_if_absent(self, user): | |
| ml = _skill('ML') | |
| r = _resource() | |
| SkillResource.objects.create(skill=ml, resource=r) | |
| _complete(user, r) | |
| result = apply_upgrade(user, ml.id) | |
| assert result['applied'] is True | |
| us = UserSkill.objects.get(user=user, skill=ml) | |
| assert us.proficiency == 60 | |
| assert us.user_level == 'INTERMEDIATE' | |
| def test_apply_bumps_proficiency_and_recomputes_level(self, user): | |
| py = _skill('Python') | |
| r = _resource() | |
| SkillResource.objects.create(skill=py, resource=r) | |
| _set_skill(user, py, proficiency=70, level='ADVANCED') | |
| _complete(user, r) | |
| result = apply_upgrade(user, py.id) | |
| assert result['applied'] is True | |
| us = UserSkill.objects.get(user=user, skill=py) | |
| assert us.proficiency == 85 | |
| # proficiency_to_level(85) → ADVANCED (since > 60) | |
| assert us.user_level == 'ADVANCED' | |
| def test_apply_is_idempotent(self, user): | |
| """Repeated applies without a new completion must be no-ops. | |
| One completion → one rung of the bump ladder, then already_applied. | |
| """ | |
| py = _skill('Python') | |
| r = _resource() | |
| SkillResource.objects.create(skill=py, resource=r) | |
| _complete(user, r) | |
| first = apply_upgrade(user, py.id) | |
| assert first['applied'] is True | |
| second = apply_upgrade(user, py.id) | |
| assert second['applied'] is False | |
| assert second['already_applied'] is True | |
| us = UserSkill.objects.get(user=user, skill=py) | |
| assert us.proficiency == 60 # did not climb past one rung | |
| def test_apply_resurfaces_on_new_completion(self, user): | |
| """A completion after the last apply unlocks the next bump.""" | |
| py = _skill('Python') | |
| r1 = _resource(title='First', url='https://e.com/first') | |
| r2 = _resource(title='Second', url='https://e.com/second') | |
| SkillResource.objects.create(skill=py, resource=r1) | |
| SkillResource.objects.create(skill=py, resource=r2) | |
| _complete(user, r1, when=timezone.now() - timezone.timedelta(days=2)) | |
| first = apply_upgrade(user, py.id) | |
| assert first['applied'] is True | |
| assert UserSkill.objects.get(user=user, skill=py).proficiency == 60 | |
| _complete(user, r2, when=timezone.now()) # newer than the cursor | |
| second = apply_upgrade(user, py.id) | |
| assert second['applied'] is True | |
| assert UserSkill.objects.get(user=user, skill=py).proficiency == 85 | |
| def test_apply_when_no_completion_is_a_noop(self, user): | |
| py = _skill('Python') | |
| result = apply_upgrade(user, py.id) | |
| assert result['applied'] is False | |
| assert result['already_applied'] is False | |
| def test_apply_when_already_at_100_is_already_applied(self, user): | |
| py = _skill('Python') | |
| r = _resource() | |
| SkillResource.objects.create(skill=py, resource=r) | |
| _set_skill(user, py, proficiency=100, level='ADVANCED') | |
| _complete(user, r) | |
| result = apply_upgrade(user, py.id) | |
| assert result['applied'] is False | |
| assert result['already_applied'] is True | |
| def test_apply_advances_dismissal_cursor(self, user): | |
| """After an apply, dismissed_upgrade_at is advanced to the latest | |
| completion timestamp. Semantically this means the suggestion is | |
| suppressed until a newer completion arrives — matching the dismissed | |
| flow. A prior user-triggered dismissal must not block an apply that is | |
| acting on a newer completion.""" | |
| py = _skill('Python') | |
| r = _resource() | |
| SkillResource.objects.create(skill=py, resource=r) | |
| earlier = timezone.now() - timezone.timedelta(days=2) | |
| _set_skill(user, py, proficiency=0, dismissed_upgrade_at=earlier) | |
| completion_at = timezone.now() | |
| _complete(user, r, when=completion_at) | |
| result = apply_upgrade(user, py.id) | |
| assert result['applied'] is True | |
| us = UserSkill.objects.get(user=user, skill=py) | |
| assert us.dismissed_upgrade_at == completion_at | |
| # Functional check: the applied suggestion doesn't resurface on a | |
| # re-compute without a new completion. | |
| assert compute_upgrade_suggestions(user) == [] | |
| class TestDismiss: | |
| def test_dismiss_sets_timestamp_and_hides(self, user): | |
| py = _skill('Python') | |
| r = _resource() | |
| SkillResource.objects.create(skill=py, resource=r) | |
| _complete(user, r) | |
| _set_skill(user, py, proficiency=0) | |
| result = dismiss_upgrade(user, py.id) | |
| assert result['dismissed'] is True | |
| us = UserSkill.objects.get(user=user, skill=py) | |
| assert us.dismissed_upgrade_at is not None | |
| assert compute_upgrade_suggestions(user) == [] | |
| def test_dismiss_creates_user_skill_if_absent(self, user): | |
| py = _skill('Python') | |
| dismiss_upgrade(user, py.id) | |
| us = UserSkill.objects.get(user=user, skill=py) | |
| assert us.proficiency == 0 | |
| assert us.dismissed_upgrade_at is not None | |
| class TestEndpoints: | |
| def test_list_endpoint(self, user, auth_client): | |
| py = _skill('Python') | |
| r = _resource() | |
| SkillResource.objects.create(skill=py, resource=r) | |
| _complete(user, r) | |
| r_resp = auth_client.get(UPGRADE_LIST) | |
| assert r_resp.status_code == 200 | |
| assert r_resp.data['suggestions'][0]['skill_id'] == py.id | |
| def test_apply_endpoint(self, user, auth_client): | |
| py = _skill('Python') | |
| r = _resource() | |
| SkillResource.objects.create(skill=py, resource=r) | |
| _complete(user, r) | |
| r_resp = auth_client.post(f'{UPGRADE_LIST}{py.id}/apply/') | |
| assert r_resp.status_code == 200 | |
| assert r_resp.data['applied'] is True | |
| assert UserSkill.objects.get(user=user, skill=py).proficiency == 60 | |
| def test_dismiss_endpoint(self, user, auth_client): | |
| py = _skill('Python') | |
| r = _resource() | |
| SkillResource.objects.create(skill=py, resource=r) | |
| _complete(user, r) | |
| r_resp = auth_client.post(f'{UPGRADE_LIST}{py.id}/dismiss/') | |
| assert r_resp.status_code == 200 | |
| assert r_resp.data['dismissed'] is True | |
| # List should now be empty. | |
| r_resp = auth_client.get(UPGRADE_LIST) | |
| assert r_resp.data['suggestions'] == [] | |
| def test_apply_unknown_skill_404(self, auth_client): | |
| r_resp = auth_client.post(f'{UPGRADE_LIST}99999/apply/') | |
| assert r_resp.status_code == 404 | |
| def test_requires_auth(self): | |
| c = APIClient() | |
| assert c.get(UPGRADE_LIST).status_code == 401 | |