Commit e2600bc5 by Matjaz Gregoric Committed by GitHub

Merge pull request #96 from arbrandes/SOL-1988

[SOL-1988] Account for decoy items in score
parents 6b6574ee 88f08735
......@@ -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
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
-----------
......
......@@ -16,7 +16,7 @@ from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader
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
......@@ -456,9 +456,9 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
feedback_key = 'finish' if is_correct else 'start'
return [FeedbackMessage(self.data['feedback'][feedback_key], None)], set()
required_ids, placed_ids, correct_ids = self._get_item_raw_stats()
missing_ids = required_ids - placed_ids
misplaced_ids = placed_ids - correct_ids
items = self._get_item_raw_stats()
missing_ids = items.required - items.placed
misplaced_ids = items.placed - items.correctly_placed
feedback_msgs = []
......@@ -469,7 +469,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
feedback_msgs.append(FeedbackMessage(message, message_class))
_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(missing_ids, FeedbackMessages.not_placed, FeedbackMessages.MessageClasses.NOT_PLACED)
......@@ -740,31 +740,39 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
def _get_item_stats(self):
"""
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).
Returns a tuple representing the number of correctly placed 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):
"""
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:
tuple: (required_items, placed_items, correct_items)
* required_items - IDs of items that must be placed on the board
* placed_items - IDs of items actually placed on the board
* correct_items - IDs of items that were placed correctly
namedtuple: (required, placed, correctly_placed, decoy, decoy_in_bank)
* required - IDs of items that must be placed on the board
* placed - IDs of items actually placed on the board
* 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()
required_items = set(item_id for item_id in all_items if self._get_item_zones(int(item_id)) != [])
placed_items = set(item_id for item_id in all_items if item_id in item_state)
correct_items = set(item_id for item_id in placed_items if item_state[item_id]['correct'])
all_items = set(str(item['id']) for item in self.data['items'])
required = set(item_id for item_id in all_items if self._get_item_zones(int(item_id)) != [])
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):
"""
......
......@@ -78,3 +78,7 @@ class FeedbackMessages(object):
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"]
)
......@@ -20,7 +20,6 @@ from drag_and_drop_v2.default_data import (
ITEM_CORRECT_FEEDBACK, ITEM_INCORRECT_FEEDBACK, ITEM_NO_ZONE_FEEDBACK,
ITEM_ANY_ZONE_FEEDBACK, START_FEEDBACK, FINISH_FEEDBACK
)
from drag_and_drop_v2.utils import FeedbackMessages
from .test_base import BaseIntegrationTest
......@@ -478,36 +477,6 @@ class DefaultDataTestMixin(object):
return "<vertical_demo><drag-and-drop-v2/></vertical_demo>"
class DefaultAssessmentDataTestMixin(DefaultDataTestMixin):
"""
Provides a test scenario with default options in assessment mode.
"""
MAX_ATTEMPTS = 5
def _get_scenario_xml(self): # pylint: disable=no-self-use
return """
<vertical_demo><drag-and-drop-v2 mode='assessment' max_attempts='{max_attempts}'/></vertical_demo>
""".format(max_attempts=self.MAX_ATTEMPTS)
class AssessmentTestMixin(object):
"""
Provides helper methods for assessment tests
"""
@staticmethod
def _wait_until_enabled(element):
wait = WebDriverWait(element, 2)
wait.until(lambda e: e.is_displayed() and e.get_attribute('disabled') is None)
def click_submit(self):
submit_button = self._get_submit_button()
self._wait_until_enabled(submit_button)
submit_button.click()
self.wait_for_ajax()
@ddt
class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest):
"""
......@@ -577,151 +546,6 @@ class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseInt
self.interact_with_keyboard_help(use_keyboard=use_keyboard)
@ddt
class AssessmentInteractionTest(
DefaultAssessmentDataTestMixin, AssessmentTestMixin, InteractionTestBase, BaseIntegrationTest
):
"""
Testing interactions with Drag and Drop XBlock against default data in assessment mode.
All interactions are tested using mouse (action_key=None) and four different keyboard action keys.
If default data changes this will break.
"""
@data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_item_no_feedback_on_good_move(self, action_key):
self.parameterized_item_positive_feedback_on_good_move(
self.items_map, action_key=action_key, assessment_mode=True
)
@data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_item_no_feedback_on_bad_move(self, action_key):
self.parameterized_item_negative_feedback_on_bad_move(
self.items_map, self.all_zones, action_key=action_key, assessment_mode=True
)
@data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_move_items_between_zones(self, action_key):
self.parameterized_move_items_between_zones(
self.items_map, self.all_zones, action_key=action_key
)
@data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_final_feedback_and_reset(self, action_key):
self.parameterized_final_feedback_and_reset(
self.items_map, self.feedback, action_key=action_key, assessment_mode=True
)
@data(False, True)
def test_keyboard_help(self, use_keyboard):
self.interact_with_keyboard_help(use_keyboard=use_keyboard)
def test_submit_button_shown(self):
first_item_definition = self._get_items_with_zone(self.items_map).values()[0]
submit_button = self._get_submit_button()
self.assertTrue(submit_button.is_displayed())
self.assertEqual(submit_button.get_attribute('disabled'), 'true') # no items are placed
attempts_info = self._get_attempts_info()
expected_text = "You have used {num} of {max} attempts.".format(num=0, max=self.MAX_ATTEMPTS)
self.assertEqual(attempts_info.text, expected_text)
self.assertEqual(attempts_info.is_displayed(), self.MAX_ATTEMPTS > 0)
self.place_item(first_item_definition.item_id, first_item_definition.zone_ids[0], None)
self.assertEqual(submit_button.get_attribute('disabled'), None)
def test_misplaced_items_returned_to_bank(self):
"""
Test items placed to incorrect zones are returned to item bank after submitting solution
"""
correct_items = {0: TOP_ZONE_ID}
misplaced_items = {1: BOTTOM_ZONE_ID, 2: MIDDLE_ZONE_ID}
for item_id, zone_id in correct_items.iteritems():
self.place_item(item_id, zone_id)
for item_id, zone_id in misplaced_items.iteritems():
self.place_item(item_id, zone_id)
self.click_submit()
for item_id in correct_items:
self.assert_placed_item(item_id, TOP_ZONE_TITLE, assessment_mode=True)
for item_id in misplaced_items:
self.assert_reverted_item(item_id)
def test_max_attempts_reached_submit_and_reset_disabled(self):
"""
Test "Submit" and "Reset" buttons are disabled when no more attempts remaining
"""
self.place_item(0, TOP_ZONE_ID)
submit_button, reset_button = self._get_submit_button(), self._get_reset_button()
attempts_info = self._get_attempts_info()
for index in xrange(self.MAX_ATTEMPTS):
expected_text = "You have used {num} of {max} attempts.".format(num=index, max=self.MAX_ATTEMPTS)
self.assertEqual(attempts_info.text, expected_text) # precondition check
self.assertEqual(submit_button.get_attribute('disabled'), None)
self.assertEqual(reset_button.get_attribute('disabled'), None)
self.click_submit()
self.assertEqual(submit_button.get_attribute('disabled'), 'true')
self.assertEqual(reset_button.get_attribute('disabled'), 'true')
def test_do_attempt_feedback_is_updated(self):
"""
Test updating overall feedback after submitting solution in assessment mode
"""
# used keyboard mode to avoid bug/feature with selenium "selecting" everything instead of dragging an element
self.place_item(0, TOP_ZONE_ID, Keys.RETURN)
self.click_submit()
feedback_lines = [
"FEEDBACK",
FeedbackMessages.correctly_placed(1),
FeedbackMessages.not_placed(3),
START_FEEDBACK
]
expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback)
self.place_item(1, BOTTOM_ZONE_ID, Keys.RETURN)
self.click_submit()
feedback_lines = [
"FEEDBACK",
FeedbackMessages.correctly_placed(1),
FeedbackMessages.misplaced(1),
FeedbackMessages.not_placed(2),
FeedbackMessages.MISPLACED_ITEMS_RETURNED,
START_FEEDBACK
]
expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback)
# reach final attempt
for _ in xrange(self.MAX_ATTEMPTS-3):
self.click_submit()
self.place_item(1, MIDDLE_ZONE_ID, Keys.RETURN)
self.place_item(2, BOTTOM_ZONE_ID, Keys.RETURN)
self.place_item(3, TOP_ZONE_ID, Keys.RETURN)
self.click_submit()
feedback_lines = [
"FEEDBACK",
FeedbackMessages.correctly_placed(4),
FINISH_FEEDBACK,
FeedbackMessages.FINAL_ATTEMPT_TPL.format(score=1.0)
]
expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback)
class MultipleValidOptionsInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest):
items_map = {
......@@ -764,7 +588,7 @@ class EventsFiredTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegration
},
{
'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',
......
# Imports ###########################################################
from ddt import ddt, data
from mock import Mock, patch
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.common.keys import Keys
from workbench.runtime import WorkbenchRuntime
from xblockutils.resources import ResourceLoader
from drag_and_drop_v2.default_data import (
TOP_ZONE_ID, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID,
TOP_ZONE_TITLE, START_FEEDBACK, FINISH_FEEDBACK
)
from drag_and_drop_v2.utils import FeedbackMessages
from .test_base import BaseIntegrationTest
from .test_interaction import InteractionTestBase, DefaultDataTestMixin
# Globals ###########################################################
loader = ResourceLoader(__name__)
# Classes ###########################################################
class DefaultAssessmentDataTestMixin(DefaultDataTestMixin):
"""
Provides a test scenario with default options in assessment mode.
"""
MAX_ATTEMPTS = 5
def _get_scenario_xml(self): # pylint: disable=no-self-use
return """
<vertical_demo><drag-and-drop-v2 mode='assessment' max_attempts='{max_attempts}'/></vertical_demo>
""".format(max_attempts=self.MAX_ATTEMPTS)
class AssessmentTestMixin(object):
"""
Provides helper methods for assessment tests
"""
@staticmethod
def _wait_until_enabled(element):
wait = WebDriverWait(element, 2)
wait.until(lambda e: e.is_displayed() and e.get_attribute('disabled') is None)
def click_submit(self):
submit_button = self._get_submit_button()
self._wait_until_enabled(submit_button)
submit_button.click()
self.wait_for_ajax()
@ddt
class AssessmentInteractionTest(
DefaultAssessmentDataTestMixin, AssessmentTestMixin, InteractionTestBase, BaseIntegrationTest
):
"""
Testing interactions with Drag and Drop XBlock against default data in assessment mode.
All interactions are tested using mouse (action_key=None) and four different keyboard action keys.
If default data changes this will break.
"""
@data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_item_no_feedback_on_good_move(self, action_key):
self.parameterized_item_positive_feedback_on_good_move(
self.items_map, action_key=action_key, assessment_mode=True
)
@data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_item_no_feedback_on_bad_move(self, action_key):
self.parameterized_item_negative_feedback_on_bad_move(
self.items_map, self.all_zones, action_key=action_key, assessment_mode=True
)
@data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_move_items_between_zones(self, action_key):
self.parameterized_move_items_between_zones(
self.items_map, self.all_zones, action_key=action_key
)
@data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_final_feedback_and_reset(self, action_key):
self.parameterized_final_feedback_and_reset(
self.items_map, self.feedback, action_key=action_key, assessment_mode=True
)
@data(False, True)
def test_keyboard_help(self, use_keyboard):
self.interact_with_keyboard_help(use_keyboard=use_keyboard)
def test_submit_button_shown(self):
first_item_definition = self._get_items_with_zone(self.items_map).values()[0]
submit_button = self._get_submit_button()
self.assertTrue(submit_button.is_displayed())
self.assertEqual(submit_button.get_attribute('disabled'), 'true') # no items are placed
attempts_info = self._get_attempts_info()
expected_text = "You have used {num} of {max} attempts.".format(num=0, max=self.MAX_ATTEMPTS)
self.assertEqual(attempts_info.text, expected_text)
self.assertEqual(attempts_info.is_displayed(), self.MAX_ATTEMPTS > 0)
self.place_item(first_item_definition.item_id, first_item_definition.zone_ids[0], None)
self.assertEqual(submit_button.get_attribute('disabled'), None)
def test_misplaced_items_returned_to_bank(self):
"""
Test items placed to incorrect zones are returned to item bank after submitting solution
"""
correct_items = {0: TOP_ZONE_ID}
misplaced_items = {1: BOTTOM_ZONE_ID, 2: MIDDLE_ZONE_ID}
for item_id, zone_id in correct_items.iteritems():
self.place_item(item_id, zone_id)
for item_id, zone_id in misplaced_items.iteritems():
self.place_item(item_id, zone_id)
self.click_submit()
for item_id in correct_items:
self.assert_placed_item(item_id, TOP_ZONE_TITLE, assessment_mode=True)
for item_id in misplaced_items:
self.assert_reverted_item(item_id)
def test_max_attempts_reached_submit_and_reset_disabled(self):
"""
Test "Submit" and "Reset" buttons are disabled when no more attempts remaining
"""
self.place_item(0, TOP_ZONE_ID)
submit_button, reset_button = self._get_submit_button(), self._get_reset_button()
attempts_info = self._get_attempts_info()
for index in xrange(self.MAX_ATTEMPTS):
expected_text = "You have used {num} of {max} attempts.".format(num=index, max=self.MAX_ATTEMPTS)
self.assertEqual(attempts_info.text, expected_text) # precondition check
self.assertEqual(submit_button.get_attribute('disabled'), None)
self.assertEqual(reset_button.get_attribute('disabled'), None)
self.click_submit()
self.assertEqual(submit_button.get_attribute('disabled'), 'true')
self.assertEqual(reset_button.get_attribute('disabled'), 'true')
def test_do_attempt_feedback_is_updated(self):
"""
Test updating overall feedback after submitting solution in assessment mode
"""
# used keyboard mode to avoid bug/feature with selenium "selecting" everything instead of dragging an element
self.place_item(0, TOP_ZONE_ID, Keys.RETURN)
self.click_submit()
feedback_lines = [
"FEEDBACK",
FeedbackMessages.correctly_placed(1),
FeedbackMessages.not_placed(3),
START_FEEDBACK
]
expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback)
self.place_item(1, BOTTOM_ZONE_ID, Keys.RETURN)
self.click_submit()
feedback_lines = [
"FEEDBACK",
FeedbackMessages.correctly_placed(1),
FeedbackMessages.misplaced(1),
FeedbackMessages.not_placed(2),
FeedbackMessages.MISPLACED_ITEMS_RETURNED,
START_FEEDBACK
]
expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback)
# reach final attempt
for _ in xrange(self.MAX_ATTEMPTS-3):
self.click_submit()
self.place_item(1, MIDDLE_ZONE_ID, Keys.RETURN)
self.place_item(2, BOTTOM_ZONE_ID, Keys.RETURN)
self.place_item(3, TOP_ZONE_ID, Keys.RETURN)
self.click_submit()
feedback_lines = [
"FEEDBACK",
FeedbackMessages.correctly_placed(4),
FINISH_FEEDBACK,
FeedbackMessages.FINAL_ATTEMPT_TPL.format(score=1.0)
]
expected_feedback = "\n".join(feedback_lines)
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)
......@@ -119,7 +119,7 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
})
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, {
"val": 1, "zone": self.ZONE_2, "y_percent": "90%", "x_percent": "42%"
......@@ -446,7 +446,7 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
def _submit_partial_solution(self):
self._submit_solution({0: self.ZONE_1})
return 1.0 / 3.0
return 3.0 / 5.0
def _submit_incorrect_solution(self):
self._submit_solution({0: self.ZONE_2, 1: self.ZONE_1})
......@@ -518,9 +518,9 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
def test_do_attempt_keeps_highest_score(self):
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.assertEqual(self.block.grade, expected_score)
......@@ -528,7 +528,7 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
# make it a last attempt so we can check feedback
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={})
self.assertEqual(self.block.grade, expected_score)
......@@ -537,3 +537,25 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
FeedbackMessages.MessageClasses.PARTIAL_SOLUTION
)
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