diff --git a/lms/djangoapps/grades/tests/base.py b/lms/djangoapps/grades/tests/base.py
new file mode 100644
index 0000000000..c2a71be0ec
--- /dev/null
+++ b/lms/djangoapps/grades/tests/base.py
@@ -0,0 +1,100 @@
+from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
+from lms.djangoapps.course_blocks.api import get_course_blocks
+from openedx.core.djangolib.testing.utils import get_mock_request
+from student.models import CourseEnrollment
+from student.tests.factories import UserFactory
+from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
+
+from ..subsection_grade_factory import SubsectionGradeFactory
+
+
+class GradeTestBase(SharedModuleStoreTestCase):
+ """
+ Base class for some Grades tests.
+ """
+ @classmethod
+ def setUpClass(cls):
+ super(GradeTestBase, cls).setUpClass()
+ cls.course = CourseFactory.create()
+ with cls.store.bulk_operations(cls.course.id):
+ cls.chapter = ItemFactory.create(
+ parent=cls.course,
+ category="chapter",
+ display_name="Test Chapter"
+ )
+ cls.sequence = ItemFactory.create(
+ parent=cls.chapter,
+ category='sequential',
+ display_name="Test Sequential 1",
+ graded=True,
+ format="Homework"
+ )
+ cls.vertical = ItemFactory.create(
+ parent=cls.sequence,
+ category='vertical',
+ display_name='Test Vertical 1'
+ )
+ problem_xml = MultipleChoiceResponseXMLFactory().build_xml(
+ question_text='The correct answer is Choice 3',
+ choices=[False, False, True, False],
+ choice_names=['choice_0', 'choice_1', 'choice_2', 'choice_3']
+ )
+ cls.problem = ItemFactory.create(
+ parent=cls.vertical,
+ category="problem",
+ display_name="Test Problem",
+ data=problem_xml
+ )
+ cls.sequence2 = ItemFactory.create(
+ parent=cls.chapter,
+ category='sequential',
+ display_name="Test Sequential 2",
+ graded=True,
+ format="Homework"
+ )
+ cls.problem2 = ItemFactory.create(
+ parent=cls.sequence2,
+ category="problem",
+ display_name="Test Problem",
+ data=problem_xml
+ )
+ # AED 2017-06-19: make cls.sequence belong to multiple parents,
+ # so we can test that DAGs with this shape are handled correctly.
+ cls.chapter_2 = ItemFactory.create(
+ parent=cls.course,
+ category='chapter',
+ display_name='Test Chapter 2'
+ )
+ cls.chapter_2.children.append(cls.sequence.location)
+ cls.store.update_item(cls.chapter_2, UserFactory().id)
+
+ def setUp(self):
+ super(GradeTestBase, self).setUp()
+ self.request = get_mock_request(UserFactory())
+ self.client.login(username=self.request.user.username, password="test")
+ self._set_grading_policy()
+ self.course_structure = get_course_blocks(self.request.user, self.course.location)
+ self.subsection_grade_factory = SubsectionGradeFactory(self.request.user, self.course, self.course_structure)
+ CourseEnrollment.enroll(self.request.user, self.course.id)
+
+ def _set_grading_policy(self, passing=0.5):
+ """
+ Updates the course's grading policy.
+ """
+ self.grading_policy = {
+ "GRADER": [
+ {
+ "type": "Homework",
+ "min_count": 1,
+ "drop_count": 0,
+ "short_label": "HW",
+ "weight": 1.0,
+ },
+ ],
+ "GRADE_CUTOFFS": {
+ "Pass": passing,
+ },
+ }
+ self.course.set_grading_policy(self.grading_policy)
+ self.store.update_item(self.course, 0)
diff --git a/lms/djangoapps/grades/tests/test_course_grade.py b/lms/djangoapps/grades/tests/test_course_grade.py
new file mode 100644
index 0000000000..54dd63bb70
--- /dev/null
+++ b/lms/djangoapps/grades/tests/test_course_grade.py
@@ -0,0 +1,145 @@
+import ddt
+from django.conf import settings
+from mock import patch
+
+from openedx.core.djangolib.testing.utils import get_mock_request
+from student.models import CourseEnrollment
+from student.tests.factories import UserFactory
+from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
+
+from ..config.waffle import ASSUME_ZERO_GRADE_IF_ABSENT, waffle
+from ..course_data import CourseData
+from ..course_grade import ZeroCourseGrade
+from ..course_grade_factory import CourseGradeFactory
+from .base import GradeTestBase
+from .utils import answer_problem
+
+
+@patch.dict(settings.FEATURES, {'ASSUME_ZERO_GRADE_IF_ABSENT_FOR_ALL_TESTS': False})
+@ddt.ddt
+class ZeroGradeTest(GradeTestBase):
+ """
+ Tests ZeroCourseGrade (and, implicitly, ZeroSubsectionGrade)
+ functionality.
+ """
+ @ddt.data(True, False)
+ def test_zero(self, assume_zero_enabled):
+ """
+ Creates a ZeroCourseGrade and ensures it's empty.
+ """
+ with waffle().override(ASSUME_ZERO_GRADE_IF_ABSENT, active=assume_zero_enabled):
+ course_data = CourseData(self.request.user, structure=self.course_structure)
+ chapter_grades = ZeroCourseGrade(self.request.user, course_data).chapter_grades
+ for chapter in chapter_grades:
+ for section in chapter_grades[chapter]['sections']:
+ for score in section.problem_scores.itervalues():
+ self.assertEqual(score.earned, 0)
+ self.assertEqual(score.first_attempted, None)
+ self.assertEqual(section.all_total.earned, 0)
+
+ @ddt.data(True, False)
+ def test_zero_null_scores(self, assume_zero_enabled):
+ """
+ Creates a zero course grade and ensures that null scores aren't included in the section problem scores.
+ """
+ with waffle().override(ASSUME_ZERO_GRADE_IF_ABSENT, active=assume_zero_enabled):
+ with patch('lms.djangoapps.grades.subsection_grade.get_score', return_value=None):
+ course_data = CourseData(self.request.user, structure=self.course_structure)
+ chapter_grades = ZeroCourseGrade(self.request.user, course_data).chapter_grades
+ for chapter in chapter_grades:
+ self.assertNotEqual({}, chapter_grades[chapter]['sections'])
+ for section in chapter_grades[chapter]['sections']:
+ self.assertEqual({}, section.problem_scores)
+
+
+class TestScoreForModule(SharedModuleStoreTestCase):
+ """
+ Test the method that calculates the score for a given block based on the
+ cumulative scores of its children. This test class uses a hard-coded block
+ hierarchy with scores as follows:
+ a
+ +--------+--------+
+ b c
+ +--------------+-----------+ |
+ d e f g
+ +-----+ +-----+-----+ | |
+ h i j k l m n
+ (2/5) (3/5) (0/1) - (1/3) - (3/10)
+
+ """
+ @classmethod
+ def setUpClass(cls):
+ super(TestScoreForModule, cls).setUpClass()
+ cls.course = CourseFactory.create()
+ with cls.store.bulk_operations(cls.course.id):
+ cls.a = ItemFactory.create(parent=cls.course, category="chapter", display_name="a")
+ cls.b = ItemFactory.create(parent=cls.a, category="sequential", display_name="b")
+ cls.c = ItemFactory.create(parent=cls.a, category="sequential", display_name="c")
+ cls.d = ItemFactory.create(parent=cls.b, category="vertical", display_name="d")
+ cls.e = ItemFactory.create(parent=cls.b, category="vertical", display_name="e")
+ cls.f = ItemFactory.create(parent=cls.b, category="vertical", display_name="f")
+ cls.g = ItemFactory.create(parent=cls.c, category="vertical", display_name="g")
+ cls.h = ItemFactory.create(parent=cls.d, category="problem", display_name="h")
+ cls.i = ItemFactory.create(parent=cls.d, category="problem", display_name="i")
+ cls.j = ItemFactory.create(parent=cls.e, category="problem", display_name="j")
+ cls.k = ItemFactory.create(parent=cls.e, category="html", display_name="k")
+ cls.l = ItemFactory.create(parent=cls.e, category="problem", display_name="l")
+ cls.m = ItemFactory.create(parent=cls.f, category="html", display_name="m")
+ cls.n = ItemFactory.create(parent=cls.g, category="problem", display_name="n")
+
+ cls.request = get_mock_request(UserFactory())
+ CourseEnrollment.enroll(cls.request.user, cls.course.id)
+
+ answer_problem(cls.course, cls.request, cls.h, score=2, max_value=5)
+ answer_problem(cls.course, cls.request, cls.i, score=3, max_value=5)
+ answer_problem(cls.course, cls.request, cls.j, score=0, max_value=1)
+ answer_problem(cls.course, cls.request, cls.l, score=1, max_value=3)
+ answer_problem(cls.course, cls.request, cls.n, score=3, max_value=10)
+
+ cls.course_grade = CourseGradeFactory().read(cls.request.user, cls.course)
+
+ def test_score_chapter(self):
+ earned, possible = self.course_grade.score_for_module(self.a.location)
+ self.assertEqual(earned, 9)
+ self.assertEqual(possible, 24)
+
+ def test_score_section_many_leaves(self):
+ earned, possible = self.course_grade.score_for_module(self.b.location)
+ self.assertEqual(earned, 6)
+ self.assertEqual(possible, 14)
+
+ def test_score_section_one_leaf(self):
+ earned, possible = self.course_grade.score_for_module(self.c.location)
+ self.assertEqual(earned, 3)
+ self.assertEqual(possible, 10)
+
+ def test_score_vertical_two_leaves(self):
+ earned, possible = self.course_grade.score_for_module(self.d.location)
+ self.assertEqual(earned, 5)
+ self.assertEqual(possible, 10)
+
+ def test_score_vertical_two_leaves_one_unscored(self):
+ earned, possible = self.course_grade.score_for_module(self.e.location)
+ self.assertEqual(earned, 1)
+ self.assertEqual(possible, 4)
+
+ def test_score_vertical_no_score(self):
+ earned, possible = self.course_grade.score_for_module(self.f.location)
+ self.assertEqual(earned, 0)
+ self.assertEqual(possible, 0)
+
+ def test_score_vertical_one_leaf(self):
+ earned, possible = self.course_grade.score_for_module(self.g.location)
+ self.assertEqual(earned, 3)
+ self.assertEqual(possible, 10)
+
+ def test_score_leaf(self):
+ earned, possible = self.course_grade.score_for_module(self.h.location)
+ self.assertEqual(earned, 2)
+ self.assertEqual(possible, 5)
+
+ def test_score_leaf_no_score(self):
+ earned, possible = self.course_grade.score_for_module(self.m.location)
+ self.assertEqual(earned, 0)
+ self.assertEqual(possible, 0)
diff --git a/lms/djangoapps/grades/tests/test_course_grade_factory.py b/lms/djangoapps/grades/tests/test_course_grade_factory.py
new file mode 100644
index 0000000000..e8ad68adb7
--- /dev/null
+++ b/lms/djangoapps/grades/tests/test_course_grade_factory.py
@@ -0,0 +1,362 @@
+import itertools
+from nose.plugins.attrib import attr
+
+import ddt
+from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
+from courseware.access import has_access
+from courseware.tests.test_submitting_problems import ProblemSubmissionTestMixin
+from django.conf import settings
+from lms.djangoapps.course_blocks.api import get_course_blocks
+from lms.djangoapps.grades.config.tests.utils import persistent_grades_feature_flags
+from mock import patch
+from openedx.core.djangolib.testing.utils import get_mock_request
+from openedx.core.djangoapps.content.block_structure.factory import BlockStructureFactory
+from student.models import CourseEnrollment
+from student.tests.factories import UserFactory
+from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
+
+from ..config.waffle import ASSUME_ZERO_GRADE_IF_ABSENT, waffle
+from ..course_grade import CourseGrade, ZeroCourseGrade
+from ..course_grade_factory import CourseGradeFactory
+from ..subsection_grade import SubsectionGrade, ZeroSubsectionGrade
+from ..subsection_grade_factory import SubsectionGradeFactory
+from .base import GradeTestBase
+from .utils import mock_get_score
+
+
+@ddt.ddt
+class TestCourseGradeFactory(GradeTestBase):
+ """
+ Test that CourseGrades are calculated properly
+ """
+ def _assert_zero_grade(self, course_grade, expected_grade_class):
+ """
+ Asserts whether the given course_grade is as expected with
+ zero values.
+ """
+ self.assertIsInstance(course_grade, expected_grade_class)
+ self.assertIsNone(course_grade.letter_grade)
+ self.assertEqual(course_grade.percent, 0.0)
+ self.assertIsNotNone(course_grade.chapter_grades)
+
+ def test_course_grade_no_access(self):
+ """
+ Test to ensure a grade can ba calculated for a student in a course, even if they themselves do not have access.
+ """
+ invisible_course = CourseFactory.create(visible_to_staff_only=True)
+ access = has_access(self.request.user, 'load', invisible_course)
+ self.assertEqual(access.has_access, False)
+ self.assertEqual(access.error_code, 'not_visible_to_user')
+
+ # with self.assertNoExceptionRaised: <- this isn't a real method, it's an implicit assumption
+ grade = CourseGradeFactory().read(self.request.user, invisible_course)
+ self.assertEqual(grade.percent, 0)
+
+ @patch.dict(settings.FEATURES, {'PERSISTENT_GRADES_ENABLED_FOR_ALL_TESTS': False})
+ @ddt.data(
+ (True, True),
+ (True, False),
+ (False, True),
+ (False, False),
+ )
+ @ddt.unpack
+ def test_course_grade_feature_gating(self, feature_flag, course_setting):
+ # Grades are only saved if the feature flag and the advanced setting are
+ # both set to True.
+ grade_factory = CourseGradeFactory()
+ with persistent_grades_feature_flags(
+ global_flag=feature_flag,
+ enabled_for_all_courses=False,
+ course_id=self.course.id,
+ enabled_for_course=course_setting
+ ):
+ with patch('lms.djangoapps.grades.models.PersistentCourseGrade.read') as mock_read_grade:
+ grade_factory.read(self.request.user, self.course)
+ self.assertEqual(mock_read_grade.called, feature_flag and course_setting)
+
+ def test_read(self):
+ grade_factory = CourseGradeFactory()
+
+ def _assert_read(expected_pass, expected_percent):
+ """
+ Creates the grade, ensuring it is as expected.
+ """
+ course_grade = grade_factory.read(self.request.user, self.course)
+ self.assertEqual(course_grade.letter_grade, u'Pass' if expected_pass else None)
+ self.assertEqual(course_grade.percent, expected_percent)
+
+ with waffle().override(ASSUME_ZERO_GRADE_IF_ABSENT):
+ with self.assertNumQueries(1), mock_get_score(1, 2):
+ _assert_read(expected_pass=False, expected_percent=0)
+
+ with self.assertNumQueries(10), mock_get_score(1, 2):
+ grade_factory.update(self.request.user, self.course)
+
+ with self.assertNumQueries(1):
+ _assert_read(expected_pass=True, expected_percent=0.5)
+
+ @patch.dict(settings.FEATURES, {'ASSUME_ZERO_GRADE_IF_ABSENT_FOR_ALL_TESTS': False})
+ @ddt.data(*itertools.product((True, False), (True, False)))
+ @ddt.unpack
+ def test_read_zero(self, assume_zero_enabled, create_if_needed):
+ with waffle().override(ASSUME_ZERO_GRADE_IF_ABSENT, active=assume_zero_enabled):
+ grade_factory = CourseGradeFactory()
+ course_grade = grade_factory.read(self.request.user, self.course, create_if_needed=create_if_needed)
+ if create_if_needed or assume_zero_enabled:
+ self._assert_zero_grade(course_grade, ZeroCourseGrade if assume_zero_enabled else CourseGrade)
+ else:
+ self.assertIsNone(course_grade)
+
+ def test_create_zero_subs_grade_for_nonzero_course_grade(self):
+ with waffle().override(ASSUME_ZERO_GRADE_IF_ABSENT):
+ subsection = self.course_structure[self.sequence.location]
+ with mock_get_score(1, 2):
+ self.subsection_grade_factory.update(subsection)
+ course_grade = CourseGradeFactory().update(self.request.user, self.course)
+ subsection1_grade = course_grade.subsection_grades[self.sequence.location]
+ subsection2_grade = course_grade.subsection_grades[self.sequence2.location]
+ self.assertIsInstance(subsection1_grade, SubsectionGrade)
+ self.assertIsInstance(subsection2_grade, ZeroSubsectionGrade)
+
+ @ddt.data(True, False)
+ def test_iter_force_update(self, force_update):
+ with patch('lms.djangoapps.grades.subsection_grade_factory.SubsectionGradeFactory.update') as mock_update:
+ set(CourseGradeFactory().iter(
+ users = [self.request.user], course = self.course, force_update = force_update
+ ))
+ self.assertEqual(mock_update.called, force_update)
+
+ def test_course_grade_summary(self):
+ with mock_get_score(1, 2):
+ self.subsection_grade_factory.update(self.course_structure[self.sequence.location])
+ course_grade = CourseGradeFactory().update(self.request.user, self.course)
+
+ actual_summary = course_grade.summary
+
+ # We should have had a zero subsection grade for sequential 2, since we never
+ # gave it a mock score above.
+ expected_summary = {
+ 'grade': None,
+ 'grade_breakdown': {
+ 'Homework': {
+ 'category': 'Homework',
+ 'percent': 0.25,
+ 'detail': 'Homework = 25.00% of a possible 100.00%',
+ }
+ },
+ 'percent': 0.25,
+ 'section_breakdown': [
+ {
+ 'category': 'Homework',
+ 'detail': u'Homework 1 - Test Sequential 1 - 50% (1/2)',
+ 'label': u'HW 01',
+ 'percent': 0.5
+ },
+ {
+ 'category': 'Homework',
+ 'detail': u'Homework 2 - Test Sequential 2 - 0% (0/1)',
+ 'label': u'HW 02',
+ 'percent': 0.0
+ },
+ {
+ 'category': 'Homework',
+ 'detail': u'Homework Average = 25%',
+ 'label': u'HW Avg',
+ 'percent': 0.25,
+ 'prominent': True
+ },
+ ]
+ }
+ self.assertEqual(expected_summary, actual_summary)
+
+
+@attr(shard=1)
+class TestGradeIteration(SharedModuleStoreTestCase):
+ """
+ Test iteration through student course grades.
+ """
+ COURSE_NUM = "1000"
+ COURSE_NAME = "grading_test_course"
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestGradeIteration, cls).setUpClass()
+ cls.course = CourseFactory.create(
+ display_name=cls.COURSE_NAME,
+ number=cls.COURSE_NUM
+ )
+
+ def setUp(self):
+ """
+ Create a course and a handful of users to assign grades
+ """
+ super(TestGradeIteration, self).setUp()
+
+ self.students = [
+ UserFactory.create(username='student1'),
+ UserFactory.create(username='student2'),
+ UserFactory.create(username='student3'),
+ UserFactory.create(username='student4'),
+ UserFactory.create(username='student5'),
+ ]
+
+ def test_empty_student_list(self):
+ """
+ If we don't pass in any students, it should return a zero-length
+ iterator, but it shouldn't error.
+ """
+ grade_results = list(CourseGradeFactory().iter([], self.course))
+ self.assertEqual(grade_results, [])
+
+ def test_all_empty_grades(self):
+ """
+ No students have grade entries.
+ """
+ with patch.object(
+ BlockStructureFactory,
+ 'create_from_store',
+ wraps=BlockStructureFactory.create_from_store
+ ) as mock_create_from_store:
+ all_course_grades, all_errors = self._course_grades_and_errors_for(self.course, self.students)
+ self.assertEquals(mock_create_from_store.call_count, 1)
+
+ self.assertEqual(len(all_errors), 0)
+ for course_grade in all_course_grades.values():
+ self.assertIsNone(course_grade.letter_grade)
+ self.assertEqual(course_grade.percent, 0.0)
+
+ @patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read')
+ def test_grading_exception(self, mock_course_grade):
+ """Test that we correctly capture exception messages that bubble up from
+ grading. Note that we only see errors at this level if the grading
+ process for this student fails entirely due to an unexpected event --
+ having errors in the problem sets will not trigger this.
+
+ We patch the grade() method with our own, which will generate the errors
+ for student3 and student4.
+ """
+
+ student1, student2, student3, student4, student5 = self.students
+ mock_course_grade.side_effect = [
+ Exception("Error for {}.".format(student.username))
+ if student.username in ['student3', 'student4']
+ else mock_course_grade.return_value
+ for student in self.students
+ ]
+ with self.assertNumQueries(4):
+ all_course_grades, all_errors = self._course_grades_and_errors_for(self.course, self.students)
+ self.assertEqual(
+ {student: all_errors[student].message for student in all_errors},
+ {
+ student3: "Error for student3.",
+ student4: "Error for student4.",
+ }
+ )
+
+ # But we should still have five gradesets
+ self.assertEqual(len(all_course_grades), 5)
+
+ # Even though two will simply be empty
+ self.assertIsNone(all_course_grades[student3])
+ self.assertIsNone(all_course_grades[student4])
+
+ # The rest will have grade information in them
+ self.assertIsNotNone(all_course_grades[student1])
+ self.assertIsNotNone(all_course_grades[student2])
+ self.assertIsNotNone(all_course_grades[student5])
+
+ def _course_grades_and_errors_for(self, course, students):
+ """
+ Simple helper method to iterate through student grades and give us
+ two dictionaries -- one that has all students and their respective
+ course grades, and one that has only students that could not be graded
+ and their respective error messages.
+ """
+ students_to_course_grades = {}
+ students_to_errors = {}
+
+ for student, course_grade, error in CourseGradeFactory().iter(students, course):
+ students_to_course_grades[student] = course_grade
+ if error:
+ students_to_errors[student] = error
+
+ return students_to_course_grades, students_to_errors
+
+
+class TestCourseGradeLogging(ProblemSubmissionTestMixin, SharedModuleStoreTestCase):
+ """
+ Tests logging in the course grades module.
+ Uses a larger course structure than other
+ unit tests.
+ """
+ def setUp(self):
+ super(TestCourseGradeLogging, self).setUp()
+ self.course = CourseFactory.create()
+ with self.store.bulk_operations(self.course.id):
+ self.chapter = ItemFactory.create(
+ parent=self.course,
+ category="chapter",
+ display_name="Test Chapter"
+ )
+ self.sequence = ItemFactory.create(
+ parent=self.chapter,
+ category='sequential',
+ display_name="Test Sequential 1",
+ graded=True
+ )
+ self.sequence_2 = ItemFactory.create(
+ parent=self.chapter,
+ category='sequential',
+ display_name="Test Sequential 2",
+ graded=True
+ )
+ self.sequence_3 = ItemFactory.create(
+ parent=self.chapter,
+ category='sequential',
+ display_name="Test Sequential 3",
+ graded=False
+ )
+ self.vertical = ItemFactory.create(
+ parent=self.sequence,
+ category='vertical',
+ display_name='Test Vertical 1'
+ )
+ self.vertical_2 = ItemFactory.create(
+ parent=self.sequence_2,
+ category='vertical',
+ display_name='Test Vertical 2'
+ )
+ self.vertical_3 = ItemFactory.create(
+ parent=self.sequence_3,
+ category='vertical',
+ display_name='Test Vertical 3'
+ )
+ problem_xml = MultipleChoiceResponseXMLFactory().build_xml(
+ question_text='The correct answer is Choice 2',
+ choices=[False, False, True, False],
+ choice_names=['choice_0', 'choice_1', 'choice_2', 'choice_3']
+ )
+ self.problem = ItemFactory.create(
+ parent=self.vertical,
+ category="problem",
+ display_name="test_problem_1",
+ data=problem_xml
+ )
+ self.problem_2 = ItemFactory.create(
+ parent=self.vertical_2,
+ category="problem",
+ display_name="test_problem_2",
+ data=problem_xml
+ )
+ self.problem_3 = ItemFactory.create(
+ parent=self.vertical_3,
+ category="problem",
+ display_name="test_problem_3",
+ data=problem_xml
+ )
+ self.request = get_mock_request(UserFactory())
+ self.client.login(username=self.request.user.username, password="test")
+ self.course_structure = get_course_blocks(self.request.user, self.course.location)
+ self.subsection_grade_factory = SubsectionGradeFactory(self.request.user, self.course, self.course_structure)
+ CourseEnrollment.enroll(self.request.user, self.course.id)
diff --git a/lms/djangoapps/grades/tests/test_grades.py b/lms/djangoapps/grades/tests/test_grades.py
deleted file mode 100644
index ca253031ea..0000000000
--- a/lms/djangoapps/grades/tests/test_grades.py
+++ /dev/null
@@ -1,335 +0,0 @@
-"""
-Test grade calculation.
-"""
-
-import datetime
-import itertools
-
-import ddt
-from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
-from lms.djangoapps.course_blocks.api import get_course_blocks
-from mock import patch
-from nose.plugins.attrib import attr
-from openedx.core.djangoapps.content.block_structure.factory import BlockStructureFactory
-from openedx.core.djangolib.testing.utils import get_mock_request
-from student.models import CourseEnrollment
-from student.tests.factories import UserFactory
-from xmodule.graders import ProblemScore
-from xmodule.modulestore import ModuleStoreEnum
-from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
-from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
-
-from ..course_grade_factory import CourseGradeFactory
-from ..subsection_grade_factory import SubsectionGradeFactory
-from .utils import answer_problem
-
-
-@attr(shard=1)
-class TestGradeIteration(SharedModuleStoreTestCase):
- """
- Test iteration through student course grades.
- """
- COURSE_NUM = "1000"
- COURSE_NAME = "grading_test_course"
-
- @classmethod
- def setUpClass(cls):
- super(TestGradeIteration, cls).setUpClass()
- cls.course = CourseFactory.create(
- display_name=cls.COURSE_NAME,
- number=cls.COURSE_NUM
- )
-
- def setUp(self):
- """
- Create a course and a handful of users to assign grades
- """
- super(TestGradeIteration, self).setUp()
-
- self.students = [
- UserFactory.create(username='student1'),
- UserFactory.create(username='student2'),
- UserFactory.create(username='student3'),
- UserFactory.create(username='student4'),
- UserFactory.create(username='student5'),
- ]
-
- def test_empty_student_list(self):
- """
- If we don't pass in any students, it should return a zero-length
- iterator, but it shouldn't error.
- """
- grade_results = list(CourseGradeFactory().iter([], self.course))
- self.assertEqual(grade_results, [])
-
- def test_all_empty_grades(self):
- """
- No students have grade entries.
- """
- with patch.object(
- BlockStructureFactory,
- 'create_from_store',
- wraps=BlockStructureFactory.create_from_store
- ) as mock_create_from_store:
- all_course_grades, all_errors = self._course_grades_and_errors_for(self.course, self.students)
- self.assertEquals(mock_create_from_store.call_count, 1)
-
- self.assertEqual(len(all_errors), 0)
- for course_grade in all_course_grades.values():
- self.assertIsNone(course_grade.letter_grade)
- self.assertEqual(course_grade.percent, 0.0)
-
- @patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read')
- def test_grading_exception(self, mock_course_grade):
- """Test that we correctly capture exception messages that bubble up from
- grading. Note that we only see errors at this level if the grading
- process for this student fails entirely due to an unexpected event --
- having errors in the problem sets will not trigger this.
-
- We patch the grade() method with our own, which will generate the errors
- for student3 and student4.
- """
-
- student1, student2, student3, student4, student5 = self.students
- mock_course_grade.side_effect = [
- Exception("Error for {}.".format(student.username))
- if student.username in ['student3', 'student4']
- else mock_course_grade.return_value
- for student in self.students
- ]
- with self.assertNumQueries(4):
- all_course_grades, all_errors = self._course_grades_and_errors_for(self.course, self.students)
- self.assertEqual(
- {student: all_errors[student].message for student in all_errors},
- {
- student3: "Error for student3.",
- student4: "Error for student4.",
- }
- )
-
- # But we should still have five gradesets
- self.assertEqual(len(all_course_grades), 5)
-
- # Even though two will simply be empty
- self.assertIsNone(all_course_grades[student3])
- self.assertIsNone(all_course_grades[student4])
-
- # The rest will have grade information in them
- self.assertIsNotNone(all_course_grades[student1])
- self.assertIsNotNone(all_course_grades[student2])
- self.assertIsNotNone(all_course_grades[student5])
-
- def _course_grades_and_errors_for(self, course, students):
- """
- Simple helper method to iterate through student grades and give us
- two dictionaries -- one that has all students and their respective
- course grades, and one that has only students that could not be graded
- and their respective error messages.
- """
- students_to_course_grades = {}
- students_to_errors = {}
-
- for student, course_grade, error in CourseGradeFactory().iter(students, course):
- students_to_course_grades[student] = course_grade
- if error:
- students_to_errors[student] = error
-
- return students_to_course_grades, students_to_errors
-
-
-@ddt.ddt
-class TestWeightedProblems(SharedModuleStoreTestCase):
- """
- Test scores and grades with various problem weight values.
- """
- @classmethod
- def setUpClass(cls):
- super(TestWeightedProblems, cls).setUpClass()
- cls.course = CourseFactory.create()
- with cls.store.bulk_operations(cls.course.id):
- cls.chapter = ItemFactory.create(parent=cls.course, category="chapter", display_name="chapter")
- cls.sequential = ItemFactory.create(parent=cls.chapter, category="sequential", display_name="sequential")
- cls.vertical = ItemFactory.create(parent=cls.sequential, category="vertical", display_name="vertical1")
- problem_xml = cls._create_problem_xml()
- cls.problems = []
- for i in range(2):
- cls.problems.append(
- ItemFactory.create(
- parent=cls.vertical,
- category="problem",
- display_name="problem_{}".format(i),
- data=problem_xml,
- )
- )
-
- def setUp(self):
- super(TestWeightedProblems, self).setUp()
- self.user = UserFactory()
- self.request = get_mock_request(self.user)
-
- @classmethod
- def _create_problem_xml(cls):
- """
- Creates and returns XML for a multiple choice response problem
- """
- return MultipleChoiceResponseXMLFactory().build_xml(
- question_text='The correct answer is Choice 3',
- choices=[False, False, True, False],
- choice_names=['choice_0', 'choice_1', 'choice_2', 'choice_3']
- )
-
- def _verify_grades(self, raw_earned, raw_possible, weight, expected_score):
- """
- Verifies the computed grades are as expected.
- """
- with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
- # pylint: disable=no-member
- for problem in self.problems:
- problem.weight = weight
- self.store.update_item(problem, self.user.id)
- self.store.publish(self.course.location, self.user.id)
-
- course_structure = get_course_blocks(self.request.user, self.course.location)
-
- # answer all problems
- for problem in self.problems:
- answer_problem(self.course, self.request, problem, score=raw_earned, max_value=raw_possible)
-
- # get grade
- subsection_grade = SubsectionGradeFactory(
- self.request.user, self.course, course_structure
- ).update(self.sequential)
-
- # verify all problem grades
- for problem in self.problems:
- problem_score = subsection_grade.problem_scores[problem.location]
- self.assertEqual(type(expected_score.first_attempted), type(problem_score.first_attempted))
- expected_score.first_attempted = problem_score.first_attempted
- self.assertEquals(problem_score, expected_score)
-
- # verify subsection grades
- self.assertEquals(subsection_grade.all_total.earned, expected_score.earned * len(self.problems))
- self.assertEquals(subsection_grade.all_total.possible, expected_score.possible * len(self.problems))
-
- @ddt.data(
- *itertools.product(
- (0.0, 0.5, 1.0, 2.0), # raw_earned
- (-2.0, -1.0, 0.0, 0.5, 1.0, 2.0), # raw_possible
- (-2.0, -1.0, -0.5, 0.0, 0.5, 1.0, 2.0, 50.0, None), # weight
- )
- )
- @ddt.unpack
- def test_problem_weight(self, raw_earned, raw_possible, weight):
-
- use_weight = weight is not None and raw_possible != 0
- if use_weight:
- expected_w_earned = raw_earned / raw_possible * weight
- expected_w_possible = weight
- else:
- expected_w_earned = raw_earned
- expected_w_possible = raw_possible
-
- expected_graded = expected_w_possible > 0
-
- expected_score = ProblemScore(
- raw_earned=raw_earned,
- raw_possible=raw_possible,
- weighted_earned=expected_w_earned,
- weighted_possible=expected_w_possible,
- weight=weight,
- graded=expected_graded,
- first_attempted=datetime.datetime(2010, 1, 1),
- )
- self._verify_grades(raw_earned, raw_possible, weight, expected_score)
-
-
-class TestScoreForModule(SharedModuleStoreTestCase):
- """
- Test the method that calculates the score for a given block based on the
- cumulative scores of its children. This test class uses a hard-coded block
- hierarchy with scores as follows:
- a
- +--------+--------+
- b c
- +--------------+-----------+ |
- d e f g
- +-----+ +-----+-----+ | |
- h i j k l m n
- (2/5) (3/5) (0/1) - (1/3) - (3/10)
-
- """
- @classmethod
- def setUpClass(cls):
- super(TestScoreForModule, cls).setUpClass()
- cls.course = CourseFactory.create()
- with cls.store.bulk_operations(cls.course.id):
- cls.a = ItemFactory.create(parent=cls.course, category="chapter", display_name="a")
- cls.b = ItemFactory.create(parent=cls.a, category="sequential", display_name="b")
- cls.c = ItemFactory.create(parent=cls.a, category="sequential", display_name="c")
- cls.d = ItemFactory.create(parent=cls.b, category="vertical", display_name="d")
- cls.e = ItemFactory.create(parent=cls.b, category="vertical", display_name="e")
- cls.f = ItemFactory.create(parent=cls.b, category="vertical", display_name="f")
- cls.g = ItemFactory.create(parent=cls.c, category="vertical", display_name="g")
- cls.h = ItemFactory.create(parent=cls.d, category="problem", display_name="h")
- cls.i = ItemFactory.create(parent=cls.d, category="problem", display_name="i")
- cls.j = ItemFactory.create(parent=cls.e, category="problem", display_name="j")
- cls.k = ItemFactory.create(parent=cls.e, category="html", display_name="k")
- cls.l = ItemFactory.create(parent=cls.e, category="problem", display_name="l")
- cls.m = ItemFactory.create(parent=cls.f, category="html", display_name="m")
- cls.n = ItemFactory.create(parent=cls.g, category="problem", display_name="n")
-
- cls.request = get_mock_request(UserFactory())
- CourseEnrollment.enroll(cls.request.user, cls.course.id)
-
- answer_problem(cls.course, cls.request, cls.h, score=2, max_value=5)
- answer_problem(cls.course, cls.request, cls.i, score=3, max_value=5)
- answer_problem(cls.course, cls.request, cls.j, score=0, max_value=1)
- answer_problem(cls.course, cls.request, cls.l, score=1, max_value=3)
- answer_problem(cls.course, cls.request, cls.n, score=3, max_value=10)
-
- cls.course_grade = CourseGradeFactory().read(cls.request.user, cls.course)
-
- def test_score_chapter(self):
- earned, possible = self.course_grade.score_for_module(self.a.location)
- self.assertEqual(earned, 9)
- self.assertEqual(possible, 24)
-
- def test_score_section_many_leaves(self):
- earned, possible = self.course_grade.score_for_module(self.b.location)
- self.assertEqual(earned, 6)
- self.assertEqual(possible, 14)
-
- def test_score_section_one_leaf(self):
- earned, possible = self.course_grade.score_for_module(self.c.location)
- self.assertEqual(earned, 3)
- self.assertEqual(possible, 10)
-
- def test_score_vertical_two_leaves(self):
- earned, possible = self.course_grade.score_for_module(self.d.location)
- self.assertEqual(earned, 5)
- self.assertEqual(possible, 10)
-
- def test_score_vertical_two_leaves_one_unscored(self):
- earned, possible = self.course_grade.score_for_module(self.e.location)
- self.assertEqual(earned, 1)
- self.assertEqual(possible, 4)
-
- def test_score_vertical_no_score(self):
- earned, possible = self.course_grade.score_for_module(self.f.location)
- self.assertEqual(earned, 0)
- self.assertEqual(possible, 0)
-
- def test_score_vertical_one_leaf(self):
- earned, possible = self.course_grade.score_for_module(self.g.location)
- self.assertEqual(earned, 3)
- self.assertEqual(possible, 10)
-
- def test_score_leaf(self):
- earned, possible = self.course_grade.score_for_module(self.h.location)
- self.assertEqual(earned, 2)
- self.assertEqual(possible, 5)
-
- def test_score_leaf_no_score(self):
- earned, possible = self.course_grade.score_for_module(self.m.location)
- self.assertEqual(earned, 0)
- self.assertEqual(possible, 0)
diff --git a/lms/djangoapps/grades/tests/test_new.py b/lms/djangoapps/grades/tests/test_new.py
deleted file mode 100644
index 6c8e35e4d1..0000000000
--- a/lms/djangoapps/grades/tests/test_new.py
+++ /dev/null
@@ -1,699 +0,0 @@
-"""
-Test saved subsection grade functionality.
-"""
-# pylint: disable=protected-access
-
-import datetime
-import itertools
-
-import ddt
-import pytz
-from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
-from courseware.access import has_access
-from courseware.tests.test_submitting_problems import ProblemSubmissionTestMixin
-from django.conf import settings
-from lms.djangoapps.course_blocks.api import get_course_blocks
-from lms.djangoapps.grades.config.tests.utils import persistent_grades_feature_flags
-from mock import patch
-from openedx.core.djangolib.testing.utils import get_mock_request
-from student.models import CourseEnrollment
-from student.tests.factories import UserFactory
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
-from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
-from xmodule.modulestore.tests.utils import TEST_DATA_DIR
-from xmodule.modulestore.xml_importer import import_course_from_xml
-
-from ..config.waffle import ASSUME_ZERO_GRADE_IF_ABSENT, waffle
-from ..course_data import CourseData
-from ..course_grade import CourseGrade, ZeroCourseGrade
-from ..course_grade_factory import CourseGradeFactory
-from ..models import PersistentSubsectionGrade
-from ..subsection_grade import SubsectionGrade, ZeroSubsectionGrade
-from ..subsection_grade_factory import SubsectionGradeFactory
-from .utils import mock_get_score, mock_get_submissions_score
-
-
-class GradeTestBase(SharedModuleStoreTestCase):
- """
- Base class for Course- and SubsectionGradeFactory tests.
- """
- @classmethod
- def setUpClass(cls):
- super(GradeTestBase, cls).setUpClass()
- cls.course = CourseFactory.create()
- with cls.store.bulk_operations(cls.course.id):
- cls.chapter = ItemFactory.create(
- parent=cls.course,
- category="chapter",
- display_name="Test Chapter"
- )
- cls.sequence = ItemFactory.create(
- parent=cls.chapter,
- category='sequential',
- display_name="Test Sequential 1",
- graded=True,
- format="Homework"
- )
- cls.vertical = ItemFactory.create(
- parent=cls.sequence,
- category='vertical',
- display_name='Test Vertical 1'
- )
- problem_xml = MultipleChoiceResponseXMLFactory().build_xml(
- question_text='The correct answer is Choice 3',
- choices=[False, False, True, False],
- choice_names=['choice_0', 'choice_1', 'choice_2', 'choice_3']
- )
- cls.problem = ItemFactory.create(
- parent=cls.vertical,
- category="problem",
- display_name="Test Problem",
- data=problem_xml
- )
- cls.sequence2 = ItemFactory.create(
- parent=cls.chapter,
- category='sequential',
- display_name="Test Sequential 2",
- graded=True,
- format="Homework"
- )
- cls.problem2 = ItemFactory.create(
- parent=cls.sequence2,
- category="problem",
- display_name="Test Problem",
- data=problem_xml
- )
- # AED 2017-06-19: make cls.sequence belong to multiple parents,
- # so we can test that DAGs with this shape are handled correctly.
- cls.chapter_2 = ItemFactory.create(
- parent=cls.course,
- category='chapter',
- display_name='Test Chapter 2'
- )
- cls.chapter_2.children.append(cls.sequence.location)
- cls.store.update_item(cls.chapter_2, UserFactory().id)
-
- def setUp(self):
- super(GradeTestBase, self).setUp()
- self.request = get_mock_request(UserFactory())
- self.client.login(username=self.request.user.username, password="test")
- self._set_grading_policy()
- self.course_structure = get_course_blocks(self.request.user, self.course.location)
- self.subsection_grade_factory = SubsectionGradeFactory(self.request.user, self.course, self.course_structure)
- CourseEnrollment.enroll(self.request.user, self.course.id)
-
- def _set_grading_policy(self, passing=0.5):
- """
- Updates the course's grading policy.
- """
- self.grading_policy = {
- "GRADER": [
- {
- "type": "Homework",
- "min_count": 1,
- "drop_count": 0,
- "short_label": "HW",
- "weight": 1.0,
- },
- ],
- "GRADE_CUTOFFS": {
- "Pass": passing,
- },
- }
- self.course.set_grading_policy(self.grading_policy)
- self.store.update_item(self.course, 0)
-
-
-@ddt.ddt
-class TestCourseGradeFactory(GradeTestBase):
- """
- Test that CourseGrades are calculated properly
- """
- def _assert_zero_grade(self, course_grade, expected_grade_class):
- """
- Asserts whether the given course_grade is as expected with
- zero values.
- """
- self.assertIsInstance(course_grade, expected_grade_class)
- self.assertIsNone(course_grade.letter_grade)
- self.assertEqual(course_grade.percent, 0.0)
- self.assertIsNotNone(course_grade.chapter_grades)
-
- def test_course_grade_no_access(self):
- """
- Test to ensure a grade can ba calculated for a student in a course, even if they themselves do not have access.
- """
- invisible_course = CourseFactory.create(visible_to_staff_only=True)
- access = has_access(self.request.user, 'load', invisible_course)
- self.assertEqual(access.has_access, False)
- self.assertEqual(access.error_code, 'not_visible_to_user')
-
- # with self.assertNoExceptionRaised: <- this isn't a real method, it's an implicit assumption
- grade = CourseGradeFactory().read(self.request.user, invisible_course)
- self.assertEqual(grade.percent, 0)
-
- @patch.dict(settings.FEATURES, {'PERSISTENT_GRADES_ENABLED_FOR_ALL_TESTS': False})
- @ddt.data(
- (True, True),
- (True, False),
- (False, True),
- (False, False),
- )
- @ddt.unpack
- def test_course_grade_feature_gating(self, feature_flag, course_setting):
- # Grades are only saved if the feature flag and the advanced setting are
- # both set to True.
- grade_factory = CourseGradeFactory()
- with persistent_grades_feature_flags(
- global_flag=feature_flag,
- enabled_for_all_courses=False,
- course_id=self.course.id,
- enabled_for_course=course_setting
- ):
- with patch('lms.djangoapps.grades.models.PersistentCourseGrade.read') as mock_read_grade:
- grade_factory.read(self.request.user, self.course)
- self.assertEqual(mock_read_grade.called, feature_flag and course_setting)
-
- def test_read(self):
- grade_factory = CourseGradeFactory()
-
- def _assert_read(expected_pass, expected_percent):
- """
- Creates the grade, ensuring it is as expected.
- """
- course_grade = grade_factory.read(self.request.user, self.course)
- self.assertEqual(course_grade.letter_grade, u'Pass' if expected_pass else None)
- self.assertEqual(course_grade.percent, expected_percent)
-
- with self.assertNumQueries(1), mock_get_score(1, 2):
- _assert_read(expected_pass=False, expected_percent=0)
-
- with self.assertNumQueries(37), mock_get_score(1, 2):
- grade_factory.update(self.request.user, self.course, force_update_subsections=True)
-
- with self.assertNumQueries(1):
- _assert_read(expected_pass=True, expected_percent=0.5)
-
- @patch.dict(settings.FEATURES, {'ASSUME_ZERO_GRADE_IF_ABSENT_FOR_ALL_TESTS': False})
- @ddt.data(*itertools.product((True, False), (True, False)))
- @ddt.unpack
- def test_read_zero(self, assume_zero_enabled, create_if_needed):
- with waffle().override(ASSUME_ZERO_GRADE_IF_ABSENT, active=assume_zero_enabled):
- grade_factory = CourseGradeFactory()
- course_grade = grade_factory.read(self.request.user, self.course, create_if_needed=create_if_needed)
- if create_if_needed or assume_zero_enabled:
- self._assert_zero_grade(course_grade, ZeroCourseGrade if assume_zero_enabled else CourseGrade)
- else:
- self.assertIsNone(course_grade)
-
- def test_create_zero_subs_grade_for_nonzero_course_grade(self):
- subsection = self.course_structure[self.sequence.location]
- with mock_get_score(1, 2):
- self.subsection_grade_factory.update(subsection)
- course_grade = CourseGradeFactory().update(self.request.user, self.course)
- subsection1_grade = course_grade.subsection_grades[self.sequence.location]
- subsection2_grade = course_grade.subsection_grades[self.sequence2.location]
- self.assertIsInstance(subsection1_grade, SubsectionGrade)
- self.assertIsInstance(subsection2_grade, ZeroSubsectionGrade)
-
- @ddt.data(True, False)
- def test_iter_force_update(self, force_update):
- with patch('lms.djangoapps.grades.subsection_grade_factory.SubsectionGradeFactory.update') as mock_update:
- set(CourseGradeFactory().iter(
- users=[self.request.user], course=self.course, force_update=force_update
- ))
-
- self.assertEqual(mock_update.called, force_update)
-
- def test_course_grade_summary(self):
- with mock_get_score(1, 2):
- self.subsection_grade_factory.update(self.course_structure[self.sequence.location])
- course_grade = CourseGradeFactory().update(self.request.user, self.course)
-
- actual_summary = course_grade.summary
-
- # We should have had a zero subsection grade for sequential 2, since we never
- # gave it a mock score above.
- expected_summary = {
- 'grade': None,
- 'grade_breakdown': {
- 'Homework': {
- 'category': 'Homework',
- 'percent': 0.25,
- 'detail': 'Homework = 25.00% of a possible 100.00%',
- }
- },
- 'percent': 0.25,
- 'section_breakdown': [
- {
- 'category': 'Homework',
- 'detail': u'Homework 1 - Test Sequential 1 - 50% (1/2)',
- 'label': u'HW 01',
- 'percent': 0.5
- },
- {
- 'category': 'Homework',
- 'detail': u'Homework 2 - Test Sequential 2 - 0% (0/1)',
- 'label': u'HW 02',
- 'percent': 0.0
- },
- {
- 'category': 'Homework',
- 'detail': u'Homework Average = 25%',
- 'label': u'HW Avg',
- 'percent': 0.25,
- 'prominent': True
- },
- ]
- }
- self.assertEqual(expected_summary, actual_summary)
-
-
-@ddt.ddt
-class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase):
- """
- Tests for SubsectionGradeFactory functionality.
-
- Ensures that SubsectionGrades are created and updated properly, that
- persistent grades are functioning as expected, and that the flag to
- enable saving subsection grades blocks/enables that feature as expected.
- """
-
- def assert_grade(self, grade, expected_earned, expected_possible):
- """
- Asserts that the given grade object has the expected score.
- """
- self.assertEqual(
- (grade.all_total.earned, grade.all_total.possible),
- (expected_earned, expected_possible),
- )
-
- def test_create_zero(self):
- """
- Test that a zero grade is returned.
- """
- grade = self.subsection_grade_factory.create(self.sequence)
- self.assertIsInstance(grade, ZeroSubsectionGrade)
- self.assert_grade(grade, 0.0, 1.0)
-
- def test_update(self):
- """
- Assuming the underlying score reporting methods work,
- test that the score is calculated properly.
- """
- with mock_get_score(1, 2):
- grade = self.subsection_grade_factory.update(self.sequence)
- self.assert_grade(grade, 1, 2)
-
- def test_write_only_if_engaged(self):
- """
- Test that scores are not persisted when a learner has
- never attempted a problem, but are persisted if the
- learner's state has been deleted.
- """
- with mock_get_score(0, 0, None):
- self.subsection_grade_factory.update(self.sequence)
- # ensure no grades have been persisted
- self.assertEqual(0, len(PersistentSubsectionGrade.objects.all()))
-
- with mock_get_score(0, 0, None):
- self.subsection_grade_factory.update(self.sequence, score_deleted=True)
- # ensure a grade has been persisted
- self.assertEqual(1, len(PersistentSubsectionGrade.objects.all()))
-
- def test_update_if_higher(self):
- def verify_update_if_higher(mock_score, expected_grade):
- """
- Updates the subsection grade and verifies the
- resulting grade is as expected.
- """
- with mock_get_score(*mock_score):
- grade = self.subsection_grade_factory.update(self.sequence, only_if_higher=True)
- self.assert_grade(grade, *expected_grade)
-
- verify_update_if_higher((1, 2), (1, 2)) # previous value was non-existent
- verify_update_if_higher((2, 4), (2, 4)) # previous value was equivalent
- verify_update_if_higher((1, 4), (2, 4)) # previous value was greater
- verify_update_if_higher((3, 4), (3, 4)) # previous value was less
-
- @patch.dict(settings.FEATURES, {'PERSISTENT_GRADES_ENABLED_FOR_ALL_TESTS': False})
- @ddt.data(
- (True, True),
- (True, False),
- (False, True),
- (False, False),
- )
- @ddt.unpack
- def test_subsection_grade_feature_gating(self, feature_flag, course_setting):
- # Grades are only saved if the feature flag and the advanced setting are
- # both set to True.
- with patch(
- 'lms.djangoapps.grades.models.PersistentSubsectionGrade.bulk_read_grades'
- ) as mock_read_saved_grade:
- with persistent_grades_feature_flags(
- global_flag=feature_flag,
- enabled_for_all_courses=False,
- course_id=self.course.id,
- enabled_for_course=course_setting
- ):
- self.subsection_grade_factory.create(self.sequence)
- self.assertEqual(mock_read_saved_grade.called, feature_flag and course_setting)
-
-
-@patch.dict(settings.FEATURES, {'ASSUME_ZERO_GRADE_IF_ABSENT_FOR_ALL_TESTS': False})
-@ddt.ddt
-class ZeroGradeTest(GradeTestBase):
- """
- Tests ZeroCourseGrade (and, implicitly, ZeroSubsectionGrade)
- functionality.
- """
- @ddt.data(True, False)
- def test_zero(self, assume_zero_enabled):
- """
- Creates a ZeroCourseGrade and ensures it's empty.
- """
- with waffle().override(ASSUME_ZERO_GRADE_IF_ABSENT, active=assume_zero_enabled):
- course_data = CourseData(self.request.user, structure=self.course_structure)
- chapter_grades = ZeroCourseGrade(self.request.user, course_data).chapter_grades
- for chapter in chapter_grades:
- for section in chapter_grades[chapter]['sections']:
- for score in section.problem_scores.itervalues():
- self.assertEqual(score.earned, 0)
- self.assertEqual(score.first_attempted, None)
- self.assertEqual(section.all_total.earned, 0)
-
- @ddt.data(True, False)
- def test_zero_null_scores(self, assume_zero_enabled):
- """
- Creates a zero course grade and ensures that null scores aren't included in the section problem scores.
- """
- with waffle().override(ASSUME_ZERO_GRADE_IF_ABSENT, active=assume_zero_enabled):
- with patch('lms.djangoapps.grades.subsection_grade.get_score', return_value=None):
- course_data = CourseData(self.request.user, structure=self.course_structure)
- chapter_grades = ZeroCourseGrade(self.request.user, course_data).chapter_grades
- for chapter in chapter_grades:
- self.assertNotEqual({}, chapter_grades[chapter]['sections'])
- for section in chapter_grades[chapter]['sections']:
- self.assertEqual({}, section.problem_scores)
-
-
-class SubsectionGradeTest(GradeTestBase):
- """
- Tests SubsectionGrade functionality.
- """
-
- def test_save_and_load(self):
- """
- Test that grades are persisted to the database properly,
- and that loading saved grades returns the same data.
- """
- with mock_get_score(1, 2):
- # Create a grade that *isn't* saved to the database
- input_grade = SubsectionGrade(self.sequence)
- input_grade.init_from_structure(
- self.request.user,
- self.course_structure,
- self.subsection_grade_factory._submissions_scores,
- self.subsection_grade_factory._csm_scores,
- )
- self.assertEqual(PersistentSubsectionGrade.objects.count(), 0)
-
- # save to db, and verify object is in database
- input_grade.create_model(self.request.user)
- self.assertEqual(PersistentSubsectionGrade.objects.count(), 1)
-
- # load from db, and ensure output matches input
- loaded_grade = SubsectionGrade(self.sequence)
- saved_model = PersistentSubsectionGrade.read_grade(
- user_id=self.request.user.id,
- usage_key=self.sequence.location,
- )
- loaded_grade.init_from_model(
- self.request.user,
- saved_model,
- self.course_structure,
- self.subsection_grade_factory._submissions_scores,
- self.subsection_grade_factory._csm_scores,
- )
-
- self.assertEqual(input_grade.url_name, loaded_grade.url_name)
- loaded_grade.all_total.first_attempted = input_grade.all_total.first_attempted = None
- self.assertEqual(input_grade.all_total, loaded_grade.all_total)
-
-
-@ddt.ddt
-class TestMultipleProblemTypesSubsectionScores(SharedModuleStoreTestCase):
- """
- Test grading of different problem types.
- """
-
- SCORED_BLOCK_COUNT = 7
- ACTUAL_TOTAL_POSSIBLE = 17.0
-
- @classmethod
- def setUpClass(cls):
- super(TestMultipleProblemTypesSubsectionScores, cls).setUpClass()
- cls.load_scoreable_course()
- chapter1 = cls.course.get_children()[0]
- cls.seq1 = chapter1.get_children()[0]
-
- def setUp(self):
- super(TestMultipleProblemTypesSubsectionScores, self).setUp()
- password = u'test'
- self.student = UserFactory.create(is_staff=False, username=u'test_student', password=password)
- self.client.login(username=self.student.username, password=password)
- self.request = get_mock_request(self.student)
- self.course_structure = get_course_blocks(self.student, self.course.location)
-
- @classmethod
- def load_scoreable_course(cls):
- """
- This test course lives at `common/test/data/scoreable`.
-
- For details on the contents and structure of the file, see
- `common/test/data/scoreable/README`.
- """
-
- course_items = import_course_from_xml(
- cls.store,
- 'test_user',
- TEST_DATA_DIR,
- source_dirs=['scoreable'],
- static_content_store=None,
- target_id=cls.store.make_course_key('edX', 'scoreable', '3000'),
- raise_on_failure=True,
- create_if_not_present=True,
- )
-
- cls.course = course_items[0]
-
- def test_score_submission_for_all_problems(self):
- subsection_factory = SubsectionGradeFactory(
- self.student,
- course_structure=self.course_structure,
- course=self.course,
- )
- score = subsection_factory.create(self.seq1)
-
- self.assertEqual(score.all_total.earned, 0.0)
- self.assertEqual(score.all_total.possible, self.ACTUAL_TOTAL_POSSIBLE)
-
- # Choose arbitrary, non-default values for earned and possible.
- earned_per_block = 3.0
- possible_per_block = 7.0
- with mock_get_submissions_score(earned_per_block, possible_per_block) as mock_score:
- # Configure one block to return no possible score, the rest to return 3.0 earned / 7.0 possible
- block_count = self.SCORED_BLOCK_COUNT - 1
- mock_score.side_effect = itertools.chain(
- [(earned_per_block, None, earned_per_block, None, datetime.datetime(2000, 1, 1))],
- itertools.repeat(mock_score.return_value)
- )
- score = subsection_factory.update(self.seq1)
- self.assertEqual(score.all_total.earned, earned_per_block * block_count)
- self.assertEqual(score.all_total.possible, possible_per_block * block_count)
-
-
-@ddt.ddt
-class TestVariedMetadata(ProblemSubmissionTestMixin, ModuleStoreTestCase):
- """
- Test that changing the metadata on a block has the desired effect on the
- persisted score.
- """
- default_problem_metadata = {
- u'graded': True,
- u'weight': 2.5,
- u'due': datetime.datetime(2099, 3, 15, 12, 30, 0, tzinfo=pytz.utc),
- }
-
- def setUp(self):
- super(TestVariedMetadata, self).setUp()
- self.course = CourseFactory.create()
- with self.store.bulk_operations(self.course.id):
- self.chapter = ItemFactory.create(
- parent=self.course,
- category="chapter",
- display_name="Test Chapter"
- )
- self.sequence = ItemFactory.create(
- parent=self.chapter,
- category='sequential',
- display_name="Test Sequential 1",
- graded=True
- )
- self.vertical = ItemFactory.create(
- parent=self.sequence,
- category='vertical',
- display_name='Test Vertical 1'
- )
- self.problem_xml = u'''
-
-
-
-
-
-
- '''
- self.request = get_mock_request(UserFactory())
- self.client.login(username=self.request.user.username, password="test")
- CourseEnrollment.enroll(self.request.user, self.course.id)
-
- def _get_altered_metadata(self, alterations):
- """
- Returns a copy of the default_problem_metadata dict updated with the
- specified alterations.
- """
- metadata = self.default_problem_metadata.copy()
- metadata.update(alterations)
- return metadata
-
- def _add_problem_with_alterations(self, alterations):
- """
- Add a problem to the course with the specified metadata alterations.
- """
-
- metadata = self._get_altered_metadata(alterations)
- ItemFactory.create(
- parent=self.vertical,
- category="problem",
- display_name="problem",
- data=self.problem_xml,
- metadata=metadata,
- )
-
- def _get_score(self):
- """
- Return the score of the test problem when one correct problem (out of
- two) is submitted.
- """
-
- self.submit_question_answer(u'problem', {u'2_1': u'Correct'})
- course_structure = get_course_blocks(self.request.user, self.course.location)
- subsection_factory = SubsectionGradeFactory(
- self.request.user,
- course_structure=course_structure,
- course=self.course,
- )
- return subsection_factory.create(self.sequence)
-
- @ddt.data(
- ({}, 1.25, 2.5),
- ({u'weight': 27}, 13.5, 27),
- ({u'weight': 1.0}, 0.5, 1.0),
- ({u'weight': 0.0}, 0.0, 0.0),
- ({u'weight': None}, 1.0, 2.0),
- )
- @ddt.unpack
- def test_weight_metadata_alterations(self, alterations, expected_earned, expected_possible):
- self._add_problem_with_alterations(alterations)
- score = self._get_score()
- self.assertEqual(score.all_total.earned, expected_earned)
- self.assertEqual(score.all_total.possible, expected_possible)
-
- @ddt.data(
- ({u'graded': True}, 1.25, 2.5),
- ({u'graded': False}, 0.0, 0.0),
- )
- @ddt.unpack
- def test_graded_metadata_alterations(self, alterations, expected_earned, expected_possible):
- self._add_problem_with_alterations(alterations)
- score = self._get_score()
- self.assertEqual(score.graded_total.earned, expected_earned)
- self.assertEqual(score.graded_total.possible, expected_possible)
-
-
-class TestCourseGradeLogging(ProblemSubmissionTestMixin, SharedModuleStoreTestCase):
- """
- Tests logging in the course grades module.
- Uses a larger course structure than other
- unit tests.
- """
- def setUp(self):
- super(TestCourseGradeLogging, self).setUp()
- self.course = CourseFactory.create()
- with self.store.bulk_operations(self.course.id):
- self.chapter = ItemFactory.create(
- parent=self.course,
- category="chapter",
- display_name="Test Chapter"
- )
- self.sequence = ItemFactory.create(
- parent=self.chapter,
- category='sequential',
- display_name="Test Sequential 1",
- graded=True
- )
- self.sequence_2 = ItemFactory.create(
- parent=self.chapter,
- category='sequential',
- display_name="Test Sequential 2",
- graded=True
- )
- self.sequence_3 = ItemFactory.create(
- parent=self.chapter,
- category='sequential',
- display_name="Test Sequential 3",
- graded=False
- )
- self.vertical = ItemFactory.create(
- parent=self.sequence,
- category='vertical',
- display_name='Test Vertical 1'
- )
- self.vertical_2 = ItemFactory.create(
- parent=self.sequence_2,
- category='vertical',
- display_name='Test Vertical 2'
- )
- self.vertical_3 = ItemFactory.create(
- parent=self.sequence_3,
- category='vertical',
- display_name='Test Vertical 3'
- )
- problem_xml = MultipleChoiceResponseXMLFactory().build_xml(
- question_text='The correct answer is Choice 2',
- choices=[False, False, True, False],
- choice_names=['choice_0', 'choice_1', 'choice_2', 'choice_3']
- )
- self.problem = ItemFactory.create(
- parent=self.vertical,
- category="problem",
- display_name="test_problem_1",
- data=problem_xml
- )
- self.problem_2 = ItemFactory.create(
- parent=self.vertical_2,
- category="problem",
- display_name="test_problem_2",
- data=problem_xml
- )
- self.problem_3 = ItemFactory.create(
- parent=self.vertical_3,
- category="problem",
- display_name="test_problem_3",
- data=problem_xml
- )
- self.request = get_mock_request(UserFactory())
- self.client.login(username=self.request.user.username, password="test")
- self.course_structure = get_course_blocks(self.request.user, self.course.location)
- self.subsection_grade_factory = SubsectionGradeFactory(self.request.user, self.course, self.course_structure)
- CourseEnrollment.enroll(self.request.user, self.course.id)
diff --git a/lms/djangoapps/grades/tests/test_problems.py b/lms/djangoapps/grades/tests/test_problems.py
new file mode 100644
index 0000000000..3af2af7566
--- /dev/null
+++ b/lms/djangoapps/grades/tests/test_problems.py
@@ -0,0 +1,306 @@
+import datetime
+import itertools
+
+import ddt
+import pytz
+from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
+from courseware.tests.test_submitting_problems import ProblemSubmissionTestMixin
+from lms.djangoapps.course_blocks.api import get_course_blocks
+from openedx.core.djangolib.testing.utils import get_mock_request
+from student.models import CourseEnrollment
+from student.tests.factories import UserFactory
+from xmodule.graders import ProblemScore
+from xmodule.modulestore import ModuleStoreEnum
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
+from xmodule.modulestore.tests.utils import TEST_DATA_DIR
+from xmodule.modulestore.xml_importer import import_course_from_xml
+
+from ..subsection_grade_factory import SubsectionGradeFactory
+from .utils import answer_problem, mock_get_submissions_score
+
+
+@ddt.ddt
+class TestMultipleProblemTypesSubsectionScores(SharedModuleStoreTestCase):
+ """
+ Test grading of different problem types.
+ """
+
+ SCORED_BLOCK_COUNT = 7
+ ACTUAL_TOTAL_POSSIBLE = 17.0
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestMultipleProblemTypesSubsectionScores, cls).setUpClass()
+ cls.load_scoreable_course()
+ chapter1 = cls.course.get_children()[0]
+ cls.seq1 = chapter1.get_children()[0]
+
+ def setUp(self):
+ super(TestMultipleProblemTypesSubsectionScores, self).setUp()
+ password = u'test'
+ self.student = UserFactory.create(is_staff=False, username=u'test_student', password=password)
+ self.client.login(username=self.student.username, password=password)
+ self.request = get_mock_request(self.student)
+ self.course_structure = get_course_blocks(self.student, self.course.location)
+
+ @classmethod
+ def load_scoreable_course(cls):
+ """
+ This test course lives at `common/test/data/scoreable`.
+
+ For details on the contents and structure of the file, see
+ `common/test/data/scoreable/README`.
+ """
+
+ course_items = import_course_from_xml(
+ cls.store,
+ 'test_user',
+ TEST_DATA_DIR,
+ source_dirs=['scoreable'],
+ static_content_store=None,
+ target_id=cls.store.make_course_key('edX', 'scoreable', '3000'),
+ raise_on_failure=True,
+ create_if_not_present=True,
+ )
+
+ cls.course = course_items[0]
+
+ def test_score_submission_for_all_problems(self):
+ subsection_factory = SubsectionGradeFactory(
+ self.student,
+ course_structure=self.course_structure,
+ course=self.course,
+ )
+ score = subsection_factory.create(self.seq1)
+
+ self.assertEqual(score.all_total.earned, 0.0)
+ self.assertEqual(score.all_total.possible, self.ACTUAL_TOTAL_POSSIBLE)
+
+ # Choose arbitrary, non-default values for earned and possible.
+ earned_per_block = 3.0
+ possible_per_block = 7.0
+ with mock_get_submissions_score(earned_per_block, possible_per_block) as mock_score:
+ # Configure one block to return no possible score, the rest to return 3.0 earned / 7.0 possible
+ block_count = self.SCORED_BLOCK_COUNT - 1
+ mock_score.side_effect = itertools.chain(
+ [(earned_per_block, None, earned_per_block, None, datetime.datetime(2000, 1, 1))],
+ itertools.repeat(mock_score.return_value)
+ )
+ score = subsection_factory.update(self.seq1)
+ self.assertEqual(score.all_total.earned, earned_per_block * block_count)
+ self.assertEqual(score.all_total.possible, possible_per_block * block_count)
+
+
+@ddt.ddt
+class TestVariedMetadata(ProblemSubmissionTestMixin, ModuleStoreTestCase):
+ """
+ Test that changing the metadata on a block has the desired effect on the
+ persisted score.
+ """
+ default_problem_metadata = {
+ u'graded': True,
+ u'weight': 2.5,
+ u'due': datetime.datetime(2099, 3, 15, 12, 30, 0, tzinfo=pytz.utc),
+ }
+
+ def setUp(self):
+ super(TestVariedMetadata, self).setUp()
+ self.course = CourseFactory.create()
+ with self.store.bulk_operations(self.course.id):
+ self.chapter = ItemFactory.create(
+ parent=self.course,
+ category="chapter",
+ display_name="Test Chapter"
+ )
+ self.sequence = ItemFactory.create(
+ parent=self.chapter,
+ category='sequential',
+ display_name="Test Sequential 1",
+ graded=True
+ )
+ self.vertical = ItemFactory.create(
+ parent=self.sequence,
+ category='vertical',
+ display_name='Test Vertical 1'
+ )
+ self.problem_xml = u'''
+
+
+
+
+
+
+ '''
+ self.request = get_mock_request(UserFactory())
+ self.client.login(username=self.request.user.username, password="test")
+ CourseEnrollment.enroll(self.request.user, self.course.id)
+
+ def _get_altered_metadata(self, alterations):
+ """
+ Returns a copy of the default_problem_metadata dict updated with the
+ specified alterations.
+ """
+ metadata = self.default_problem_metadata.copy()
+ metadata.update(alterations)
+ return metadata
+
+ def _add_problem_with_alterations(self, alterations):
+ """
+ Add a problem to the course with the specified metadata alterations.
+ """
+
+ metadata = self._get_altered_metadata(alterations)
+ ItemFactory.create(
+ parent=self.vertical,
+ category="problem",
+ display_name="problem",
+ data=self.problem_xml,
+ metadata=metadata,
+ )
+
+ def _get_score(self):
+ """
+ Return the score of the test problem when one correct problem (out of
+ two) is submitted.
+ """
+
+ self.submit_question_answer(u'problem', {u'2_1': u'Correct'})
+ course_structure = get_course_blocks(self.request.user, self.course.location)
+ subsection_factory = SubsectionGradeFactory(
+ self.request.user,
+ course_structure=course_structure,
+ course=self.course,
+ )
+ return subsection_factory.create(self.sequence)
+
+ @ddt.data(
+ ({}, 1.25, 2.5),
+ ({u'weight': 27}, 13.5, 27),
+ ({u'weight': 1.0}, 0.5, 1.0),
+ ({u'weight': 0.0}, 0.0, 0.0),
+ ({u'weight': None}, 1.0, 2.0),
+ )
+ @ddt.unpack
+ def test_weight_metadata_alterations(self, alterations, expected_earned, expected_possible):
+ self._add_problem_with_alterations(alterations)
+ score = self._get_score()
+ self.assertEqual(score.all_total.earned, expected_earned)
+ self.assertEqual(score.all_total.possible, expected_possible)
+
+ @ddt.data(
+ ({u'graded': True}, 1.25, 2.5),
+ ({u'graded': False}, 0.0, 0.0),
+ )
+ @ddt.unpack
+ def test_graded_metadata_alterations(self, alterations, expected_earned, expected_possible):
+ self._add_problem_with_alterations(alterations)
+ score = self._get_score()
+ self.assertEqual(score.graded_total.earned, expected_earned)
+ self.assertEqual(score.graded_total.possible, expected_possible)
+
+
+@ddt.ddt
+class TestWeightedProblems(SharedModuleStoreTestCase):
+ """
+ Test scores and grades with various problem weight values.
+ """
+ @classmethod
+ def setUpClass(cls):
+ super(TestWeightedProblems, cls).setUpClass()
+ cls.course = CourseFactory.create()
+ with cls.store.bulk_operations(cls.course.id):
+ cls.chapter = ItemFactory.create(parent=cls.course, category="chapter", display_name="chapter")
+ cls.sequential = ItemFactory.create(parent=cls.chapter, category="sequential", display_name="sequential")
+ cls.vertical = ItemFactory.create(parent=cls.sequential, category="vertical", display_name="vertical1")
+ problem_xml = cls._create_problem_xml()
+ cls.problems = []
+ for i in range(2):
+ cls.problems.append(
+ ItemFactory.create(
+ parent=cls.vertical,
+ category="problem",
+ display_name="problem_{}".format(i),
+ data=problem_xml,
+ )
+ )
+
+ def setUp(self):
+ super(TestWeightedProblems, self).setUp()
+ self.user = UserFactory()
+ self.request = get_mock_request(self.user)
+
+ @classmethod
+ def _create_problem_xml(cls):
+ """
+ Creates and returns XML for a multiple choice response problem
+ """
+ return MultipleChoiceResponseXMLFactory().build_xml(
+ question_text='The correct answer is Choice 3',
+ choices=[False, False, True, False],
+ choice_names=['choice_0', 'choice_1', 'choice_2', 'choice_3']
+ )
+
+ def _verify_grades(self, raw_earned, raw_possible, weight, expected_score):
+ """
+ Verifies the computed grades are as expected.
+ """
+ with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
+ # pylint: disable=no-member
+ for problem in self.problems:
+ problem.weight = weight
+ self.store.update_item(problem, self.user.id)
+ self.store.publish(self.course.location, self.user.id)
+
+ course_structure = get_course_blocks(self.request.user, self.course.location)
+
+ # answer all problems
+ for problem in self.problems:
+ answer_problem(self.course, self.request, problem, score=raw_earned, max_value=raw_possible)
+
+ # get grade
+ subsection_grade = SubsectionGradeFactory(
+ self.request.user, self.course, course_structure
+ ).update(self.sequential)
+
+ # verify all problem grades
+ for problem in self.problems:
+ problem_score = subsection_grade.problem_scores[problem.location]
+ self.assertEqual(type(expected_score.first_attempted), type(problem_score.first_attempted))
+ expected_score.first_attempted = problem_score.first_attempted
+ self.assertEquals(problem_score, expected_score)
+
+ # verify subsection grades
+ self.assertEquals(subsection_grade.all_total.earned, expected_score.earned * len(self.problems))
+ self.assertEquals(subsection_grade.all_total.possible, expected_score.possible * len(self.problems))
+
+ @ddt.data(
+ *itertools.product(
+ (0.0, 0.5, 1.0, 2.0), # raw_earned
+ (-2.0, -1.0, 0.0, 0.5, 1.0, 2.0), # raw_possible
+ (-2.0, -1.0, -0.5, 0.0, 0.5, 1.0, 2.0, 50.0, None), # weight
+ )
+ )
+ @ddt.unpack
+ def test_problem_weight(self, raw_earned, raw_possible, weight):
+
+ use_weight = weight is not None and raw_possible != 0
+ if use_weight:
+ expected_w_earned = raw_earned / raw_possible * weight
+ expected_w_possible = weight
+ else:
+ expected_w_earned = raw_earned
+ expected_w_possible = raw_possible
+
+ expected_graded = expected_w_possible > 0
+
+ expected_score = ProblemScore(
+ raw_earned=raw_earned,
+ raw_possible=raw_possible,
+ weighted_earned=expected_w_earned,
+ weighted_possible=expected_w_possible,
+ weight=weight,
+ graded=expected_graded,
+ first_attempted=datetime.datetime(2010, 1, 1),
+ )
+ self._verify_grades(raw_earned, raw_possible, weight, expected_score)
diff --git a/lms/djangoapps/grades/tests/test_subsection_grade.py b/lms/djangoapps/grades/tests/test_subsection_grade.py
new file mode 100644
index 0000000000..ba546bdee5
--- /dev/null
+++ b/lms/djangoapps/grades/tests/test_subsection_grade.py
@@ -0,0 +1,40 @@
+from ..models import PersistentSubsectionGrade
+from ..subsection_grade import SubsectionGrade
+from .utils import mock_get_score
+from .base import GradeTestBase
+
+
+class SubsectionGradeTest(GradeTestBase):
+ def test_save_and_load(self):
+ with mock_get_score(1, 2):
+ # Create a grade that *isn't* saved to the database
+ input_grade = SubsectionGrade(self.sequence)
+ input_grade.init_from_structure(
+ self.request.user,
+ self.course_structure,
+ self.subsection_grade_factory._submissions_scores,
+ self.subsection_grade_factory._csm_scores,
+ )
+ self.assertEqual(PersistentSubsectionGrade.objects.count(), 0)
+
+ # save to db, and verify object is in database
+ input_grade.create_model(self.request.user)
+ self.assertEqual(PersistentSubsectionGrade.objects.count(), 1)
+
+ # load from db, and ensure output matches input
+ loaded_grade = SubsectionGrade(self.sequence)
+ saved_model = PersistentSubsectionGrade.read_grade(
+ user_id=self.request.user.id,
+ usage_key=self.sequence.location,
+ )
+ loaded_grade.init_from_model(
+ self.request.user,
+ saved_model,
+ self.course_structure,
+ self.subsection_grade_factory._submissions_scores,
+ self.subsection_grade_factory._csm_scores,
+ )
+
+ self.assertEqual(input_grade.url_name, loaded_grade.url_name)
+ loaded_grade.all_total.first_attempted = input_grade.all_total.first_attempted = None
+ self.assertEqual(input_grade.all_total, loaded_grade.all_total)
diff --git a/lms/djangoapps/grades/tests/test_subsection_grade_factory.py b/lms/djangoapps/grades/tests/test_subsection_grade_factory.py
new file mode 100644
index 0000000000..eefef437ed
--- /dev/null
+++ b/lms/djangoapps/grades/tests/test_subsection_grade_factory.py
@@ -0,0 +1,101 @@
+import ddt
+from courseware.tests.test_submitting_problems import ProblemSubmissionTestMixin
+from django.conf import settings
+from lms.djangoapps.grades.config.tests.utils import persistent_grades_feature_flags
+from mock import patch
+
+from ..models import PersistentSubsectionGrade
+from ..subsection_grade_factory import ZeroSubsectionGrade
+from .base import GradeTestBase
+from .utils import mock_get_score
+
+
+@ddt.ddt
+class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase):
+ """
+ Tests for SubsectionGradeFactory functionality.
+
+ Ensures that SubsectionGrades are created and updated properly, that
+ persistent grades are functioning as expected, and that the flag to
+ enable saving subsection grades blocks/enables that feature as expected.
+ """
+
+ def assert_grade(self, grade, expected_earned, expected_possible):
+ """
+ Asserts that the given grade object has the expected score.
+ """
+ self.assertEqual(
+ (grade.all_total.earned, grade.all_total.possible),
+ (expected_earned, expected_possible),
+ )
+
+ def test_create_zero(self):
+ """
+ Test that a zero grade is returned.
+ """
+ grade = self.subsection_grade_factory.create(self.sequence)
+ self.assertIsInstance(grade, ZeroSubsectionGrade)
+ self.assert_grade(grade, 0.0, 1.0)
+
+ def test_update(self):
+ """
+ Assuming the underlying score reporting methods work,
+ test that the score is calculated properly.
+ """
+ with mock_get_score(1, 2):
+ grade = self.subsection_grade_factory.update(self.sequence)
+ self.assert_grade(grade, 1, 2)
+
+ def test_write_only_if_engaged(self):
+ """
+ Test that scores are not persisted when a learner has
+ never attempted a problem, but are persisted if the
+ learner's state has been deleted.
+ """
+ with mock_get_score(0, 0, None):
+ self.subsection_grade_factory.update(self.sequence)
+ # ensure no grades have been persisted
+ self.assertEqual(0, len(PersistentSubsectionGrade.objects.all()))
+
+ with mock_get_score(0, 0, None):
+ self.subsection_grade_factory.update(self.sequence, score_deleted=True)
+ # ensure a grade has been persisted
+ self.assertEqual(1, len(PersistentSubsectionGrade.objects.all()))
+
+ def test_update_if_higher(self):
+ def verify_update_if_higher(mock_score, expected_grade):
+ """
+ Updates the subsection grade and verifies the
+ resulting grade is as expected.
+ """
+ with mock_get_score(*mock_score):
+ grade = self.subsection_grade_factory.update(self.sequence, only_if_higher=True)
+ self.assert_grade(grade, *expected_grade)
+
+ verify_update_if_higher((1, 2), (1, 2)) # previous value was non-existent
+ verify_update_if_higher((2, 4), (2, 4)) # previous value was equivalent
+ verify_update_if_higher((1, 4), (2, 4)) # previous value was greater
+ verify_update_if_higher((3, 4), (3, 4)) # previous value was less
+
+ @patch.dict(settings.FEATURES, {'PERSISTENT_GRADES_ENABLED_FOR_ALL_TESTS': False})
+ @ddt.data(
+ (True, True),
+ (True, False),
+ (False, True),
+ (False, False),
+ )
+ @ddt.unpack
+ def test_subsection_grade_feature_gating(self, feature_flag, course_setting):
+ # Grades are only saved if the feature flag and the advanced setting are
+ # both set to True.
+ with patch(
+ 'lms.djangoapps.grades.models.PersistentSubsectionGrade.bulk_read_grades'
+ ) as mock_read_saved_grade:
+ with persistent_grades_feature_flags(
+ global_flag=feature_flag,
+ enabled_for_all_courses=False,
+ course_id=self.course.id,
+ enabled_for_course=course_setting
+ ):
+ self.subsection_grade_factory.create(self.sequence)
+ self.assertEqual(mock_read_saved_grade.called, feature_flag and course_setting)