This commit migrates the data calculation logic for the GradeSummary table, which was previously in the frontend-app-learning. This commit also introduces a new visibility option for assignment scores: “Never show individual assessment results, but show overall assessment results after the due date.” With this option, learners cannot see question-level correctness or scores at any time. However, once the due date has passed, they can view their overall score in the total grades section on the Progress page. These two changes are coupled with each other because it compromises the integrity of this data to do the score hiding logic on the front end. The corresponding frontend PR is: openedx/frontend-app-learning#1797
504 lines
19 KiB
Python
504 lines
19 KiB
Python
"""
|
|
Grading tests
|
|
"""
|
|
|
|
|
|
import unittest
|
|
from datetime import datetime, timedelta
|
|
import pytest
|
|
import ddt
|
|
from pytz import UTC
|
|
|
|
from lms.djangoapps.grades.scores import compute_percent
|
|
from xmodule import graders
|
|
from xmodule.graders import AggregatedScore, ProblemScore, ShowCorrectness, aggregate_scores
|
|
|
|
|
|
class GradesheetTest(unittest.TestCase):
|
|
"""
|
|
Tests the aggregate_scores method
|
|
"""
|
|
|
|
def test_weighted_grading(self):
|
|
scores = []
|
|
agg_fields = dict(first_attempted=None)
|
|
prob_fields = dict(raw_earned=0, raw_possible=0, weight=0, first_attempted=None)
|
|
|
|
# No scores
|
|
all_total, graded_total = aggregate_scores(scores)
|
|
assert all_total == AggregatedScore(tw_earned=0, tw_possible=0, graded=False, **agg_fields)
|
|
assert graded_total == AggregatedScore(tw_earned=0, tw_possible=0, graded=True, **agg_fields)
|
|
|
|
# (0/5 non-graded)
|
|
scores.append(ProblemScore(weighted_earned=0, weighted_possible=5, graded=False, **prob_fields))
|
|
all_total, graded_total = aggregate_scores(scores)
|
|
assert all_total == AggregatedScore(tw_earned=0, tw_possible=5, graded=False, **agg_fields)
|
|
assert graded_total == AggregatedScore(tw_earned=0, tw_possible=0, graded=True, **agg_fields)
|
|
|
|
# (0/5 non-graded) + (3/5 graded) = 3/10 total, 3/5 graded
|
|
now = datetime.now()
|
|
prob_fields['first_attempted'] = now
|
|
agg_fields['first_attempted'] = now
|
|
scores.append(ProblemScore(weighted_earned=3, weighted_possible=5, graded=True, **prob_fields))
|
|
all_total, graded_total = aggregate_scores(scores)
|
|
assert all_total == AggregatedScore(tw_earned=3, tw_possible=10, graded=False, **agg_fields)
|
|
assert graded_total == AggregatedScore(tw_earned=3, tw_possible=5, graded=True, **agg_fields)
|
|
|
|
# (0/5 non-graded) + (3/5 graded) + (2/5 graded) = 5/15 total, 5/10 graded
|
|
scores.append(ProblemScore(weighted_earned=2, weighted_possible=5, graded=True, **prob_fields))
|
|
all_total, graded_total = aggregate_scores(scores)
|
|
assert all_total == AggregatedScore(tw_earned=5, tw_possible=15, graded=False, **agg_fields)
|
|
assert graded_total == AggregatedScore(tw_earned=5, tw_possible=10, graded=True, **agg_fields)
|
|
|
|
|
|
@ddt.ddt
|
|
class GraderTest(unittest.TestCase):
|
|
"""
|
|
Tests grader implementations
|
|
"""
|
|
|
|
empty_gradesheet = {
|
|
}
|
|
|
|
incomplete_gradesheet = {
|
|
'Homework': {},
|
|
'Lab': {},
|
|
'Midterm': {},
|
|
}
|
|
|
|
class MockGrade:
|
|
"""
|
|
Mock class for SubsectionGrade object.
|
|
"""
|
|
def __init__(self, graded_total, location, display_name):
|
|
self.graded_total = graded_total
|
|
self.display_name = display_name
|
|
self.location = location
|
|
|
|
@property
|
|
def percent_graded(self):
|
|
return compute_percent(self.graded_total.earned, self.graded_total.possible)
|
|
|
|
common_fields = dict(graded=True, first_attempted=datetime.now())
|
|
test_gradesheet = {
|
|
'Homework': {
|
|
'hw1': MockGrade(
|
|
AggregatedScore(tw_earned=2, tw_possible=20.0, **common_fields),
|
|
location='location_hw1_mock',
|
|
display_name='hw1'
|
|
),
|
|
'hw2': MockGrade(
|
|
AggregatedScore(tw_earned=16, tw_possible=16.0, **common_fields),
|
|
location='location_hw2_mock',
|
|
display_name='hw2'
|
|
),
|
|
},
|
|
|
|
# The dropped scores should be from the assignments that don't exist yet
|
|
'Lab': {
|
|
# Dropped
|
|
'lab1': MockGrade(
|
|
AggregatedScore(tw_earned=1, tw_possible=2.0, **common_fields),
|
|
location='location_lab1_mock',
|
|
display_name='lab1'
|
|
),
|
|
'lab2': MockGrade(
|
|
AggregatedScore(tw_earned=1, tw_possible=1.0, **common_fields),
|
|
location='location_lab2_mock',
|
|
display_name='lab2'
|
|
),
|
|
'lab3': MockGrade(
|
|
AggregatedScore(tw_earned=1, tw_possible=1.0, **common_fields),
|
|
location='location_lab3_mock',
|
|
display_name='lab3'
|
|
),
|
|
# Dropped
|
|
'lab4': MockGrade(
|
|
AggregatedScore(tw_earned=5, tw_possible=25.0, **common_fields),
|
|
location='location_lab4_mock',
|
|
display_name='lab4'
|
|
),
|
|
# Dropped
|
|
'lab5': MockGrade(
|
|
AggregatedScore(tw_earned=3, tw_possible=4.0, **common_fields),
|
|
location='location_lab5_mock',
|
|
display_name='lab5'
|
|
),
|
|
'lab6': MockGrade(
|
|
AggregatedScore(tw_earned=6, tw_possible=7.0, **common_fields),
|
|
location='location_lab6_mock',
|
|
display_name='lab6'
|
|
),
|
|
'lab7': MockGrade(
|
|
AggregatedScore(tw_earned=5, tw_possible=6.0, **common_fields),
|
|
location='location_lab7_mock',
|
|
display_name='lab7'
|
|
),
|
|
},
|
|
|
|
'Midterm': {
|
|
'midterm': MockGrade(
|
|
AggregatedScore(tw_earned=50.5, tw_possible=100, **common_fields),
|
|
location='location_midterm_mock',
|
|
display_name="Midterm Exam",
|
|
),
|
|
},
|
|
}
|
|
|
|
def test_assignment_format_grader(self):
|
|
homework_grader = graders.AssignmentFormatGrader("Homework", 12, 2)
|
|
no_drop_grader = graders.AssignmentFormatGrader("Homework", 12, 0)
|
|
# Even though the minimum number is 3, this should grade correctly when 7 assignments are found
|
|
overflow_grader = graders.AssignmentFormatGrader("Lab", 3, 2)
|
|
lab_grader = graders.AssignmentFormatGrader("Lab", 7, 3)
|
|
|
|
# Test the grading of an empty gradesheet
|
|
for graded in [
|
|
homework_grader.grade(self.empty_gradesheet),
|
|
no_drop_grader.grade(self.empty_gradesheet),
|
|
homework_grader.grade(self.incomplete_gradesheet),
|
|
no_drop_grader.grade(self.incomplete_gradesheet),
|
|
]:
|
|
assert round(graded['percent'] - 0.0, 7) >= 0
|
|
# Make sure the breakdown includes 12 sections, plus one summary
|
|
assert len(graded['section_breakdown']) == (12 + 1)
|
|
|
|
graded = homework_grader.grade(self.test_gradesheet)
|
|
assert round(graded['percent'] - 0.11, 7) >= 0
|
|
# 100% + 10% / 10 assignments
|
|
assert len(graded['section_breakdown']) == (12 + 1)
|
|
|
|
graded = no_drop_grader.grade(self.test_gradesheet)
|
|
assert round(graded['percent'] - 0.0916666666666666, 7) >= 0
|
|
# 100% + 10% / 12 assignments
|
|
assert len(graded['section_breakdown']) == (12 + 1)
|
|
|
|
graded = overflow_grader.grade(self.test_gradesheet)
|
|
assert round(graded['percent'] - 0.8879999999999999, 7) >= 0
|
|
# 100% + 10% / 5 assignments
|
|
assert len(graded['section_breakdown']) == (7 + 1)
|
|
|
|
graded = lab_grader.grade(self.test_gradesheet)
|
|
assert round(graded['percent'] - 0.9225, 7) >= 0
|
|
assert len(graded['section_breakdown']) == (7 + 1)
|
|
|
|
def test_assignment_format_grader_on_single_section_entry(self):
|
|
midterm_grader = graders.AssignmentFormatGrader("Midterm", 1, 0)
|
|
# Test the grading on a section with one item:
|
|
for graded in [
|
|
midterm_grader.grade(self.empty_gradesheet),
|
|
midterm_grader.grade(self.incomplete_gradesheet),
|
|
]:
|
|
assert round(graded['percent'] - 0.0, 7) >= 0
|
|
# Make sure the breakdown includes just the one summary
|
|
assert len(graded['section_breakdown']) == (0 + 1)
|
|
assert graded['section_breakdown'][0]['label'] == 'Midterm'
|
|
|
|
graded = midterm_grader.grade(self.test_gradesheet)
|
|
assert round(graded['percent'] - 0.5, 7) >= 0
|
|
assert len(graded['section_breakdown']) == (0 + 1)
|
|
|
|
def test_weighted_subsections_grader(self):
|
|
# First, a few sub graders
|
|
homework_grader = graders.AssignmentFormatGrader("Homework", 12, 2)
|
|
lab_grader = graders.AssignmentFormatGrader("Lab", 7, 3)
|
|
midterm_grader = graders.AssignmentFormatGrader("Midterm", 1, 0)
|
|
|
|
weighted_grader = graders.WeightedSubsectionsGrader([
|
|
(homework_grader, homework_grader.category, 0.25),
|
|
(lab_grader, lab_grader.category, 0.25),
|
|
(midterm_grader, midterm_grader.category, 0.5),
|
|
])
|
|
|
|
over_one_weights_grader = graders.WeightedSubsectionsGrader([
|
|
(homework_grader, homework_grader.category, 0.5),
|
|
(lab_grader, lab_grader.category, 0.5),
|
|
(midterm_grader, midterm_grader.category, 0.5),
|
|
])
|
|
|
|
# The midterm should have all weight on this one
|
|
zero_weights_grader = graders.WeightedSubsectionsGrader([
|
|
(homework_grader, homework_grader.category, 0.0),
|
|
(lab_grader, lab_grader.category, 0.0),
|
|
(midterm_grader, midterm_grader.category, 0.5),
|
|
])
|
|
|
|
# This should always have a final percent of zero
|
|
all_zero_weights_grader = graders.WeightedSubsectionsGrader([
|
|
(homework_grader, homework_grader.category, 0.0),
|
|
(lab_grader, lab_grader.category, 0.0),
|
|
(midterm_grader, midterm_grader.category, 0.0),
|
|
])
|
|
|
|
empty_grader = graders.WeightedSubsectionsGrader([])
|
|
|
|
graded = weighted_grader.grade(self.test_gradesheet)
|
|
assert round(graded['percent'] - 0.5081249999999999, 7) >= 0
|
|
assert len(graded['section_breakdown']) == (((12 + 1) + (7 + 1)) + 1)
|
|
assert len(graded['grade_breakdown']) == 3
|
|
|
|
graded = over_one_weights_grader.grade(self.test_gradesheet)
|
|
assert round(graded['percent'] - 0.76625, 7) >= 0
|
|
assert len(graded['section_breakdown']) == (((12 + 1) + (7 + 1)) + 1)
|
|
assert len(graded['grade_breakdown']) == 3
|
|
|
|
graded = zero_weights_grader.grade(self.test_gradesheet)
|
|
assert round(graded['percent'] - 0.25, 7) >= 0
|
|
assert len(graded['section_breakdown']) == (((12 + 1) + (7 + 1)) + 1)
|
|
assert len(graded['grade_breakdown']) == 3
|
|
|
|
graded = all_zero_weights_grader.grade(self.test_gradesheet)
|
|
assert round(graded['percent'] - 0.0, 7) >= 0
|
|
assert len(graded['section_breakdown']) == (((12 + 1) + (7 + 1)) + 1)
|
|
assert len(graded['grade_breakdown']) == 3
|
|
|
|
for graded in [
|
|
weighted_grader.grade(self.empty_gradesheet),
|
|
weighted_grader.grade(self.incomplete_gradesheet),
|
|
zero_weights_grader.grade(self.empty_gradesheet),
|
|
all_zero_weights_grader.grade(self.empty_gradesheet),
|
|
]:
|
|
assert round(graded['percent'] - 0.0, 7) >= 0
|
|
assert len(graded['section_breakdown']) == (((12 + 1) + (7 + 1)) + 1)
|
|
assert len(graded['grade_breakdown']) == 3
|
|
|
|
graded = empty_grader.grade(self.test_gradesheet)
|
|
assert round(graded['percent'] - 0.0, 7) >= 0
|
|
assert len(graded['section_breakdown']) == 0
|
|
assert len(graded['grade_breakdown']) == 0
|
|
|
|
def test_grade_with_string_min_count(self):
|
|
"""
|
|
Test that the grading succeeds in case the min_count is set to a string
|
|
"""
|
|
weighted_grader = graders.grader_from_conf([
|
|
{
|
|
'type': "Homework",
|
|
'min_count': '12',
|
|
'drop_count': 2,
|
|
'short_label': "HW",
|
|
'weight': 0.25,
|
|
},
|
|
{
|
|
'type': "Lab",
|
|
'min_count': '7',
|
|
'drop_count': 3,
|
|
'category': "Labs",
|
|
'weight': 0.25
|
|
},
|
|
{
|
|
'type': "Midterm",
|
|
'min_count': '0',
|
|
'drop_count': 0,
|
|
'name': "Midterm Exam",
|
|
'short_label': "Midterm",
|
|
'weight': 0.5,
|
|
},
|
|
])
|
|
|
|
graded = weighted_grader.grade(self.test_gradesheet)
|
|
assert round(graded['percent'] - 0.50812499999999994, 7) >= 0
|
|
assert len(graded['section_breakdown']) == (((12 + 1) + (7 + 1)) + 1)
|
|
assert len(graded['grade_breakdown']) == 3
|
|
|
|
def test_grader_from_conf(self):
|
|
|
|
# Confs always produce a graders.WeightedSubsectionsGrader, so we test this by repeating the test
|
|
# in test_graders.WeightedSubsectionsGrader, but generate the graders with confs.
|
|
|
|
weighted_grader = graders.grader_from_conf([
|
|
{
|
|
'type': "Homework",
|
|
'min_count': 12,
|
|
'drop_count': 2,
|
|
'short_label': "HW",
|
|
'weight': 0.25,
|
|
},
|
|
{
|
|
'type': "Lab",
|
|
'min_count': 7,
|
|
'drop_count': 3,
|
|
'category': "Labs",
|
|
'weight': 0.25
|
|
},
|
|
{
|
|
'type': "Midterm",
|
|
'min_count': 0,
|
|
'drop_count': 0,
|
|
'name': "Midterm Exam",
|
|
'short_label': "Midterm",
|
|
'weight': 0.5,
|
|
},
|
|
])
|
|
|
|
empty_grader = graders.grader_from_conf([])
|
|
|
|
graded = weighted_grader.grade(self.test_gradesheet)
|
|
assert round(graded['percent'] - 0.5081249999999999, 7) >= 0
|
|
assert len(graded['section_breakdown']) == (((12 + 1) + (7 + 1)) + 1)
|
|
assert len(graded['grade_breakdown']) == 3
|
|
|
|
graded = empty_grader.grade(self.test_gradesheet)
|
|
assert round(graded['percent'] - 0.0, 7) >= 0
|
|
assert len(graded['section_breakdown']) == 0
|
|
assert len(graded['grade_breakdown']) == 0
|
|
|
|
# Test that graders can also be used instead of lists of dictionaries
|
|
homework_grader = graders.AssignmentFormatGrader("Homework", 12, 2)
|
|
homework_grader2 = graders.grader_from_conf(homework_grader)
|
|
|
|
graded = homework_grader2.grade(self.test_gradesheet)
|
|
assert round(graded['percent'] - 0.11, 7) >= 0
|
|
assert len(graded['section_breakdown']) == (12 + 1)
|
|
|
|
@ddt.data(
|
|
(
|
|
# empty
|
|
{},
|
|
"Configuration has no appropriate grader class."
|
|
),
|
|
(
|
|
# no min_count
|
|
{'type': "Homework", 'drop_count': 0},
|
|
"Configuration has no appropriate grader class."
|
|
),
|
|
(
|
|
# no drop_count
|
|
{'type': "Homework", 'min_count': 0},
|
|
# pylint: disable=line-too-long
|
|
"__init__() missing 1 required positional argument: 'drop_count'"
|
|
),
|
|
)
|
|
@ddt.unpack
|
|
def test_grader_with_invalid_conf(self, invalid_conf, expected_error_message):
|
|
with pytest.raises(ValueError) as error:
|
|
graders.grader_from_conf([invalid_conf])
|
|
assert expected_error_message in str(error.value)
|
|
|
|
def test_sequential_location_in_section_breakdown(self):
|
|
homework_grader = graders.AssignmentFormatGrader("Homework", 12, 2)
|
|
lab_grader = graders.AssignmentFormatGrader("Lab", 7, 3)
|
|
midterm_grader = graders.AssignmentFormatGrader("Midterm", 1, 0)
|
|
|
|
weighted_grader = graders.WeightedSubsectionsGrader([
|
|
(homework_grader, homework_grader.category, 0.25),
|
|
(lab_grader, lab_grader.category, 0.25),
|
|
(midterm_grader, midterm_grader.category, 0.5),
|
|
])
|
|
|
|
expected_sequential_ids = [
|
|
'location_hw1_mock',
|
|
'location_hw2_mock',
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
'location_lab1_mock',
|
|
'location_lab2_mock',
|
|
'location_lab3_mock',
|
|
'location_lab4_mock',
|
|
'location_lab5_mock',
|
|
'location_lab6_mock',
|
|
'location_lab7_mock',
|
|
None,
|
|
'location_midterm_mock',
|
|
]
|
|
|
|
graded = weighted_grader.grade(self.test_gradesheet)
|
|
|
|
for i, section_breakdown in enumerate(graded['section_breakdown']):
|
|
assert expected_sequential_ids[i] == section_breakdown.get('sequential_id')
|
|
|
|
|
|
@ddt.ddt
|
|
class ShowCorrectnessTest(unittest.TestCase):
|
|
"""
|
|
Tests the correctness_available method
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
|
|
now = datetime.now(UTC)
|
|
day_delta = timedelta(days=1)
|
|
self.yesterday = now - day_delta
|
|
self.today = now
|
|
self.tomorrow = now + day_delta
|
|
|
|
def test_show_correctness_default(self):
|
|
"""
|
|
Test that correctness is visible by default.
|
|
"""
|
|
assert ShowCorrectness.correctness_available()
|
|
|
|
@ddt.data(
|
|
(ShowCorrectness.ALWAYS, True),
|
|
(ShowCorrectness.ALWAYS, False),
|
|
# Any non-constant values behave like "always"
|
|
('', True),
|
|
('', False),
|
|
('other-value', True),
|
|
('other-value', False),
|
|
)
|
|
@ddt.unpack
|
|
def test_show_correctness_always(self, show_correctness, has_staff_access):
|
|
"""
|
|
Test that correctness is visible when show_correctness is turned on.
|
|
"""
|
|
assert ShowCorrectness.correctness_available(show_correctness=show_correctness,
|
|
has_staff_access=has_staff_access)
|
|
|
|
@ddt.data(True, False)
|
|
def test_show_correctness_never(self, has_staff_access):
|
|
"""
|
|
Test that show_correctness="never" hides correctness from learners and course staff.
|
|
"""
|
|
assert not ShowCorrectness.correctness_available(show_correctness=ShowCorrectness.NEVER,
|
|
has_staff_access=has_staff_access)
|
|
|
|
@ddt.data(
|
|
# Correctness not visible to learners if due date in the future
|
|
('tomorrow', False, False),
|
|
# Correctness is visible to learners if due date in the past
|
|
('yesterday', False, True),
|
|
# Correctness is visible to learners if due date in the past (just)
|
|
('today', False, True),
|
|
# Correctness is visible to learners if there is no due date
|
|
(None, False, True),
|
|
# Correctness is visible to staff if due date in the future
|
|
('tomorrow', True, True),
|
|
# Correctness is visible to staff if due date in the past
|
|
('yesterday', True, True),
|
|
# Correctness is visible to staff if there is no due date
|
|
(None, True, True),
|
|
)
|
|
@ddt.unpack
|
|
def test_show_correctness_past_due(self, due_date_str, has_staff_access, expected_result):
|
|
"""
|
|
Test show_correctness="past_due" to ensure:
|
|
* correctness is always visible to course staff
|
|
* correctness is always visible to everyone if there is no due date
|
|
* correctness is visible to learners after the due date, when there is a due date.
|
|
"""
|
|
if due_date_str is None:
|
|
due_date = None
|
|
else:
|
|
due_date = getattr(self, due_date_str)
|
|
assert ShowCorrectness.correctness_available(ShowCorrectness.PAST_DUE, due_date, has_staff_access) ==\
|
|
expected_result
|
|
|
|
@ddt.data(True, False)
|
|
def test_show_correctness_never_but_include_grade(self, has_staff_access):
|
|
"""
|
|
Test that show_correctness="never_but_include_grade" hides correctness from learners and course staff.
|
|
"""
|
|
assert not ShowCorrectness.correctness_available(show_correctness=ShowCorrectness.NEVER_BUT_INCLUDE_GRADE,
|
|
has_staff_access=has_staff_access)
|