Commit 7cbafb8f by Adolfo R. Brandes

[SOL-1988] Account for decoy items in score

In order to take into account the placement of decoy items, calculate a
student's score as:

score = ((correctly placed items) + (correctly unplaced decoys)) / (total items)
parent ecca8bf3
...@@ -151,6 +151,32 @@ You can leave all of the checkboxes unchecked in order to create a ...@@ -151,6 +151,32 @@ You can leave all of the checkboxes unchecked in order to create a
You can define an arbitrary number of drag items, each of which may You can define an arbitrary number of drag items, each of which may
be attached to any number of zones. be attached to any number of zones.
Scoring
-------
Student assessment scores for the Drag and Drop XBlock are calculated according
to the following formula:
score = (C + D) / T
Where *C* is the number of correctly placed regular items, *D* is the number of
decoy items that were correctly left unplaced, and *T* is the total number of
items available.
Example: consider a Drag and Drop instance configured with a total of four
items, of which three are regular items and one is a decoy. If a learner
places two of the normal items correctly and one incorrectly (`C = 2`), and
wrongly places the decoy item onto a drop zone (`D = 0`), that learner's score
will be `50%`, as given by:
score = (2 + 0) / 4
If the learner were to then move the decoy item back to the bank (`D = 1`) and
move the wrongly placed regular item to the correct dropzone (`C = 3`), their
score would be `100%`:
score = (3 + 1) / 4
Demo Course Demo Course
----------- -----------
......
...@@ -16,7 +16,7 @@ from xblock.fragment import Fragment ...@@ -16,7 +16,7 @@ from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader from xblockutils.resources import ResourceLoader
from xblockutils.settings import XBlockWithSettingsMixin, ThemableXBlockMixin from xblockutils.settings import XBlockWithSettingsMixin, ThemableXBlockMixin
from .utils import _, DummyTranslationService, FeedbackMessage, FeedbackMessages from .utils import _, DummyTranslationService, FeedbackMessage, FeedbackMessages, ItemStats
from .default_data import DEFAULT_DATA from .default_data import DEFAULT_DATA
...@@ -456,9 +456,9 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -456,9 +456,9 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
feedback_key = 'finish' if is_correct else 'start' feedback_key = 'finish' if is_correct else 'start'
return [FeedbackMessage(self.data['feedback'][feedback_key], None)], set() return [FeedbackMessage(self.data['feedback'][feedback_key], None)], set()
required_ids, placed_ids, correct_ids = self._get_item_raw_stats() items = self._get_item_raw_stats()
missing_ids = required_ids - placed_ids missing_ids = items.required - items.placed
misplaced_ids = placed_ids - correct_ids misplaced_ids = items.placed - items.correctly_placed
feedback_msgs = [] feedback_msgs = []
...@@ -469,7 +469,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -469,7 +469,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
feedback_msgs.append(FeedbackMessage(message, message_class)) feedback_msgs.append(FeedbackMessage(message, message_class))
_add_msg_if_exists( _add_msg_if_exists(
correct_ids, FeedbackMessages.correctly_placed, FeedbackMessages.MessageClasses.CORRECTLY_PLACED items.correctly_placed, FeedbackMessages.correctly_placed, FeedbackMessages.MessageClasses.CORRECTLY_PLACED
) )
_add_msg_if_exists(misplaced_ids, FeedbackMessages.misplaced, FeedbackMessages.MessageClasses.MISPLACED) _add_msg_if_exists(misplaced_ids, FeedbackMessages.misplaced, FeedbackMessages.MessageClasses.MISPLACED)
_add_msg_if_exists(missing_ids, FeedbackMessages.not_placed, FeedbackMessages.MessageClasses.NOT_PLACED) _add_msg_if_exists(missing_ids, FeedbackMessages.not_placed, FeedbackMessages.MessageClasses.NOT_PLACED)
...@@ -740,31 +740,39 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -740,31 +740,39 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
def _get_item_stats(self): def _get_item_stats(self):
""" """
Returns a tuple representing the number of correctly-placed items, Returns a tuple representing the number of correctly placed items,
and the total number of items that must be placed on the board (non-decoy items). and the total number of items required (including decoy items).
""" """
required_items, __, correct_items = self._get_item_raw_stats() items = self._get_item_raw_stats()
return len(correct_items), len(required_items) correct_count = len(items.correctly_placed) + len(items.decoy_in_bank)
total_count = len(items.required) + len(items.decoy)
return correct_count, total_count
def _get_item_raw_stats(self): def _get_item_raw_stats(self):
""" """
Returns a 3-tuple containing required, placed and correct items. Returns a named tuple containing required, decoy, placed, correctly
placed, and correctly unplaced decoy items.
Returns: Returns:
tuple: (required_items, placed_items, correct_items) namedtuple: (required, placed, correctly_placed, decoy, decoy_in_bank)
* required_items - IDs of items that must be placed on the board * required - IDs of items that must be placed on the board
* placed_items - IDs of items actually placed on the board * placed - IDs of items actually placed on the board
* correct_items - IDs of items that were placed correctly * correctly_placed - IDs of items that were placed correctly
* decoy - IDs of decoy items
* decoy_in_bank - IDs of decoy items that were unplaced
""" """
all_items = [str(item['id']) for item in self.data['items']]
item_state = self._get_item_state() item_state = self._get_item_state()
required_items = set(item_id for item_id in all_items if self._get_item_zones(int(item_id)) != []) all_items = set(str(item['id']) for item in self.data['items'])
placed_items = set(item_id for item_id in all_items if item_id in item_state) required = set(item_id for item_id in all_items if self._get_item_zones(int(item_id)) != [])
correct_items = set(item_id for item_id in placed_items if item_state[item_id]['correct']) placed = set(item_id for item_id in all_items if item_id in item_state)
correctly_placed = set(item_id for item_id in placed if item_state[item_id]['correct'])
decoy = all_items - required
decoy_in_bank = set(item_id for item_id in decoy if item_id not in item_state)
return required_items, placed_items, correct_items return ItemStats(required, placed, correctly_placed, decoy, decoy_in_bank)
def _get_grade(self): def _get_grade(self):
""" """
......
...@@ -78,3 +78,7 @@ class FeedbackMessages(object): ...@@ -78,3 +78,7 @@ class FeedbackMessages(object):
FeedbackMessage = namedtuple("FeedbackMessage", ["message", "message_class"]) # pylint: disable=invalid-name FeedbackMessage = namedtuple("FeedbackMessage", ["message", "message_class"]) # pylint: disable=invalid-name
ItemStats = namedtuple( # pylint: disable=invalid-name
'ItemStats',
["required", "placed", "correctly_placed", "decoy", "decoy_in_bank"]
)
...@@ -673,6 +673,26 @@ class AssessmentInteractionTest( ...@@ -673,6 +673,26 @@ class AssessmentInteractionTest(
expected_feedback = "\n".join(feedback_lines) expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback) self.assertEqual(self._get_feedback().text, expected_feedback)
def test_grade(self):
"""
Test grading after submitting solution in assessment mode
"""
mock = Mock()
context = patch.object(WorkbenchRuntime, 'publish', mock)
context.start()
self.addCleanup(context.stop)
self.publish = mock
self.place_item(0, TOP_ZONE_ID, Keys.RETURN) # Correctly placed item
self.place_item(1, BOTTOM_ZONE_ID, Keys.RETURN) # Incorrectly placed item
self.place_item(4, MIDDLE_ZONE_ID, Keys.RETURN) # Incorrectly placed decoy
self.click_submit()
events = self.publish.call_args_list
published_grade = next((event[0][2] for event in events if event[0][1] == 'grade'))
expected_grade = {'max_value': 1, 'value': (1.0 / 5.0)}
self.assertEqual(published_grade, expected_grade)
class MultipleValidOptionsInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest): class MultipleValidOptionsInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest):
...@@ -716,7 +736,7 @@ class EventsFiredTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegration ...@@ -716,7 +736,7 @@ class EventsFiredTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegration
}, },
{ {
'name': 'grade', 'name': 'grade',
'data': {'max_value': 1, 'value': (1.0 / 4)}, 'data': {'max_value': 1, 'value': (2.0 / 5)},
}, },
{ {
'name': 'edx.drag_and_drop_v2.item.dropped', 'name': 'edx.drag_and_drop_v2.item.dropped',
......
...@@ -119,7 +119,7 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture): ...@@ -119,7 +119,7 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
}) })
self.assertEqual(1, len(published_grades)) self.assertEqual(1, len(published_grades))
self.assertEqual({'value': 0.5, 'max_value': 1}, published_grades[-1]) self.assertEqual({'value': 0.75, 'max_value': 1}, published_grades[-1])
self.call_handler(self.DROP_ITEM_HANDLER, { self.call_handler(self.DROP_ITEM_HANDLER, {
"val": 1, "zone": self.ZONE_2, "y_percent": "90%", "x_percent": "42%" "val": 1, "zone": self.ZONE_2, "y_percent": "90%", "x_percent": "42%"
...@@ -446,7 +446,7 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase): ...@@ -446,7 +446,7 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
def _submit_partial_solution(self): def _submit_partial_solution(self):
self._submit_solution({0: self.ZONE_1}) self._submit_solution({0: self.ZONE_1})
return 1.0 / 3.0 return 3.0 / 5.0
def _submit_incorrect_solution(self): def _submit_incorrect_solution(self):
self._submit_solution({0: self.ZONE_2, 1: self.ZONE_1}) self._submit_solution({0: self.ZONE_2, 1: self.ZONE_1})
...@@ -518,9 +518,9 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase): ...@@ -518,9 +518,9 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
def test_do_attempt_keeps_highest_score(self): def test_do_attempt_keeps_highest_score(self):
self.assertFalse(self.block.completed) # precondition check self.assertFalse(self.block.completed) # precondition check
expected_score = 2.0 / 3.0 expected_score = 4.0 / 5.0
self._submit_solution({0: self.ZONE_1, 1: self.ZONE_2}) # partial solution, 0.66 score self._submit_solution({0: self.ZONE_1, 1: self.ZONE_2}) # partial solution, 0.8 score
self.call_handler(self.DO_ATTEMPT_HANDLER, data={}) self.call_handler(self.DO_ATTEMPT_HANDLER, data={})
self.assertEqual(self.block.grade, expected_score) self.assertEqual(self.block.grade, expected_score)
...@@ -528,7 +528,7 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase): ...@@ -528,7 +528,7 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
# make it a last attempt so we can check feedback # make it a last attempt so we can check feedback
self._set_final_attempt() self._set_final_attempt()
self._submit_solution({0: self.ZONE_1}) # partial solution, 0.33 score self._submit_solution({0: self.ZONE_1}) # partial solution, 0.6 score
res = self.call_handler(self.DO_ATTEMPT_HANDLER, data={}) res = self.call_handler(self.DO_ATTEMPT_HANDLER, data={})
self.assertEqual(self.block.grade, expected_score) self.assertEqual(self.block.grade, expected_score)
...@@ -537,3 +537,25 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase): ...@@ -537,3 +537,25 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
FeedbackMessages.MessageClasses.PARTIAL_SOLUTION FeedbackMessages.MessageClasses.PARTIAL_SOLUTION
) )
self.assertIn(expected_feedback, res[self.OVERALL_FEEDBACK_KEY]) self.assertIn(expected_feedback, res[self.OVERALL_FEEDBACK_KEY])
def test_do_attempt_check_score_with_decoy(self):
self.assertFalse(self.block.completed) # precondition check
expected_score = 4.0 / 5.0
self._submit_solution({
0: self.ZONE_1,
1: self.ZONE_2,
2: self.ZONE_2,
3: self.ZONE_1,
}) # incorrect solution, 0.8 score
self.call_handler(self.DO_ATTEMPT_HANDLER, data={})
self.assertEqual(self.block.grade, expected_score)
def test_do_attempt_zero_score_with_all_decoys(self):
self.assertFalse(self.block.completed) # precondition check
expected_score = 0
self._submit_solution({
3: self.ZONE_1,
4: self.ZONE_2,
}) # incorrect solution, 0 score
self.call_handler(self.DO_ATTEMPT_HANDLER, data={})
self.assertEqual(self.block.grade, expected_score)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment