diff --git a/planner/tests.py b/planner/tests.py index bb65930..a639800 100644 --- a/planner/tests.py +++ b/planner/tests.py @@ -1853,6 +1853,119 @@ def test_plan_complete_redirects_when_no_plan_yet(self): response, reverse('planner:feasibility', kwargs={'period_id': self.exam_period.id}) ) +class FeasibilitySubjectResultsTests(TestCase): + """ + #90 feasibility() subject_results context 테스트. + """ + + def setUp(self): + from django.contrib.auth import get_user_model + from exams.models import Exam, ExamPeriod, StudyTask + + User = get_user_model() + self.user = User.objects.create_user( + username="subject_results_tester", + email="subject_results@example.com", + password="pass1234", + ) + self.other_user = User.objects.create_user( + username="subject_results_other", + email="subject_results_other@example.com", + password="pass1234", + ) + self.today = django_timezone.localdate() + self.exam_period = ExamPeriod.objects.create( + user=self.user, + title="과목별 데이터 테스트 시험기간", + start_date=self.today, + end_date=self.today + timedelta(days=10), + status="active", + ) + self.client.login(username="subject_results@example.com", password="pass1234") + + def _get(self): + return self.client.get( + reverse("planner:feasibility", kwargs={"period_id": self.exam_period.id}) + ) + + def _subject_results_by_name(self, response): + return {s["subject_name"]: s for s in response.context["subject_results"]} + + # ── 1. 확정된 작업만 집계되는지 (미확정 제외) ── + def test_only_confirmed_tasks_are_counted(self): + from exams.models import Exam, StudyTask + + exam = Exam.objects.create( + exam_period=self.exam_period, subject_name="데이터통신", + exam_date=self.today + timedelta(days=5), + ) + StudyTask.objects.create( + exam=exam, title="확정 작업", importance="high", depth="core", + task_type="concept", difficulty="normal", order=1, + estimated_min_minutes=20, estimated_max_minutes=40, is_confirmed=True, + ) + StudyTask.objects.create( + exam=exam, title="미확정 작업", importance="high", depth="core", + task_type="concept", difficulty="normal", order=2, + estimated_min_minutes=30, estimated_max_minutes=50, is_confirmed=False, + ) + + response = self._get() + results = self._subject_results_by_name(response) + + self.assertEqual(results["데이터통신"]["task_count"], 1) + self.assertEqual(results["데이터통신"]["required_min_minutes"], 20) + self.assertEqual(results["데이터통신"]["required_recommended_minutes"], 40) + + # ── 2. 작업이 아예 없는 과목도 0/0/0으로 나오는지 ── + def test_subject_with_no_tasks_shows_zero(self): + from exams.models import Exam + + Exam.objects.create( + exam_period=self.exam_period, subject_name="운영체제", + exam_date=self.today + timedelta(days=8), + ) + + response = self._get() + results = self._subject_results_by_name(response) + + self.assertEqual(results["운영체제"]["task_count"], 0) + self.assertEqual(results["운영체제"]["required_min_minutes"], 0) + self.assertEqual(results["운영체제"]["required_recommended_minutes"], 0) + + # ── 3. exam_date 순으로 정렬되는지 ── + def test_sorted_by_exam_date(self): + from exams.models import Exam + + Exam.objects.create( + exam_period=self.exam_period, subject_name="나중 시험", + exam_date=self.today + timedelta(days=9), + ) + Exam.objects.create( + exam_period=self.exam_period, subject_name="먼저 시험", + exam_date=self.today + timedelta(days=2), + ) + + response = self._get() + names = [s["subject_name"] for s in response.context["subject_results"]] + + self.assertEqual(names, ["먼저 시험", "나중 시험"]) + + # ── 4. 다른 사용자는 접근 자체가 404 (subject_results 노출 안 됨) ── + def test_other_user_cannot_access(self): + from exams.models import Exam + + Exam.objects.create( + exam_period=self.exam_period, subject_name="비공개 과목", + exam_date=self.today + timedelta(days=5), + ) + self.client.logout() + self.client.login(username="subject_results_other@example.com", password="pass1234") + + response = self._get() + + self.assertEqual(response.status_code, 404) + class DashboardViewTests(TestCase): def setUp(self): from django.contrib.auth import get_user_model diff --git a/planner/views.py b/planner/views.py index 7f88d0e..ba6347f 100644 --- a/planner/views.py +++ b/planner/views.py @@ -79,6 +79,30 @@ def _calculate_feasibility_for_period(exam_period): result = calculate_feasibility(required_min, required_max, available) return result, tasks +def _build_subject_results(exam_period, tasks): + """ + 과목별 카드 표시용 데이터. 가용시간은 시험기간 전체가 공유하는 구조라 + 과목별 possible/risky/impossible 판정은 여기서 만들지 않는다. + """ + subject_results = [] + + for exam in exam_period.exams.all().order_by('exam_date'): + subject_tasks = [task for task in tasks if task.exam_id == exam.id] + + subject_results.append({ + 'exam_id': exam.id, + 'subject_name': exam.subject_name, + 'exam_date': exam.exam_date, + 'task_count': len(subject_tasks), + 'required_min_minutes': sum( + task.estimated_min_minutes for task in subject_tasks + ), + 'required_recommended_minutes': sum( + task.estimated_max_minutes for task in subject_tasks + ), + }) + + return subject_results def _validate_task_readiness(exam_period): """ @@ -115,9 +139,12 @@ def feasibility(request, period_id): is_ready, readiness_error = _validate_task_readiness(exam_period) result, tasks = _calculate_feasibility_for_period(exam_period) + subject_results = _build_subject_results(exam_period, tasks) + context = { 'exam_period': exam_period, 'result': result, + 'subject_results': subject_results, 'total_min_minutes': result['required_min_minutes'], 'total_max_minutes': result['required_recommended_minutes'], 'total_available_minutes': result['available_minutes'],