From 1a7d418a97b6f2f6e6451544c1630b24e23ef1b4 Mon Sep 17 00:00:00 2001 From: aadalal <57609353+AaDalal@users.noreply.github.com> Date: Wed, 3 Jan 2024 01:22:24 -0500 Subject: [PATCH] fulfillment tests --- backend/degree/README.md | 9 + backend/degree/exceptions.py | 2 +- backend/tests/degree/test_models.py | 286 +++++++++++++++++++++++++++- 3 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 backend/degree/README.md diff --git a/backend/degree/README.md b/backend/degree/README.md new file mode 100644 index 000000000..6c1261b7a --- /dev/null +++ b/backend/degree/README.md @@ -0,0 +1,9 @@ +# Penn Degree Plan + +A four year degree planner for Penn students. + +## Edge cases +- 0-credit courses +- double counts +- overrides +- chameleon courses \ No newline at end of file diff --git a/backend/degree/exceptions.py b/backend/degree/exceptions.py index 5093f91f0..db09e3f54 100644 --- a/backend/degree/exceptions.py +++ b/backend/degree/exceptions.py @@ -16,7 +16,7 @@ class DoubleCountException(exceptions.APIException): default_code = "double_count_violation" -class RuleFulfillmentException(exceptions.APIException): +class RuleViolationException(exceptions.APIException): status_code = status.HTTP_400_BAD_REQUEST # Detail should contain a list of violated Rule ids default_detail = "Rule not fulfilled." diff --git a/backend/tests/degree/test_models.py b/backend/tests/degree/test_models.py index a3d949dc0..cfedc052c 100644 --- a/backend/tests/degree/test_models.py +++ b/backend/tests/degree/test_models.py @@ -1,15 +1,18 @@ +from rest_framework import serializers from django.db.models import Q from django.test import TestCase from lark.exceptions import LarkError +from courses.models import User from courses.util import get_or_create_course_and_section -from degree.models import Degree, Rule +from degree.models import Degree, DegreePlan, DoubleCountRestriction, Rule, Fulfillment from degree.utils.model_utils import q_object_parser +from degree.exceptions import DoubleCountException, RuleViolationException +from django.db import IntegrityError TEST_SEMESTER = "2023C" - class QObjectParserTest(TestCase): def assertParsedEqual(self, q: Q): self.assertEqual(q_object_parser.parse(repr(q)), q) @@ -100,31 +103,31 @@ def setUp(self): "CIS-1910-001", TEST_SEMESTER, course_defaults={"credits": 0.5} ) - self.degree_plan = Degree.objects.create( + self.degree = Degree.objects.create( program="EU_BSE", degree="BSE", major="CIS", year=2023 ) self.parent_rule = Rule.objects.create(degree_plan=self.degree_plan) self.rule1 = Rule.objects.create( - degree_plan=self.degree_plan, + degree=self.degree, parent=self.parent_rule, q=repr(Q(full_code="CIS-1200")), num_courses=1, ) self.rule2 = Rule.objects.create( # Self-contradictory rule - degree_plan=self.degree_plan, + degree=self.degree_plan, parent=None, # For now... q=repr(Q(full_code__startswith="CIS-12", full_code__endswith="1600")), credits=1, ) self.rule3 = Rule.objects.create( # .5 cus / 1 course CIS-19XX classes - degree_plan=self.degree_plan, + degree=self.degree_plan, parent=self.parent_rule, q=repr(Q(full_code__startswith="CIS-19")), credits=0.5, num_courses=1, ) self.rule4 = Rule.objects.create( # 2 CIS classes - degree_plan=self.degree_plan, + degree=self.degree_plan, parent=None, q=repr(Q(full_code__startswith="CIS")), num_courses=2, @@ -179,3 +182,272 @@ def test_parent_rule_satisfied(self): self.assertTrue( self.parent_rule.evaluate([self.cis_1200.full_code, self.cis_1910.full_code]) ) + +class FulfillmentTest(TestCase): + """ + Test cases for the Fulfillment model. + + Most tests are related to the overriden `.save()` method, which relies on + logic from the DoubleCountRestriction and Rule models; that logic is tested + in the test cases for those respective rules. + """ + + def setUp(self): + self.user = User.objects.create_user( + username="test", password="top_secret", email="test@example.com" + ) + self.cis_1200, self.cis_1200_001, _, _ = get_or_create_course_and_section( + "CIS-1200-001", TEST_SEMESTER, course_defaults={"credits": 1} + ) + self.cis_1910, self.cis_1910_001, _, _ = get_or_create_course_and_section( + "CIS-1910-001", TEST_SEMESTER, course_defaults={"credits": 0.5} + ) + self.cis_1930, self.cis_1930_001, _, _ = get_or_create_course_and_section( + "CIS-1920-001", TEST_SEMESTER, course_defaults={"credits": 1} + ) + + self.degree = Degree.objects.create( + program="EU_BSE", degree="BSE", major="CIS", year=2023 + ) + self.parent_rule = Rule.objects.create(degree=self.degree) + self.rule1 = Rule.objects.create( + degree=self.degree, + parent=self.parent_rule, + q=repr(Q(full_code="CIS-1200")), + num_courses=1, + ) + self.rule2 = Rule.objects.create( # .5 cus / 1 course CIS-19XX classes + degree=self.degree, + parent=self.parent_rule, + q=repr(Q(full_code__startswith="CIS-19")), + credits=0.5, + num_courses=1, + ) + self.rule3 = Rule.objects.create( # 2 CIS classes + degree=self.degree, + parent=None, + q=repr(Q(full_code__startswith="CIS")), + num_courses=2, + ) + + self.double_count_restriction = DoubleCountRestriction.objects.create( + degree=self.degree, + rule=self.rule2, # CIS-19XX + rule=self.rule3, # CIS-XXXX + max_credits=1, + ) + self.degree_plan = DegreePlan.objects.create( + name="Good Degree Plan 😇" + person=self.user, + degree=self.degree, + ) + self.other_degree = Degree.objects.create( + program="EU_BSE", degree="BSE", major="CMPE", year=2023 + ) + self.bad_degree_plan = DegreePlan.objects.create( + name="Bad Degree Plan 😈" + person=self.user, + degree=self.other_degree, # empty degree + ) + + # TODO: this test feels mildly useless... + def test_creation(self): + fulfillment = Fulfillment( + degree_plan=self.degree_plan, + full_code=self.cis_1200.full_code, + semester=TEST_SEMESTER, + rules=[self.rule1, self.rule3] + ) + fulfillment.save() + + def test_creation_without_semester(self): + fulfillment = Fulfillment( + degree_plan=self.degree_plan, + full_code=self.cis_1200.full_code, + rules=[self.rule1] + ) + fulfillment.save() + + def test_duplicate_full_code(self): + fulfillment = Fulfillment( + degree_plan=self.degree_plan, + full_code=self.cis_1200.full_code, + semester=TEST_SEMESTER, + rules=[self.rule1] + ) + fulfillment.save() + + other_fulfillment = Fulfillment( + degree_plan=self.degree_plan, + full_code=self.cis_1200.full_code, + semester=TEST_SEMESTER, + rules=[self.rule3] + ) + + try: + other_fulfillment.save() + self.fail("No exception raised.") + except IntegrityError: + pass + + def test_fulfill_rule_of_wrong_degree(self): + fulfillment = Fulfillment( + degree_plan=self.bad_degree_plan, # has no rules + full_code=self.cis_1200.full_code, + semester=TEST_SEMESTER, + rules=[self.rule1] + ) + try: + fulfillment.save() + self.fail("No exception raised.") + except serializers.ValidationError: + pass # OK + + + def test_fulfill_nested_rule_of_wrong_degree(self): + fulfillment = Fulfillment( + degree_plan=self.bad_degree_plan, # has no rules + full_code=self.cis_1910_001.full_code, + semester=TEST_SEMESTER, + rules=[self.parent_rule] + ) + try: + fulfillment.save() + self.fail("No exception raised.") + except serializers.ValidationError: + pass # OK + + def test_double_count_violation(self): + fulfillment = Fulfillment( + degree_plan=self.degree_plan, + full_code=self.cis_1910_001.full_code, + semester=TEST_SEMESTER, + rules=[self.rule2, self.rule3] + ) + fulfillment.save() + + other_fulfillment = Fulfillment( + degree_plan=self.degree_plan, + full_code=self.cis_1920_001.full_code, + semester=TEST_SEMESTER, + rules=[self.rule2, self.rule3] + ) + try: + other_fulfillment.save() + self.fail("No exception raised.") + except DoubleCountException as e: + self.assertEquals(e.detail, [self.double_count_restriction.id]) + pass # OK + + def test_multiple_double_count_violations(self): + self.fail("unimplemented") + + def test_rule_violation_on_credits(self): # rule_violation + fulfillment = Fulfillment( + degree_plan=self.degree_plan, + full_code=self.cis_1930.full_code, # 1 CU course + semester=TEST_SEMESTER, + rules=[self.rule2] # 0.5 CU rule + ) + try: + fulfillment.save() + self.fail("No exception raised.") + except RuleViolationException as e: + self.assertEquals(e.detail, [self.rule2.id]) + pass + + def test_rule_violation_on_courses(self): # rule_violation + fulfillment = Fulfillment( + degree_plan=self.degree_plan, + full_code=self.cis_1930.full_code, + semester=TEST_SEMESTER, + rules=[self.rule3] + ) + fulfillment.save() + + other_fulfillment = Fulfillment( + degree_plan=self.degree_plan, + full_code=self.cis_1200.full_code, + semester=TEST_SEMESTER, + rules=[self.rule3] + ) + try: + other_fulfillment.save() + self.fail("No exception raised.") + except RuleViolationException as e: + self.assertEquals(e.detail, [self.rule3.id]) + pass + + def test_multiple_rule_violations(self): # rule_violation + fulfillment = Fulfillment( + degree_plan=self.degree_plan, + full_code=self.cis_1930.full_code, + semester=TEST_SEMESTER, + rules=[self.rule3, self.rule2] + ) + fulfillment.save() + + other_fulfillment = Fulfillment( + degree_plan=self.degree_plan, + full_code=self.cis_1200.full_code, + semester=TEST_SEMESTER, + rules=[self.rule3] + ) + try: + other_fulfillment.save() + self.fail("No exception raised.") + except RuleViolationException as e: + self.assertEquals(e.detail, [self.rule3.id, self.rule2.id]) + pass + + def test_fulfill_non_leaf_rule(self): + fulfillment = Fulfillment( + degree_plan=self.degree_plan, + full_code=self.cis_1910.full_code, + semester=TEST_SEMESTER, + rules=[self.parent_rule] + ) + + fulfillment.save() + # TODO: figure out what the right exception is here and put in try-except + self.fail("No exception raised.") + + def test_update_fulfilled_rules_causes_rule_violation(self): + fulfillment = Fulfillment( + degree_plan=self.degree_plan, + full_code=self.cis_1200.full_code, + semester=TEST_SEMESTER, + rules=[self.rule1] + ) + fulfillment.save() + try: + fulfillment.rules.add(self.rule2) # CIS-1900! + # TODO: do we need an explicit save here? + self.fail("No exception raised.") + except RuleViolationException: + pass # OK + + def test_status_updates(self): + self.fail("unimplemented") # TODO: should this be tested here? + + def test_fulfill_with_old_code(self): + # TODO: is this the responsibility of the Rule or the Fulfillment? + self.fail("unimplemented") + + def test_fulfill_with_nonexistent_code(self): + # TODO: does nonexistent code mean the fullfillment is wrong? or that the courses we have in our DB are incomplete? + self.fail("unimplemented") + + +class DoubleCountRestrictionTest(TestCase): + def setUp(self): + pass + + def test_nested_rules(self): + pass + + def test_credits_violation(self): + pass + + def test_num_courses_violation(self): + pass \ No newline at end of file