Commit 5f7d26cb by E. Kolpakov

Assessment mode: showing per-item feedback

* "Negative" item feedback is displayed for misplaced items when an attempt is submitted.
* Avoid showing item-related general feedback (correctly placed-misplaced-not placed) after problem reset.
* Disabled submit button while drop_item is in progress.
* Added notes and todos about implicit invocation order some methods expect.
* Item feedback popup only close when close button is hit or another item is dragged
* Translation fixes
parent 4d0d5001
...@@ -138,8 +138,8 @@ image. You can define custom success and error feedback for each item. In ...@@ -138,8 +138,8 @@ image. You can define custom success and error feedback for each item. In
standard mode, the feedback text is displayed in a popup after the learner drops standard mode, the feedback text is displayed in a popup after the learner drops
the item on a zone - the success feedback is shown if the item is dropped on a the item on a zone - the success feedback is shown if the item is dropped on a
correct zone, while the error feedback is shown when dropping the item on an correct zone, while the error feedback is shown when dropping the item on an
incorrect drop zone. In assessment mode, the success and error feedback texts incorrect drop zone. In assessment mode, the success feedback texts
are not used. are not used, while error feedback texts are shown when learner submits a solution.
You can select any number of zones for an item to belong to using You can select any number of zones for an item to belong to using
the checkboxes; all zones defined in the previous step are available. the checkboxes; all zones defined in the previous step are available.
......
...@@ -246,7 +246,6 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -246,7 +246,6 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
"target_img_description": self.target_img_description, "target_img_description": self.target_img_description,
"item_background_color": self.item_background_color or None, "item_background_color": self.item_background_color or None,
"item_text_color": self.item_text_color or None, "item_text_color": self.item_text_color or None,
"initial_feedback": self.data['feedback']['start'],
# final feedback (data.feedback.finish) is not included - it may give away answers. # final feedback (data.feedback.finish) is not included - it may give away answers.
} }
...@@ -392,17 +391,29 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -392,17 +391,29 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
self._validate_do_attempt() self._validate_do_attempt()
self.attempts += 1 self.attempts += 1
self._mark_complete_and_publish_grade() # must happen before _get_feedback # pylint: disable=fixme
# TODO: Refactor this method to "freeze" item_state and pass it to methods that need access to it.
# These implicit dependencies between methods exist because most of them use `item_state` or other
# fields, either as an "input" (i.e. read value) or as output (i.e. set value) or both. As a result,
# incorrect order of invocation causes issues:
self._mark_complete_and_publish_grade() # must happen before _get_feedback - sets grade
correct = self._is_answer_correct() # must happen before manipulating item_state - reads item_state
overall_feedback_msgs, misplaced_ids = self._get_feedback() overall_feedback_msgs, misplaced_ids = self._get_feedback(include_item_feedback=True)
misplaced_items = []
for item_id in misplaced_ids: for item_id in misplaced_ids:
del self.item_state[item_id] del self.item_state[item_id]
misplaced_items.append(self._get_item_definition(int(item_id)))
feedback_msgs = [FeedbackMessage(item['feedback']['incorrect'], None) for item in misplaced_items]
return { return {
'correct': correct,
'attempts': self.attempts, 'attempts': self.attempts,
'misplaced_items': list(misplaced_ids), 'misplaced_items': list(misplaced_ids),
'overall_feedback': self._present_overall_feedback(overall_feedback_msgs) 'feedback': self._present_feedback(feedback_msgs),
'overall_feedback': self._present_feedback(overall_feedback_msgs)
} }
@XBlock.json_handler @XBlock.json_handler
...@@ -487,7 +498,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -487,7 +498,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
self.i18n_service.gettext("Max number of attempts reached") self.i18n_service.gettext("Max number of attempts reached")
) )
def _get_feedback(self): def _get_feedback(self, include_item_feedback=False):
""" """
Builds overall feedback for both standard and assessment modes Builds overall feedback for both standard and assessment modes
""" """
...@@ -510,17 +521,15 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -510,17 +521,15 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
message = message_template(len(ids_list), self.i18n_service.ngettext) message = message_template(len(ids_list), self.i18n_service.ngettext)
feedback_msgs.append(FeedbackMessage(message, message_class)) feedback_msgs.append(FeedbackMessage(message, message_class))
if self.item_state or include_item_feedback:
_add_msg_if_exists( _add_msg_if_exists(
items.correctly_placed, 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)
if misplaced_ids and self.attempts_remain:
feedback_msgs.append(
FeedbackMessage(FeedbackMessages.MISPLACED_ITEMS_RETURNED, None)
)
if self.attempts_remain and (misplaced_ids or missing_ids): if self.attempts_remain and (misplaced_ids or missing_ids):
problem_feedback_message = self.data['feedback']['start'] problem_feedback_message = self.data['feedback']['start']
else: else:
...@@ -539,7 +548,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -539,7 +548,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
return feedback_msgs, misplaced_ids return feedback_msgs, misplaced_ids
@staticmethod @staticmethod
def _present_overall_feedback(feedback_messages): def _present_feedback(feedback_messages):
""" """
Transforms feedback messages into format expected by frontend code Transforms feedback messages into format expected by frontend code
""" """
...@@ -563,14 +572,14 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -563,14 +572,14 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
self._publish_item_dropped_event(item_attempt, is_correct) self._publish_item_dropped_event(item_attempt, is_correct)
item_feedback_key = 'correct' if is_correct else 'incorrect' item_feedback_key = 'correct' if is_correct else 'incorrect'
item_feedback = item['feedback'][item_feedback_key] item_feedback = FeedbackMessage(item['feedback'][item_feedback_key], None)
overall_feedback, __ = self._get_feedback() overall_feedback, __ = self._get_feedback()
return { return {
'correct': is_correct, 'correct': is_correct,
'finished': self._is_answer_correct(), 'finished': self._is_answer_correct(),
'overall_feedback': self._present_overall_feedback(overall_feedback), 'overall_feedback': self._present_feedback(overall_feedback),
'feedback': item_feedback 'feedback': self._present_feedback([item_feedback])
} }
def _drop_item_assessment(self, item_attempt): def _drop_item_assessment(self, item_attempt):
...@@ -612,6 +621,18 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -612,6 +621,18 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
""" """
Helper method to update `self.completed` and submit grade event if appropriate conditions met. Helper method to update `self.completed` and submit grade event if appropriate conditions met.
""" """
# pylint: disable=fixme
# TODO: (arguable) split this method into "clean" functions (with no side effects and implicit state)
# This method implicitly depends on self.item_state (via _is_answer_correct and _get_grade)
# and also updates self.grade if some conditions are met. As a result this method implies some order of
# invocation:
# * it should be called after learner-caused updates to self.item_state is applied
# * it should be called before self.item_state cleanup is applied (i.e. returning misplaced items to item bank)
# * it should be called before any method that depends on self.grade (i.e. self._get_feedback)
# Splitting it into a "clean" functions will allow to capture this implicit invocation order in caller method
# and help avoid bugs caused by invocation order violation in future.
# There's no going back from "completed" status to "incomplete" # There's no going back from "completed" status to "incomplete"
self.completed = self.completed or self._is_answer_correct() or not self.attempts_remain self.completed = self.completed or self._is_answer_correct() or not self.attempts_remain
grade = self._get_grade() grade = self._get_grade()
...@@ -694,7 +715,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -694,7 +715,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
'items': item_state, 'items': item_state,
'finished': is_finished, 'finished': is_finished,
'attempts': self.attempts, 'attempts': self.attempts,
'overall_feedback': self._present_overall_feedback(overall_feedback_msgs) 'overall_feedback': self._present_feedback(overall_feedback_msgs)
} }
def _get_item_state(self): def _get_item_state(self):
...@@ -794,8 +815,8 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -794,8 +815,8 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
""" """
Returns the student's grade for this block. Returns the student's grade for this block.
""" """
correct_count, required_count = self._get_item_stats() correct_count, total_count = self._get_item_stats()
return correct_count / float(required_count) * self.weight return correct_count / float(total_count) * self.weight
def _answer_correctness(self): def _answer_correctness(self):
""" """
...@@ -807,8 +828,8 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -807,8 +828,8 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
* Partial: Some items are at their correct place. * Partial: Some items are at their correct place.
* Incorrect: None items are at their correct place. * Incorrect: None items are at their correct place.
""" """
correct_count, required_count = self._get_item_stats() correct_count, total_count = self._get_item_stats()
if correct_count == required_count: if correct_count == total_count:
return self.SOLUTION_CORRECT return self.SOLUTION_CORRECT
elif correct_count == 0: elif correct_count == 0:
return self.SOLUTION_INCORRECT return self.SOLUTION_INCORRECT
......
...@@ -356,13 +356,11 @@ ...@@ -356,13 +356,11 @@
.xblock--drag-and-drop .popup .popup-content { .xblock--drag-and-drop .popup .popup-content {
color: #ffffff; color: #ffffff;
margin-left: 15px; margin: 35px 15px 15px 15px;
margin-top: 35px;
margin-bottom: 15px;
font-size: 14px; font-size: 14px;
} }
.xblock--drag-and-drop .popup .close { .xblock--drag-and-drop .popup .close-feedback-popup-button {
cursor: pointer; cursor: pointer;
float: right; float: right;
margin-right: 8px; margin-right: 8px;
...@@ -373,7 +371,7 @@ ...@@ -373,7 +371,7 @@
font-size: 18pt; font-size: 18pt;
} }
.xblock--drag-and-drop .popup .close:focus { .xblock--drag-and-drop .popup .close-feedback-popup-button:focus {
outline: 2px solid white; outline: 2px solid white;
} }
......
...@@ -499,6 +499,10 @@ msgstr "" ...@@ -499,6 +499,10 @@ msgstr ""
msgid "You have used {used} of {total} attempts." msgid "You have used {used} of {total} attempts."
msgstr "" msgstr ""
#: public/js/drag_and_drop.js
msgid "Some of your answers were not correct."
msgstr ""
#: public/js/drag_and_drop_edit.js #: public/js/drag_and_drop_edit.js
msgid "There was an error with your form." msgid "There was an error with your form."
msgstr "" msgstr ""
...@@ -511,12 +515,12 @@ msgstr "" ...@@ -511,12 +515,12 @@ msgstr ""
msgid "None" msgid "None"
msgstr "" msgstr ""
#: utils.py:18 #: public/js/drag_and_drop_edit.js
msgid "Final attempt was used, highest score is {score}" msgid "Close item feedback popup"
msgstr "" msgstr ""
#: utils.py:19 #: utils.py:18
msgid "Misplaced items were returned to item bank." msgid "Final attempt was used, highest score is {score}"
msgstr "" msgstr ""
#: utils.py:24 #: utils.py:24
...@@ -526,8 +530,8 @@ msgstr[0] "" ...@@ -526,8 +530,8 @@ msgstr[0] ""
msgstr[1] "" msgstr[1] ""
#: utils.py:32 #: utils.py:32
msgid "Misplaced {misplaced_count} item." msgid "Misplaced {misplaced_count} item. Misplaced item was returned to item bank."
msgid_plural "Misplaced {misplaced_count} items." msgid_plural "Misplaced {misplaced_count} items. Misplaced items were returned to item bank."
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
......
...@@ -585,6 +585,10 @@ msgstr "" ...@@ -585,6 +585,10 @@ msgstr ""
msgid "You have used {used} of {total} attempts." msgid "You have used {used} of {total} attempts."
msgstr "Ýöü hävé üséd {used} öf {total} ättémpts. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєт#" msgstr "Ýöü hävé üséd {used} öf {total} ättémpts. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєт#"
#: public/js/drag_and_drop.js
msgid "Some of your answers were not correct."
msgstr "Sömé öf ýöür änswérs wéré nöt cörréct. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєт#"
#: public/js/drag_and_drop_edit.js #: public/js/drag_and_drop_edit.js
msgid "There was an error with your form." msgid "There was an error with your form."
msgstr "" msgstr ""
...@@ -599,28 +603,28 @@ msgstr "" ...@@ -599,28 +603,28 @@ msgstr ""
msgid "None" msgid "None"
msgstr "Nöné Ⱡ'σяєм ι#" msgstr "Nöné Ⱡ'σяєм ι#"
#: utils.py:18 #: public/js/drag_and_drop_edit.js
msgid "Fïnäl ättémpt wäs üséd, hïghést sçöré ïs {score} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #" msgid "Close item feedback popup"
msgstr "" msgstr "Çlösé ïtém féédßäçk pöpüp Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#"
#: utils.py:19 #: utils.py:18
msgid "Mïspläçéd ïtéms wéré rétürnéd tö ïtém ßänk. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #" msgid "Final attempt was used, highest score is {score}"
msgstr "" msgstr "Fïnäl ättémpt wäs üséd, hïghést sçöré ïs {score} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #"
#: utils.py:24 #: utils.py:24
msgid "Çörréçtlý pläçéd {correct_count} ïtém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#" msgid "Correctly placed {correct_count} item."
msgid_plural "Çörréçtlý pläçéd {correct_count} ïtéms. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє#" msgid_plural "Correctly placed {correct_count} items."
msgstr[0] "" msgstr[0] "Çörréçtlý pläçéd {correct_count} ïtém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#"
msgstr[1] "" msgstr[1] "Çörréçtlý pläçéd {correct_count} ïtéms. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє#"
#: utils.py:32 #: utils.py:32
msgid "Mïspläçéd {misplaced_count} ïtém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт,#" msgid "Misplaced {misplaced_count} item. Misplaced item was returned to item bank."
msgid_plural "Mïspläçéd {misplaced_count} ïtéms. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #" msgid_plural "Misplaced {misplaced_count} items. Misplaced items were returned to item bank."
msgstr[0] "" msgstr[0] "Mïspläçéd {misplaced_count} ïtém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт,#"
msgstr[1] "" msgstr[1] "Mïspläçéd {misplaced_count} ïtéms. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #"
#: utils.py:40 #: utils.py:40
msgid "Dïd nöt pläçé {missing_count} réqüïréd ïtém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢#" msgid "Did not place {missing_count} required item."
msgid_plural "Dïd nöt pläçé {missing_count} réqüïréd ïtéms. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#" msgid_plural "Did not place {missing_count} required items."
msgstr[0] "" msgstr[0] "Dïd nöt pläçé {missing_count} réqüïréd ïtém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢#"
msgstr[1] "" msgstr[1] "Dïd nöt pläçé {missing_count} réqüïréd ïtéms. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#"
...@@ -42,7 +42,6 @@ class FeedbackMessages(object): ...@@ -42,7 +42,6 @@ class FeedbackMessages(object):
NOT_PLACED = INCORRECT_SOLUTION NOT_PLACED = INCORRECT_SOLUTION
FINAL_ATTEMPT_TPL = _('Final attempt was used, highest score is {score}') FINAL_ATTEMPT_TPL = _('Final attempt was used, highest score is {score}')
MISPLACED_ITEMS_RETURNED = _('Misplaced item(s) were returned to item bank.')
@staticmethod @staticmethod
def correctly_placed(number, ngettext=ngettext_fallback): def correctly_placed(number, ngettext=ngettext_fallback):
......
...@@ -214,6 +214,8 @@ class DefaultDataTestMixin(object): ...@@ -214,6 +214,8 @@ class DefaultDataTestMixin(object):
class InteractionTestBase(object): class InteractionTestBase(object):
POPUP_ERROR_CLASS = "popup-incorrect"
@classmethod @classmethod
def _get_items_with_zone(cls, items_map): def _get_items_with_zone(cls, items_map):
return { return {
...@@ -309,7 +311,7 @@ class InteractionTestBase(object): ...@@ -309,7 +311,7 @@ class InteractionTestBase(object):
u"Spinner should not be in {}".format(elem.get_attribute('innerHTML')) u"Spinner should not be in {}".format(elem.get_attribute('innerHTML'))
) )
def place_item(self, item_value, zone_id, action_key=None): def place_item(self, item_value, zone_id, action_key=None, wait=True):
""" """
Place item with ID of item_value into zone with ID of zone_id. Place item with ID of item_value into zone with ID of zone_id.
zone_id=None means place item back to the item bank. zone_id=None means place item back to the item bank.
...@@ -319,6 +321,7 @@ class InteractionTestBase(object): ...@@ -319,6 +321,7 @@ class InteractionTestBase(object):
self.drag_item_to_zone(item_value, zone_id) self.drag_item_to_zone(item_value, zone_id)
else: else:
self.move_item_to_zone(item_value, zone_id, action_key) self.move_item_to_zone(item_value, zone_id, action_key)
if wait:
self.wait_for_ajax() self.wait_for_ajax()
def drag_item_to_zone(self, item_value, zone_id): def drag_item_to_zone(self, item_value, zone_id):
...@@ -429,3 +432,12 @@ class InteractionTestBase(object): ...@@ -429,3 +432,12 @@ class InteractionTestBase(object):
""" Only needed if there are multiple blocks on the page. """ """ Only needed if there are multiple blocks on the page. """
self._page = self.browser.find_elements_by_css_selector(self.default_css_selector)[idx] self._page = self.browser.find_elements_by_css_selector(self.default_css_selector)[idx]
self.scroll_down(0) self.scroll_down(0)
def assert_popup_correct(self, popup):
self.assertNotIn(self.POPUP_ERROR_CLASS, popup.get_attribute('class'))
def assert_popup_incorrect(self, popup):
self.assertIn(self.POPUP_ERROR_CLASS, popup.get_attribute('class'))
def assert_button_enabled(self, submit_button, enabled=True):
self.assertEqual(submit_button.is_enabled(), enabled)
from ddt import ddt, data, unpack from ddt import data, ddt, unpack
from mock import Mock, patch from mock import Mock, patch
from workbench.runtime import WorkbenchRuntime from workbench.runtime import WorkbenchRuntime
from drag_and_drop_v2.default_data import TOP_ZONE_TITLE, TOP_ZONE_ID, ITEM_CORRECT_FEEDBACK from drag_and_drop_v2.default_data import (
TOP_ZONE_TITLE, TOP_ZONE_ID, MIDDLE_ZONE_TITLE, MIDDLE_ZONE_ID, ITEM_CORRECT_FEEDBACK, ITEM_INCORRECT_FEEDBACK
)
from tests.integration.test_base import BaseIntegrationTest, DefaultDataTestMixin, InteractionTestBase
from tests.integration.test_interaction import DefaultDataTestMixin, ParameterizedTestsMixin
from tests.integration.test_interaction_assessment import DefaultAssessmentDataTestMixin, AssessmentTestMixin
from .test_base import BaseIntegrationTest, DefaultDataTestMixin class BaseEventsTests(InteractionTestBase, BaseIntegrationTest):
from .test_interaction import ParameterizedTestsMixin def setUp(self):
from tests.integration.test_base import InteractionTestBase mock = Mock()
context = patch.object(WorkbenchRuntime, 'publish', mock)
context.start()
self.addCleanup(context.stop)
self.publish = mock
super(BaseEventsTests, self).setUp()
@ddt @ddt
class EventsFiredTest(DefaultDataTestMixin, ParameterizedTestsMixin, InteractionTestBase, BaseIntegrationTest): class EventsFiredTest(DefaultDataTestMixin, ParameterizedTestsMixin, BaseEventsTests):
""" """
Tests that the analytics events are fired and in the proper order. Tests that the analytics events are fired and in the proper order.
""" """
...@@ -54,14 +66,6 @@ class EventsFiredTest(DefaultDataTestMixin, ParameterizedTestsMixin, Interaction ...@@ -54,14 +66,6 @@ class EventsFiredTest(DefaultDataTestMixin, ParameterizedTestsMixin, Interaction
}, },
) )
def setUp(self):
mock = Mock()
context = patch.object(WorkbenchRuntime, 'publish', mock)
context.start()
self.addCleanup(context.stop)
self.publish = mock
super(EventsFiredTest, self).setUp()
def _get_scenario_xml(self): # pylint: disable=no-self-use def _get_scenario_xml(self): # pylint: disable=no-self-use
return "<vertical_demo><drag-and-drop-v2/></vertical_demo>" return "<vertical_demo><drag-and-drop-v2/></vertical_demo>"
...@@ -71,6 +75,68 @@ class EventsFiredTest(DefaultDataTestMixin, ParameterizedTestsMixin, Interaction ...@@ -71,6 +75,68 @@ class EventsFiredTest(DefaultDataTestMixin, ParameterizedTestsMixin, Interaction
self.parameterized_item_positive_feedback_on_good_move(self.items_map) self.parameterized_item_positive_feedback_on_good_move(self.items_map)
dummy, name, published_data = self.publish.call_args_list[index][0] dummy, name, published_data = self.publish.call_args_list[index][0]
self.assertEqual(name, event['name']) self.assertEqual(name, event['name'])
self.assertEqual( self.assertEqual(published_data, event['data'])
published_data, event['data']
@ddt
class AssessmentEventsFiredTest(
DefaultAssessmentDataTestMixin, AssessmentTestMixin, BaseEventsTests
):
scenarios = (
{
'name': 'edx.drag_and_drop_v2.loaded',
'data': {},
},
{
'name': 'edx.drag_and_drop_v2.item.picked_up',
'data': {'item_id': 0},
},
{
'name': 'edx.drag_and_drop_v2.item.dropped',
'data': {
'is_correct': False,
'item_id': 0,
'location': MIDDLE_ZONE_TITLE,
'location_id': MIDDLE_ZONE_ID,
},
},
{
'name': 'edx.drag_and_drop_v2.item.picked_up',
'data': {'item_id': 1},
},
{
'name': 'edx.drag_and_drop_v2.item.dropped',
'data': {
'is_correct': False,
'item_id': 1,
'location': TOP_ZONE_TITLE,
'location_id': TOP_ZONE_ID,
},
},
{
'name': 'grade',
'data': {'max_value': 1, 'value': (1.0 / 5)},
},
{
'name': 'edx.drag_and_drop_v2.feedback.opened',
'data': {
'content': "\n".join([ITEM_INCORRECT_FEEDBACK, ITEM_INCORRECT_FEEDBACK]),
'truncated': False,
},
},
) )
def test_event(self):
self.scroll_down(pixels=100)
self.place_item(0, MIDDLE_ZONE_ID)
self.wait_until_ondrop_xhr_finished(self._get_item_by_value(0))
self.place_item(1, TOP_ZONE_ID)
self.wait_until_ondrop_xhr_finished(self._get_item_by_value(0))
self.click_submit()
self.wait_for_ajax()
for index, event in enumerate(self.scenarios):
dummy, name, published_data = self.publish.call_args_list[index][0]
self.assertEqual(name, event['name'])
self.assertEqual(published_data, event['data'])
...@@ -42,8 +42,8 @@ class ParameterizedTestsMixin(object): ...@@ -42,8 +42,8 @@ class ParameterizedTestsMixin(object):
self.assertEqual(feedback_popup_html, '') self.assertEqual(feedback_popup_html, '')
self.assertFalse(popup.is_displayed()) self.assertFalse(popup.is_displayed())
else: else:
self.assertEqual(feedback_popup_html, definition.feedback_positive) self.assertEqual(feedback_popup_html, "<p>{}</p>".format(definition.feedback_positive))
self.assertEqual(popup.get_attribute('class'), 'popup') self.assert_popup_correct(popup)
self.assertTrue(popup.is_displayed()) self.assertTrue(popup.is_displayed())
def parameterized_item_negative_feedback_on_bad_move( def parameterized_item_negative_feedback_on_bad_move(
...@@ -74,7 +74,7 @@ class ParameterizedTestsMixin(object): ...@@ -74,7 +74,7 @@ class ParameterizedTestsMixin(object):
self.assert_placed_item(definition.item_id, zone_title, assessment_mode=True) self.assert_placed_item(definition.item_id, zone_title, assessment_mode=True)
else: else:
self.wait_until_html_in(definition.feedback_negative, feedback_popup_content) self.wait_until_html_in(definition.feedback_negative, feedback_popup_content)
self.assertEqual(popup.get_attribute('class'), 'popup popup-incorrect') self.assert_popup_incorrect(popup)
self.assertTrue(popup.is_displayed()) self.assertTrue(popup.is_displayed())
self.assert_reverted_item(definition.item_id) self.assert_reverted_item(definition.item_id)
...@@ -288,7 +288,7 @@ class MultipleValidOptionsInteractionTest(DefaultDataTestMixin, InteractionTestB ...@@ -288,7 +288,7 @@ class MultipleValidOptionsInteractionTest(DefaultDataTestMixin, InteractionTestB
for i, zone in enumerate(item.zone_ids): for i, zone in enumerate(item.zone_ids):
self.place_item(item.item_id, zone, None) self.place_item(item.item_id, zone, None)
self.wait_until_html_in(item.feedback_positive[i], feedback_popup_content) self.wait_until_html_in(item.feedback_positive[i], feedback_popup_content)
self.assertEqual(popup.get_attribute('class'), 'popup') self.assert_popup_correct(popup)
self.assert_placed_item(item.item_id, item.zone_title[i]) self.assert_placed_item(item.item_id, item.zone_title[i])
reset.click() reset.click()
self.wait_until_disabled(reset) self.wait_until_disabled(reset)
...@@ -534,12 +534,15 @@ class TestMaxItemsPerZone(InteractionTestBase, BaseIntegrationTest): ...@@ -534,12 +534,15 @@ class TestMaxItemsPerZone(InteractionTestBase, BaseIntegrationTest):
self.assertTrue(feedback_popup.is_displayed()) self.assertTrue(feedback_popup.is_displayed())
feedback_popup_content = self._get_popup_content() feedback_popup_content = self._get_popup_content()
self.assertEqual( self.assertIn(
feedback_popup_content.get_attribute('innerHTML'), "You cannot add any more items to this zone.",
"You cannot add any more items to this zone." feedback_popup_content.get_attribute('innerHTML')
) )
def test_item_returned_to_bank_after_refresh(self): def test_item_returned_to_bank_after_refresh(self):
"""
Tests that an item returned to the bank stays there after page refresh
"""
zone_id = "Zone Left Align" zone_id = "Zone Left Align"
self.place_item(6, zone_id) self.place_item(6, zone_id)
self.place_item(7, zone_id) self.place_item(7, zone_id)
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
from ddt import ddt, data from ddt import ddt, data
from mock import Mock, patch from mock import Mock, patch
import time
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
...@@ -175,7 +176,6 @@ class AssessmentInteractionTest( ...@@ -175,7 +176,6 @@ class AssessmentInteractionTest(
FeedbackMessages.correctly_placed(1), FeedbackMessages.correctly_placed(1),
FeedbackMessages.misplaced(1), FeedbackMessages.misplaced(1),
FeedbackMessages.not_placed(2), FeedbackMessages.not_placed(2),
FeedbackMessages.MISPLACED_ITEMS_RETURNED,
START_FEEDBACK START_FEEDBACK
] ]
expected_feedback = "\n".join(feedback_lines) expected_feedback = "\n".join(feedback_lines)
...@@ -219,6 +219,44 @@ class AssessmentInteractionTest( ...@@ -219,6 +219,44 @@ class AssessmentInteractionTest(
expected_grade = {'max_value': 1, 'value': (1.0 / 5.0)} expected_grade = {'max_value': 1, 'value': (1.0 / 5.0)}
self.assertEqual(published_grade, expected_grade) self.assertEqual(published_grade, expected_grade)
def test_per_item_feedback_multiple_misplaced(self):
self.place_item(0, MIDDLE_ZONE_ID, Keys.RETURN)
self.place_item(1, BOTTOM_ZONE_ID, Keys.RETURN)
self.place_item(2, TOP_ZONE_ID, Keys.RETURN)
self.click_submit()
placed_item_definitions = [self.items_map[item_id] for item_id in (1, 2, 3)]
expected_message_elements = [
"<li>{msg}</li>".format(msg=definition.feedback_negative)
for definition in placed_item_definitions
]
for message_element in expected_message_elements:
self.assertIn(message_element, self._get_popup_content().get_attribute('innerHTML'))
def test_submit_disabled_during_drop_item(self):
def delayed_drop_item(item_attempt, suffix=''): # pylint: disable=unused-argument
# some delay to allow selenium check submit button disabled status while "drop_item"
# XHR is still executing
time.sleep(0.1)
return {}
self.place_item(0, TOP_ZONE_ID)
self.assert_placed_item(0, TOP_ZONE_TITLE, assessment_mode=True)
submit_button = self._get_submit_button()
self.assert_button_enabled(submit_button) # precondition check
with patch('drag_and_drop_v2.DragAndDropBlock._drop_item_assessment', Mock(side_effect=delayed_drop_item)):
item_id = 1
self.place_item(item_id, MIDDLE_ZONE_ID, wait=False)
# do not wait for XHR to complete
self.assert_button_enabled(submit_button, enabled=False)
self.wait_until_ondrop_xhr_finished(self._get_placed_item_by_value(item_id))
self.assert_button_enabled(submit_button, enabled=True)
class TestMaxItemsPerZoneAssessment(TestMaxItemsPerZone): class TestMaxItemsPerZoneAssessment(TestMaxItemsPerZone):
assessment_mode = True assessment_mode = True
...@@ -228,6 +266,9 @@ class TestMaxItemsPerZoneAssessment(TestMaxItemsPerZone): ...@@ -228,6 +266,9 @@ class TestMaxItemsPerZoneAssessment(TestMaxItemsPerZone):
return self._make_scenario_xml(data=scenario_data, max_items_per_zone=2, mode=Constants.ASSESSMENT_MODE) return self._make_scenario_xml(data=scenario_data, max_items_per_zone=2, mode=Constants.ASSESSMENT_MODE)
def test_drop_item_to_same_zone_does_not_show_popup(self): def test_drop_item_to_same_zone_does_not_show_popup(self):
"""
Tests that picking item from saturated zone and dropping it back again does not trigger error popup
"""
zone_id = "Zone Left Align" zone_id = "Zone Left Align"
self.place_item(6, zone_id) self.place_item(6, zone_id)
self.place_item(7, zone_id) self.place_item(7, zone_id)
......
...@@ -209,7 +209,7 @@ class TestDragAndDropRender(BaseIntegrationTest): ...@@ -209,7 +209,7 @@ class TestDragAndDropRender(BaseIntegrationTest):
popup_wrapper = self._get_popup_wrapper() popup_wrapper = self._get_popup_wrapper()
popup_content = self._get_popup_content() popup_content = self._get_popup_content()
self.assertFalse(popup.is_displayed()) self.assertFalse(popup.is_displayed())
self.assertEqual(popup.get_attribute('class'), 'popup') self.assertIn('popup', popup.get_attribute('class'))
self.assertEqual(popup_content.text, "") self.assertEqual(popup_content.text, "")
self.assertEqual(popup_wrapper.get_attribute('aria-live'), 'polite') self.assertEqual(popup_wrapper.get_attribute('aria-live'), 'polite')
......
...@@ -26,7 +26,7 @@ class TestDragAndDropTitleAndProblem(BaseIntegrationTest): ...@@ -26,7 +26,7 @@ class TestDragAndDropTitleAndProblem(BaseIntegrationTest):
self.addCleanup(scenarios.remove_scenario, const_page_id) self.addCleanup(scenarios.remove_scenario, const_page_id)
page = self.go_to_page(const_page_name) page = self.go_to_page(const_page_name)
is_problem_header_visible = len(page.find_elements_by_css_selector('section.problem > h3')) > 0 is_problem_header_visible = len(page.find_elements_by_css_selector('section.problem > h4')) > 0
self.assertEqual(is_problem_header_visible, show_problem_header) self.assertEqual(is_problem_header_visible, show_problem_header)
problem = page.find_element_by_css_selector('section.problem > p') problem = page.find_element_by_css_selector('section.problem > p')
...@@ -52,8 +52,8 @@ class TestDragAndDropTitleAndProblem(BaseIntegrationTest): ...@@ -52,8 +52,8 @@ class TestDragAndDropTitleAndProblem(BaseIntegrationTest):
page = self.go_to_page(const_page_name) page = self.go_to_page(const_page_name)
if show_title: if show_title:
problem_header = page.find_element_by_css_selector('h2.problem-title') problem_header = page.find_element_by_css_selector('h3.problem-title')
self.assertEqual(self.get_element_html(problem_header), display_name) self.assertEqual(self.get_element_html(problem_header), display_name)
else: else:
with self.assertRaises(NoSuchElementException): with self.assertRaises(NoSuchElementException):
page.find_element_by_css_selector('h2.problem-title') page.find_element_by_css_selector('h3.problem-title')
...@@ -9,7 +9,6 @@ ...@@ -9,7 +9,6 @@
"target_img_description": "This describes the target image", "target_img_description": "This describes the target image",
"item_background_color": null, "item_background_color": null,
"item_text_color": null, "item_text_color": null,
"initial_feedback": "This is the initial feedback.",
"display_zone_borders": false, "display_zone_borders": false,
"display_zone_labels": false, "display_zone_labels": false,
"url_name": "test", "url_name": "test",
......
...@@ -9,7 +9,6 @@ ...@@ -9,7 +9,6 @@
"target_img_description": "This describes the target image", "target_img_description": "This describes the target image",
"item_background_color": "white", "item_background_color": "white",
"item_text_color": "#000080", "item_text_color": "#000080",
"initial_feedback": "HTML <strong>Intro</strong> Feed",
"display_zone_borders": false, "display_zone_borders": false,
"display_zone_labels": false, "display_zone_labels": false,
"url_name": "unique_name", "url_name": "unique_name",
......
...@@ -9,7 +9,6 @@ ...@@ -9,7 +9,6 @@
"target_img_description": "This describes the target image", "target_img_description": "This describes the target image",
"item_background_color": null, "item_background_color": null,
"item_text_color": null, "item_text_color": null,
"initial_feedback": "Intro Feed",
"display_zone_borders": false, "display_zone_borders": false,
"display_zone_labels": false, "display_zone_labels": false,
"url_name": "", "url_name": "",
......
...@@ -9,7 +9,6 @@ ...@@ -9,7 +9,6 @@
"target_img_description": "This describes the target image", "target_img_description": "This describes the target image",
"item_background_color": null, "item_background_color": null,
"item_text_color": null, "item_text_color": null,
"initial_feedback": "This is the initial feedback.",
"display_zone_borders": false, "display_zone_borders": false,
"display_zone_labels": false, "display_zone_labels": false,
"url_name": "test", "url_name": "test",
......
...@@ -69,7 +69,6 @@ class BasicTests(TestCaseMixin, unittest.TestCase): ...@@ -69,7 +69,6 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
"target_img_description": TARGET_IMG_DESCRIPTION, "target_img_description": TARGET_IMG_DESCRIPTION,
"item_background_color": None, "item_background_color": None,
"item_text_color": None, "item_text_color": None,
"initial_feedback": START_FEEDBACK,
"url_name": "", "url_name": "",
}) })
self.assertEqual(zones, DEFAULT_DATA["zones"]) self.assertEqual(zones, DEFAULT_DATA["zones"])
......
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