diff --git a/drag_and_drop_v2/drag_and_drop_v2.py b/drag_and_drop_v2/drag_and_drop_v2.py index 08f35cbea..a2bcf5ccb 100644 --- a/drag_and_drop_v2/drag_and_drop_v2.py +++ b/drag_and_drop_v2/drag_and_drop_v2.py @@ -190,6 +190,7 @@ def items_without_answers(): return items return { + "mode": self.mode, "zones": self._get_zones(), # SDK doesn't supply url_name. "url_name": getattr(self, 'url_name', ''), @@ -337,12 +338,18 @@ def do_attempt(self, attempt, suffix=''): 'is_correct': is_correct, }) - return { - 'correct': is_correct, - 'finished': self._is_finished(), - 'overall_feedback': overall_feedback, - 'feedback': feedback - } + if self.mode == self.ASSESSMENT_MODE: + # In assessment mode we don't send any feedback on drop. + result = {} + else: + result = { + 'correct': is_correct, + 'finished': self._is_finished(), + 'overall_feedback': overall_feedback, + 'feedback': feedback + } + + return result @XBlock.json_handler def reset(self, data, suffix=''): diff --git a/drag_and_drop_v2/public/js/drag_and_drop.js b/drag_and_drop_v2/public/js/drag_and_drop.js index 43ad31e54..85ad5f371 100644 --- a/drag_and_drop_v2/public/js/drag_and_drop.js +++ b/drag_and_drop_v2/public/js/drag_and_drop.js @@ -1,4 +1,4 @@ -function DragNDropTemplates(url_name) { +function DragAndDropTemplates(configuration) { "use strict"; var h = virtualDom.h; // Set up a mock for gettext if it isn't available in the client runtime: @@ -107,13 +107,22 @@ function DragNDropTemplates(url_name) { var item_content = h('div', { innerHTML: item_content_html, className: "item-content" }); if (item.is_placed) { // Insert information about zone in which this item has been placed - var item_description_id = url_name + '-item-' + item.value + '-description'; + var item_description_id = configuration.url_name + '-item-' + item.value + '-description'; item_content.properties.attributes = { 'aria-describedby': item_description_id }; var zone_title = (zone.title || "Unknown Zone"); // This "Unknown" text should never be seen, so does not need i18n + var description_content; + if (configuration.mode === DragAndDropBlock.ASSESSMENT_MODE) { + // In assessment mode placed items will "stick" even when not in correct zone. + description_content = gettext('Placed in: {zone_title}').replace('{zone_title}', zone_title); + } else { + // In standard mode item is immediately returned back to the bank if dropped on a wrong zone, + // so all placed items are always in the correct zone. + description_content = gettext('Correctly placed in: {zone_title}').replace('{zone_title}', zone_title); + } var item_description = h( 'div', { id: item_description_id, className: 'sr' }, - gettext('Correctly placed in: ') + zone_title + description_content ); children.splice(1, 0, item_description); } @@ -283,14 +292,17 @@ function DragNDropTemplates(url_name) { ); }; - DragAndDropBlock.renderView = mainTemplate; - + return mainTemplate; } function DragAndDropBlock(runtime, element, configuration) { "use strict"; - DragNDropTemplates(configuration.url_name); + DragAndDropBlock.STANDARD_MODE = 'standard'; + DragAndDropBlock.ASSESSMENT_MODE = 'assessment'; + + var renderView = DragAndDropTemplates(configuration); + // Set up a mock for gettext if it isn't available in the client runtime: if (!window.gettext) { window.gettext = function gettext_stub(string) { return string; }; } @@ -747,17 +759,21 @@ function DragAndDropBlock(runtime, element, configuration) { $.post(url, JSON.stringify(data), 'json') .done(function(data){ - state.last_action_correct = data.correct; - if (data.correct) { - state.items[item_id].correct = true; - state.items[item_id].submitting_location = false; - } else { - delete state.items[item_id]; - } - state.feedback = data.feedback; - if (data.finished) { - state.finished = true; - state.overall_feedback = data.overall_feedback; + state.items[item_id].submitting_location = false; + // In standard mode we immediately return item to the bank if dropped on wrong zone. + // In assessment mode we leave it in the chosen zone until explicit answer submission. + if (configuration.mode === DragAndDropBlock.STANDARD_MODE) { + state.last_action_correct = data.correct; + if (data.correct) { + state.items[item_id].correct = true; + } else { + delete state.items[item_id]; + } + state.feedback = data.feedback; + if (data.finished) { + state.finished = true; + state.overall_feedback = data.overall_feedback; + } } applyState(); }) @@ -866,7 +882,7 @@ function DragAndDropBlock(runtime, element, configuration) { display_reset_button: Object.keys(state.items).length > 0, }; - return DragAndDropBlock.renderView(context); + return renderView(context); }; /** diff --git a/drag_and_drop_v2/translations/en/LC_MESSAGES/text.po b/drag_and_drop_v2/translations/en/LC_MESSAGES/text.po index 9b9d624c1..f34ec739d 100644 --- a/drag_and_drop_v2/translations/en/LC_MESSAGES/text.po +++ b/drag_and_drop_v2/translations/en/LC_MESSAGES/text.po @@ -408,6 +408,10 @@ msgstr "" msgid "ok" msgstr "" +#: public/js/drag_and_drop.js +msgid "Placed in: " +msgstr "" + #: public/js/drag_and_drop.js msgid "Correctly placed in: " msgstr "" diff --git a/drag_and_drop_v2/translations/eo/LC_MESSAGES/text.po b/drag_and_drop_v2/translations/eo/LC_MESSAGES/text.po index ad3a2b7ac..51b2e56dc 100644 --- a/drag_and_drop_v2/translations/eo/LC_MESSAGES/text.po +++ b/drag_and_drop_v2/translations/eo/LC_MESSAGES/text.po @@ -493,6 +493,10 @@ msgstr "Çänçél Ⱡ'σяєм ιρѕυ#" msgid "ok" msgstr "ök Ⱡ'σя#" +#: public/js/drag_and_drop.js +msgid "Placed in: " +msgstr "Pläçéd ïn: Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #" + #: public/js/drag_and_drop.js msgid "Correctly placed in: " msgstr "Çörréçtlý pläçéd ïn: Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #" diff --git a/tests/integration/test_interaction.py b/tests/integration/test_interaction.py index cb72a2dd5..a91b91fce 100644 --- a/tests/integration/test_interaction.py +++ b/tests/integration/test_interaction.py @@ -6,6 +6,7 @@ from selenium.common.exceptions import NoSuchElementException from selenium.webdriver import ActionChains from selenium.webdriver.common.keys import Keys +from selenium.webdriver.support.ui import WebDriverWait from workbench.runtime import WorkbenchRuntime from xblockutils.resources import ResourceLoader @@ -89,6 +90,19 @@ def _get_zone_position(self, zone_id): 'return $("div[data-uid=\'{zone_id}\']").prevAll(".zone").length'.format(zone_id=zone_id) ) + @staticmethod + def wait_until_ondrop_xhr_finished(elem): + """ + Waits until the XHR request triggered by dropping the item finishes loading. + """ + wait = WebDriverWait(elem, 2) + # While the XHR is in progress, a spinner icon is shown inside the item. + # When the spinner disappears, we can assume that the XHR request has finished. + wait.until( + lambda e: 'fa-spinner' not in e.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): if action_key is None: self.drag_item_to_zone(item_value, zone_id) @@ -117,7 +131,7 @@ def move_item_to_zone(self, item_value, zone_id, action_key): def assert_grabbed_item(self, item): self.assertEqual(item.get_attribute('aria-grabbed'), 'true') - def assert_placed_item(self, item_value, zone_title): + def assert_placed_item(self, item_value, zone_title, assessment_mode=False): item = self._get_placed_item_by_value(item_value) self.wait_until_visible(item) item_content = item.find_element_by_css_selector('.item-content') @@ -131,7 +145,10 @@ def assert_placed_item(self, item_value, zone_title): self.assertEqual(item.get_attribute('data-drag-disabled'), 'true') self.assertEqual(item_content.get_attribute('aria-describedby'), item_description_id) self.assertEqual(item_description.get_attribute('id'), item_description_id) - self.assertEqual(item_description.text, 'Correctly placed in: {}'.format(zone_title)) + if assessment_mode: + self.assertEqual(item_description.text, 'Placed in: {}'.format(zone_title)) + else: + self.assertEqual(item_description.text, 'Correctly placed in: {}'.format(zone_title)) def assert_reverted_item(self, item_value): item = self._get_item_by_value(item_value) @@ -152,16 +169,28 @@ def assert_reverted_item(self, item_value): else: self.fail('Reverted item should not have .sr description.') - def assert_decoy_items(self, items_map): + def place_decoy_items(self, items_map, action_key): + decoy_items = self._get_items_without_zone(items_map) + # Place decoy items into first available zone. + zone_id, zone_title = self.all_zones[0] + for definition in decoy_items.values(): + self.place_item(definition.item_id, zone_id, action_key) + self.assert_placed_item(definition.item_id, zone_title, assessment_mode=True) + + def assert_decoy_items(self, items_map, assessment_mode=False): decoy_items = self._get_items_without_zone(items_map) for item_key in decoy_items: item = self._get_item_by_value(item_key) - - self.assertEqual(item.get_attribute('class'), 'option fade') self.assertEqual(item.get_attribute('aria-grabbed'), 'false') self.assertEqual(item.get_attribute('data-drag-disabled'), 'true') - - def parameterized_item_positive_feedback_on_good_move(self, items_map, scroll_down=100, action_key=None): + if assessment_mode: + self.assertEqual(item.get_attribute('class'), 'option') + else: + self.assertEqual(item.get_attribute('class'), 'option fade') + + def parameterized_item_positive_feedback_on_good_move( + self, items_map, scroll_down=100, action_key=None, assessment_mode=False + ): popup = self._get_popup() feedback_popup_content = self._get_popup_content() @@ -170,11 +199,20 @@ def parameterized_item_positive_feedback_on_good_move(self, items_map, scroll_do for definition in self._get_items_with_zone(items_map).values(): self.place_item(definition.item_id, definition.zone_ids[0], action_key) - self.wait_until_html_in(definition.feedback_positive, feedback_popup_content) - self.assertEqual(popup.get_attribute('class'), 'popup') - self.assert_placed_item(definition.item_id, definition.zone_title) + self.wait_until_ondrop_xhr_finished(self._get_item_by_value(definition.item_id)) + self.assert_placed_item(definition.item_id, definition.zone_title, assessment_mode=assessment_mode) + feedback_popup_html = feedback_popup_content.get_attribute('innerHTML') + if assessment_mode: + self.assertEqual(feedback_popup_html, '') + self.assertFalse(popup.is_displayed()) + else: + self.assertEqual(feedback_popup_html, definition.feedback_positive) + self.assertEqual(popup.get_attribute('class'), 'popup') + self.assertTrue(popup.is_displayed()) - def parameterized_item_negative_feedback_on_bad_move(self, items_map, all_zones, scroll_down=100, action_key=None): + def parameterized_item_negative_feedback_on_bad_move( + self, items_map, all_zones, scroll_down=100, action_key=None, assessment_mode=False + ): popup = self._get_popup() feedback_popup_content = self._get_popup_content() @@ -182,15 +220,31 @@ def parameterized_item_negative_feedback_on_bad_move(self, items_map, all_zones, self.scroll_down(pixels=scroll_down) for definition in items_map.values(): - for zone in all_zones: - if zone in definition.zone_ids: - continue - self.place_item(definition.item_id, zone, action_key) - self.wait_until_html_in(definition.feedback_negative, feedback_popup_content) - self.assertEqual(popup.get_attribute('class'), 'popup popup-incorrect') - self.assert_reverted_item(definition.item_id) - - def parameterized_final_feedback_and_reset(self, items_map, feedback, scroll_down=100, action_key=None): + # Get first zone that is not correct for this item. + zone_id = None + zone_title = None + for z_id, z_title in all_zones: + if z_id not in definition.zone_ids: + zone_id = z_id + zone_title = z_title + break + if zone_id is not None: # Some items may be placed in any zone, ignore those. + self.place_item(definition.item_id, zone_id, action_key) + if assessment_mode: + self.wait_until_ondrop_xhr_finished(self._get_item_by_value(definition.item_id)) + feedback_popup_html = feedback_popup_content.get_attribute('innerHTML') + self.assertEqual(feedback_popup_html, '') + self.assertFalse(popup.is_displayed()) + self.assert_placed_item(definition.item_id, zone_title, assessment_mode=True) + else: + self.wait_until_html_in(definition.feedback_negative, feedback_popup_content) + self.assertEqual(popup.get_attribute('class'), 'popup popup-incorrect') + self.assertTrue(popup.is_displayed()) + self.assert_reverted_item(definition.item_id) + + def parameterized_final_feedback_and_reset( + self, items_map, feedback, scroll_down=100, action_key=None, assessment_mode=False + ): feedback_message = self._get_feedback_message() self.assertEqual(self.get_element_html(feedback_message), feedback['intro']) # precondition check @@ -206,12 +260,17 @@ def get_locations(): for item_key, definition in items.items(): self.place_item(definition.item_id, definition.zone_ids[0], action_key) - self.assert_placed_item(definition.item_id, definition.zone_title) + self.assert_placed_item(definition.item_id, definition.zone_title, assessment_mode=assessment_mode) - self.wait_until_html_in(feedback['final'], self._get_feedback_message()) + if assessment_mode: + # In assessment mode we also place decoy items onto the board, + # to make sure they are correctly reverted back to the bank on problem reset. + self.place_decoy_items(items_map, action_key) + else: + self.wait_until_html_in(feedback['final'], self._get_feedback_message()) # Check decoy items - self.assert_decoy_items(items_map) + self.assert_decoy_items(items_map, assessment_mode=assessment_mode) # Scroll "Reset problem" button into view to make sure Selenium can successfully click it self.scroll_down(pixels=scroll_down+150) @@ -298,7 +357,11 @@ class DefaultDataTestMixin(object): 4: ItemDefinition(4, [], None, "", ITEM_NO_ZONE_FEEDBACK), } - all_zones = [TOP_ZONE_ID, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID] + all_zones = [ + (TOP_ZONE_ID, TOP_ZONE_TITLE), + (MIDDLE_ZONE_ID, MIDDLE_ZONE_TITLE), + (BOTTOM_ZONE_ID, BOTTOM_ZONE_TITLE) + ] feedback = { "intro": START_FEEDBACK, @@ -309,21 +372,66 @@ def _get_scenario_xml(self): # pylint: disable=no-self-use return "" -class BasicInteractionTest(DefaultDataTestMixin, InteractionTestBase): +class DefaultAssessmentDataTestMixin(DefaultDataTestMixin): """ - Testing interactions with Drag and Drop XBlock against default data. If default data changes this will break. + Provides a test scenario with default options in assessment mode. """ - def test_item_positive_feedback_on_good_move(self): - self.parameterized_item_positive_feedback_on_good_move(self.items_map) + def _get_scenario_xml(self): # pylint: disable=no-self-use + return "" - def test_item_negative_feedback_on_bad_move(self): - self.parameterized_item_negative_feedback_on_bad_move(self.items_map, self.all_zones) - def test_final_feedback_and_reset(self): - self.parameterized_final_feedback_and_reset(self.items_map, self.feedback) +@ddt +class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest): + """ + Testing interactions with Drag and Drop XBlock against default data. + 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_positive_feedback_on_good_move(self, action_key): + self.parameterized_item_positive_feedback_on_good_move(self.items_map, action_key=action_key) - def test_keyboard_help(self): - self.interact_with_keyboard_help() + @data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m') + def test_item_negative_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) + + @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) + + @data(False, True) + def test_keyboard_help(self, use_keyboard): + self.interact_with_keyboard_help(use_keyboard=use_keyboard) + + +@ddt +class AssessmentInteractionTest(DefaultAssessmentDataTestMixin, 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_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) class MultipleValidOptionsInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest): @@ -417,32 +525,14 @@ def test_event(self, index, event): ) -@ddt -class KeyboardInteractionTest(BasicInteractionTest, BaseIntegrationTest): - @data(Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m') - def test_item_positive_feedback_on_good_move_with_keyboard(self, action_key): - self.parameterized_item_positive_feedback_on_good_move(self.items_map, action_key=action_key) - - @data(Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m') - def test_item_negative_feedback_on_bad_move_with_keyboard(self, action_key): - self.parameterized_item_negative_feedback_on_bad_move(self.items_map, self.all_zones, action_key=action_key) - - @data(Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m') - def test_final_feedback_and_reset_with_keyboard(self, action_key): - self.parameterized_final_feedback_and_reset(self.items_map, self.feedback, action_key=action_key) - - def test_keyboard_help(self): - self.interact_with_keyboard_help(use_keyboard=True) - - -class CustomDataInteractionTest(BasicInteractionTest, BaseIntegrationTest): +class CustomDataInteractionTest(StandardInteractionTest): items_map = { 0: ItemDefinition(0, ['zone-1'], "Zone 1", "Yes 1", "No 1"), 1: ItemDefinition(1, ['zone-2'], "Zone 2", "Yes 2", "No 2"), 2: ItemDefinition(2, [], None, "", "No Zone for this") } - all_zones = ['zone-1', 'zone-2'] + all_zones = [('zone-1', 'Zone 1'), ('zone-2', 'Zone 2')] feedback = { "intro": "Some Intro Feed", @@ -453,14 +543,14 @@ def _get_scenario_xml(self): return self._get_custom_scenario_xml("data/test_data.json") -class CustomHtmlDataInteractionTest(BasicInteractionTest, BaseIntegrationTest): +class CustomHtmlDataInteractionTest(StandardInteractionTest): items_map = { 0: ItemDefinition(0, ['zone-1'], 'Zone 1', "Yes 1", "No 1"), 1: ItemDefinition(1, ['zone-2'], 'Zone 2', "Yes 2", "No 2"), 2: ItemDefinition(2, [], None, "", "No Zone for X") } - all_zones = ['zone-1', 'zone-2'] + all_zones = [('zone-1', 'Zone 1'), ('zone-2', 'Zone 2')] feedback = { "intro": "Intro Feed", @@ -492,8 +582,8 @@ class MultipleBlocksDataInteraction(InteractionTestBase, BaseIntegrationTest): } all_zones = { - 'block1': ['zone-1', 'zone-2'], - 'block2': ['zone-51', 'zone-52'] + 'block1': [('zone-1', 'Zone 1'), ('zone-2', 'Zone 2')], + 'block2': [('zone-51', 'Zone 51'), ('zone-52', 'Zone 52')] } feedback = { diff --git a/tests/unit/data/html/config_out.json b/tests/unit/data/html/config_out.json index 13ea0f1fe..80f40880c 100644 --- a/tests/unit/data/html/config_out.json +++ b/tests/unit/data/html/config_out.json @@ -1,5 +1,6 @@ { "title": "DnDv2 XBlock with HTML instructions", + "mode": "standard", "show_title": false, "problem_text": "Solve this drag-and-drop problem.", "show_problem_header": false, diff --git a/tests/unit/data/old/config_out.json b/tests/unit/data/old/config_out.json index 3ef89fa3c..d2ef3efd1 100644 --- a/tests/unit/data/old/config_out.json +++ b/tests/unit/data/old/config_out.json @@ -1,5 +1,6 @@ { "title": "Drag and Drop", + "mode": "standard", "show_title": true, "problem_text": "", "show_problem_header": true, diff --git a/tests/unit/data/plain/config_out.json b/tests/unit/data/plain/config_out.json index 0afa35d41..6f60f442b 100644 --- a/tests/unit/data/plain/config_out.json +++ b/tests/unit/data/plain/config_out.json @@ -1,5 +1,6 @@ { "title": "DnDv2 XBlock with plain text instructions", + "mode": "standard", "show_title": true, "problem_text": "Can you solve this drag-and-drop problem?", "show_problem_header": true, diff --git a/tests/unit/test_advanced.py b/tests/unit/test_advanced.py index ef52190ef..4449fa345 100644 --- a/tests/unit/test_advanced.py +++ b/tests/unit/test_advanced.py @@ -5,6 +5,8 @@ from xblockutils.resources import ResourceLoader +from drag_and_drop_v2.drag_and_drop_v2 import DragAndDropBlock + from ..utils import make_block, TestCaseMixin @@ -90,6 +92,14 @@ def test_do_attempt_correct(self): "feedback": self.FEEDBACK[item_id]["correct"] }) + def test_do_attempt_in_assessment_mode(self): + self.block.mode = DragAndDropBlock.ASSESSMENT_MODE + item_id, zone_id = 0, self.ZONE_1 + data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"} + res = self.call_handler('do_attempt', data) + # In assessment mode, the do_attempt doesn't return any data. + self.assertEqual(res, {}) + def test_grading(self): published_grades = [] diff --git a/tests/unit/test_basics.py b/tests/unit/test_basics.py index 31a833e64..4191a1bac 100644 --- a/tests/unit/test_basics.py +++ b/tests/unit/test_basics.py @@ -30,6 +30,7 @@ def test_get_configuration(self): zones = config.pop("zones") items = config.pop("items") self.assertEqual(config, { + "mode": DragAndDropBlock.STANDARD_MODE, "display_zone_borders": False, "display_zone_labels": False, "title": "Drag and Drop",