Files
edx-platform/xmodule/tests/test_graders.py
Kyrylo Kireiev 777ba88218 feat: [AXM-2398] Add short label to assignment (#36970)
Rationale: The instructor may create short labels that are longer than
3 characters, and they can be hard to work with in the mobile UI. Thus,
on mobile, it was decided to add short labels to the api response by
getting them from section breakdown, which ensures they are consistent
with the labels the user sees in the Grading section.
2025-09-18 10:46:02 -04:00

496 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