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)