Grades: Clean up tests
EDUCATOR-1404
This commit is contained in:
100
lms/djangoapps/grades/tests/base.py
Normal file
100
lms/djangoapps/grades/tests/base.py
Normal file
@@ -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)
|
||||
145
lms/djangoapps/grades/tests/test_course_grade.py
Normal file
145
lms/djangoapps/grades/tests/test_course_grade.py
Normal file
@@ -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)
|
||||
362
lms/djangoapps/grades/tests/test_course_grade_factory.py
Normal file
362
lms/djangoapps/grades/tests/test_course_grade_factory.py
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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'''
|
||||
<problem url_name="capa-optionresponse">
|
||||
<optionresponse>
|
||||
<optioninput options="('Correct', 'Incorrect')" correct="Correct"></optioninput>
|
||||
<optioninput options="('Correct', 'Incorrect')" correct="Correct"></optioninput>
|
||||
</optionresponse>
|
||||
</problem>
|
||||
'''
|
||||
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)
|
||||
306
lms/djangoapps/grades/tests/test_problems.py
Normal file
306
lms/djangoapps/grades/tests/test_problems.py
Normal file
@@ -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'''
|
||||
<problem url_name="capa-optionresponse">
|
||||
<optionresponse>
|
||||
<optioninput options="('Correct', 'Incorrect')" correct="Correct"></optioninput>
|
||||
<optioninput options="('Correct', 'Incorrect')" correct="Correct"></optioninput>
|
||||
</optionresponse>
|
||||
</problem>
|
||||
'''
|
||||
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)
|
||||
40
lms/djangoapps/grades/tests/test_subsection_grade.py
Normal file
40
lms/djangoapps/grades/tests/test_subsection_grade.py
Normal file
@@ -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)
|
||||
101
lms/djangoapps/grades/tests/test_subsection_grade_factory.py
Normal file
101
lms/djangoapps/grades/tests/test_subsection_grade_factory.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user