diff --git a/README.md b/README.md index 421910c3d..6f9e3bc84 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,8 @@ There are two problem modes available: attempt to place an item, and the number of attempts is not limited. * **Assessment**: In this mode, the learner places all items on the board and then clicks a "Submit" button to get feedback. The number of attempts can be - limited. + limited. When all attempts are used, the learner can click a "Show Answer" + button to temporarily place items on their correct drop zones. ![Drop zone edit](/doc/img/edit-view-zones.png) diff --git a/drag_and_drop_v2/drag_and_drop_v2.py b/drag_and_drop_v2/drag_and_drop_v2.py index b7a4396f3..ce99aea6a 100644 --- a/drag_and_drop_v2/drag_and_drop_v2.py +++ b/drag_and_drop_v2/drag_and_drop_v2.py @@ -437,6 +437,28 @@ def reset(self, data, suffix=''): self.item_state = {} return self._get_user_state() + @XBlock.json_handler + def show_answer(self, data, suffix=''): + """ + Returns correct answer in assessment mode. + + Raises: + * JsonHandlerError with 400 error code in standard mode. + * JsonHandlerError with 409 error code if there are still attempts left + """ + if self.mode != Constants.ASSESSMENT_MODE: + raise JsonHandlerError( + 400, + self.i18n_service.gettext("show_answer handler should only be called for assessment mode") + ) + if self.attempts_remain: + raise JsonHandlerError( + 409, + self.i18n_service.gettext("There are attempts remaining") + ) + + return self._get_correct_state() + @XBlock.json_handler def expand_static_url(self, url, suffix=''): """ AJAX-accessible handler for expanding URLs to static [image] files """ @@ -527,7 +549,14 @@ def _add_msg_if_exists(ids_list, message_template, message_class): FeedbackMessages.correctly_placed, FeedbackMessages.MessageClasses.CORRECTLY_PLACED ) - _add_msg_if_exists(misplaced_ids, FeedbackMessages.misplaced, FeedbackMessages.MessageClasses.MISPLACED) + + # Misplaced items are not returned to the bank on the final attempt. + if self.attempts_remain: + misplaced_template = FeedbackMessages.misplaced_returned + else: + misplaced_template = FeedbackMessages.misplaced + + _add_msg_if_exists(misplaced_ids, misplaced_template, FeedbackMessages.MessageClasses.MISPLACED) _add_msg_if_exists(missing_ids, FeedbackMessages.not_placed, FeedbackMessages.MessageClasses.NOT_PLACED) if self.attempts_remain and (misplaced_ids or missing_ids): @@ -723,6 +752,31 @@ def _get_user_state(self): 'overall_feedback': self._present_feedback(overall_feedback_msgs) } + def _get_correct_state(self): + """ + Returns one of the possible correct states for the configured data. + """ + state = {} + items = copy.deepcopy(self.data.get('items', [])) + for item in items: + zones = item.get('zones') + + # For backwards compatibility + if zones is None: + zones = [] + zone = item.get('zone') + if zone is not None and zone != 'none': + zones.append(zone) + + if zones: + zone = zones.pop() + state[str(item['id'])] = { + 'zone': zone, + 'correct': True, + } + + return {'items': state} + def _get_item_state(self): """ Returns a copy of the user item state. @@ -855,4 +909,13 @@ def workbench_scenarios(): """ A canned scenario for display in the workbench. """ - return [("Drag-and-drop-v2 scenario", "")] + return [ + ( + "Drag-and-drop-v2 standard", + "" + ), + ( + "Drag-and-drop-v2 assessment", + "" + ), + ] diff --git a/drag_and_drop_v2/public/css/drag_and_drop.css b/drag_and_drop_v2/public/css/drag_and_drop.css index 72bcd31d4..38df7cc5e 100644 --- a/drag_and_drop_v2/public/css/drag_and_drop.css +++ b/drag_and_drop_v2/public/css/drag_and_drop.css @@ -568,6 +568,7 @@ .ltr .xblock--drag-and-drop .actions-toolbar .action-toolbar-item.sidebar-buttons { float: right; padding-right: -5px; + padding-top: 5px; } .rtl .xblock--drag-and-drop .actions-toolbar .action-toolbar-item.sidebar-buttons { @@ -623,10 +624,6 @@ display: block; } -.xblock--drag-and-drop .reset-button { - margin-top: 3px; -} - /*** ACTIONS TOOLBAR END ***/ /*** KEYBOARD HELP ***/ 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 24b6e2b5e..5930d7f0e 100644 --- a/drag_and_drop_v2/public/js/drag_and_drop.js +++ b/drag_and_drop_v2/public/js/drag_and_drop.js @@ -110,7 +110,7 @@ function DragAndDropTemplates(configuration) { if (item.is_placed) { 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) { + if (configuration.mode === DragAndDropBlock.ASSESSMENT_MODE && !ctx.showing_answer) { // 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 { @@ -180,9 +180,8 @@ function DragAndDropTemplates(configuration) { var zoneTemplate = function(zone, ctx) { var className = ctx.display_zone_labels ? 'zone-name' : 'zone-name sr'; var selector = ctx.display_zone_borders ? 'div.zone.zone-with-borders' : 'div.zone'; - // If zone is aligned, mark its item alignment - // and render its placed items as children - var item_wrapper = 'div.item-wrapper'; + // Mark item alignment and render its placed items as children + var item_wrapper = 'div.item-wrapper.item-align.item-align-' + zone.align; var is_item_in_zone = function(i) { return i.is_placed && (i.zone === zone.uid); }; var items_in_zone = $.grep(ctx.items, is_item_in_zone); var zone_description_id = 'zone-' + zone.uid + '-description'; @@ -199,12 +198,7 @@ function DragAndDropTemplates(configuration) { gettext('Items placed here: ') + items_in_zone.map(function (item) { return item.displayName; }).join(", ") ); } - if (zone.align !== 'none') { - item_wrapper += '.item-align.item-align-' + zone.align; - //items_in_zone = $.grep(ctx.items, is_item_in_zone); - } else { - items_in_zone = []; - } + return ( h( selector, @@ -343,14 +337,21 @@ function DragAndDropTemplates(configuration) { ); }; - var sidebarButtonTemplate = function(buttonClass, iconClass, buttonText, disabled) { + var sidebarButtonTemplate = function(buttonClass, iconClass, buttonText, disabled, spinner) { + if (spinner) { + iconClass = 'fa-spin.fa-spinner'; + } return ( h('span.sidebar-button-wrapper', {}, [ h( 'button.unbutton.btn-default.btn-small.'+buttonClass, - {disabled: disabled || false, attributes: {tabindex: 0}}, + {disabled: disabled || spinner || false, attributes: {tabindex: 0}}, [ - h("span.btn-icon.fa."+iconClass, {attributes: {"aria-hidden": true}}, []), + h( + "span.btn-icon.fa." + iconClass, + {attributes: {"aria-hidden": true}}, + [] + ), buttonText ] ) @@ -359,10 +360,21 @@ function DragAndDropTemplates(configuration) { }; var sidebarTemplate = function(ctx) { + var showAnswerButton = null; + if (ctx.show_show_answer) { + showAnswerButton = sidebarButtonTemplate( + "show-answer-button", + "fa-info-circle", + gettext('Show Answer'), + ctx.showing_answer ? true : ctx.disable_show_answer_button, + ctx.show_answer_spinner + ); + } return( h("section.action-toolbar-item.sidebar-buttons", {}, [ sidebarButtonTemplate("keyboard-help-button", "fa-question", gettext('Keyboard Help')), sidebarButtonTemplate("reset-button", "fa-refresh", gettext('Reset'), ctx.disable_reset_button), + showAnswerButton, ]) ) }; @@ -434,9 +446,8 @@ function DragAndDropTemplates(configuration) { var mainTemplate = function(ctx) { var problemTitle = ctx.show_title ? h('h3.problem-title', {innerHTML: ctx.title_html}) : null; var problemHeader = ctx.show_problem_header ? h('h4.title1', gettext('Problem')) : null; - - // Render only items_in_bank and items_placed_unaligned here; - // items placed in aligned zones will be rendered by zoneTemplate. + // Render only items in the bank here, including placeholders. Placed + // items will be rendered by zoneTemplate. var is_item_placed = function(i) { return i.is_placed; }; var items_placed = $.grep(ctx.items, is_item_placed); var items_in_bank = $.grep(ctx.items, is_item_placed, true); @@ -561,6 +572,10 @@ function DragAndDropBlock(runtime, element, configuration) { $element.on('keydown', '.reset-button', function(evt) { runOnKey(evt, RET, resetProblem); }); + $element.on('click', '.show-answer-button', showAnswer); + $element.on('keydown', '.show-answer-button', function(evt) { + runOnKey(evt, RET, showAnswer); + }); // For the next one, we need to use addEventListener with useCapture 'true' in order // to watch for load events on any child element, since load events do not bubble. @@ -1098,6 +1113,26 @@ function DragAndDropBlock(runtime, element, configuration) { }); }; + var showAnswer = function(evt) { + evt.preventDefault(); + state.show_answer_spinner = true; + applyState(); + + $.ajax({ + type: 'POST', + url: runtime.handlerUrl(element, 'show_answer'), + data: '{}', + }).done(function(data) { + state.items = data.items; + state.showing_answer = true; + delete state.feedback; + }).always(function() { + state.show_answer_spinner = false; + applyState(); + $root.find('.item-bank').focus(); + }); + }; + var doAttempt = function(evt) { evt.preventDefault(); state.submit_spinner = true; @@ -1147,6 +1182,10 @@ function DragAndDropBlock(runtime, element, configuration) { return any_items_placed && (configuration.mode !== DragAndDropBlock.ASSESSMENT_MODE || attemptsRemain()); }; + var canShowAnswer = function() { + return configuration.mode === DragAndDropBlock.ASSESSMENT_MODE && !attemptsRemain(); + }; + var attemptsRemain = function() { return !configuration.max_attempts || configuration.max_attempts > state.attempts; }; @@ -1207,7 +1246,7 @@ function DragAndDropBlock(runtime, element, configuration) { // In assessment mode, it is possible to move items back to the bank, so the bank should be able to // gain focus while keyboard placement is in progress. - var item_bank_focusable = state.keyboard_placement_mode && + var item_bank_focusable = (state.keyboard_placement_mode || state.showing_answer) && configuration.mode === DragAndDropBlock.ASSESSMENT_MODE; var context = { @@ -1220,6 +1259,7 @@ function DragAndDropBlock(runtime, element, configuration) { problem_html: configuration.problem_text, show_problem_header: configuration.show_problem_header, show_submit_answer: configuration.mode == DragAndDropBlock.ASSESSMENT_MODE, + show_show_answer: configuration.mode == DragAndDropBlock.ASSESSMENT_MODE, target_img_src: configuration.target_img_expanded_url, target_img_description: configuration.target_img_description, display_zone_labels: configuration.display_zone_labels, @@ -1233,8 +1273,11 @@ function DragAndDropBlock(runtime, element, configuration) { feedback_messages: state.feedback, overall_feedback_messages: state.overall_feedback, disable_reset_button: !canReset(), + disable_show_answer_button: !canShowAnswer(), disable_submit_button: !canSubmitAttempt(), - submit_spinner: state.submit_spinner + submit_spinner: state.submit_spinner, + showing_answer: state.showing_answer, + show_answer_spinner: state.show_answer_spinner }; 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 9094d4a58..4245b256d 100644 --- a/drag_and_drop_v2/translations/en/LC_MESSAGES/text.po +++ b/drag_and_drop_v2/translations/en/LC_MESSAGES/text.po @@ -216,6 +216,14 @@ msgstr "" msgid "Max number of attempts reached" msgstr "" +#: drag_and_drop_v2.py +msgid "show_answer handler should only be called for assessment mode" +msgstr "" + +#: drag_and_drop_v2.py +msgid "There are attempts remaining" +msgstr "" + #: drag_and_drop_v2.py msgid "Unknown DnDv2 mode {mode} - course is misconfigured" msgstr "" @@ -445,6 +453,14 @@ msgstr "" msgid "Reset" msgstr "" +#: public/js/drag_and_drop.js +msgid "Show Answer" +msgstr "" + +#: public/js/drag_and_drop.js +msgid "Hide Answer" +msgstr "" + #: public/js/drag_and_drop.js msgid "Submit" msgstr "" @@ -529,7 +545,13 @@ msgid_plural "Correctly placed {correct_count} items." msgstr[0] "" msgstr[1] "" -#: utils.py:32 +#: utils.py:62 +msgid "Misplaced {misplaced_count} item." +msgid_plural "Misplaced {misplaced_count} items." +msgstr[0] "" +msgstr[1] "" + +#: utils.py:73 msgid "Misplaced {misplaced_count} item. Misplaced item was returned to item bank." msgid_plural "Misplaced {misplaced_count} items. Misplaced items were returned to item bank." msgstr[0] "" 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 3af229897..516d9f873 100644 --- a/drag_and_drop_v2/translations/eo/LC_MESSAGES/text.po +++ b/drag_and_drop_v2/translations/eo/LC_MESSAGES/text.po @@ -271,6 +271,14 @@ msgstr "dö_ättémpt händlér shöüld önlý ßé çälléd för ässéssmén msgid "Max number of attempts reached" msgstr "Mäx nümßér öf ättémpts réäçhéd Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#" +#: drag_and_drop_v2.py +msgid "show_answer handler should only be called for assessment mode" +msgstr "shöw_änswér händlér shöüld önlý ßé çälléd för ässéssmént mödé Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#" + +#: drag_and_drop_v2.py +msgid "There are attempts remaining" +msgstr "Théré äré ättémpts rémäïnïng Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢#" + #: drag_and_drop_v2.py msgid "Unknown DnDv2 mode {mode} - course is misconfigured" msgstr "Ûnknöwn DnDv2 mödé {mode} - çöürsé ïs mïsçönfïgüréd Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#" @@ -519,6 +527,14 @@ msgstr "Çörréçtlý pläçéd ïn: {zone_title} Ⱡ'σяєм ιρѕυм ∂σ msgid "Reset" msgstr "Rését Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#" +#: public/js/drag_and_drop.js +msgid "Show Answer" +msgstr "Shöw Ànswér Ⱡ'σяєм ιρѕυм ∂σłσя #" + +#: public/js/drag_and_drop.js +msgid "Hide Answer" +msgstr "Hïdé Ànswér Ⱡ'σяєм ιρѕυм ∂σłσя #" + #: public/js/drag_and_drop.js msgid "Submit" msgstr "Süßmït Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#" @@ -617,12 +633,18 @@ msgid_plural "Correctly placed {correct_count} items." msgstr[0] "Çörréçtlý pläçéd {correct_count} ïtém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#" msgstr[1] "Çörréçtlý pläçéd {correct_count} ïtéms. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє#" -#: utils.py:32 -msgid "Misplaced {misplaced_count} item. Misplaced item was returned to item bank." -msgid_plural "Misplaced {misplaced_count} items. Misplaced items were returned to item bank." +#: utils.py:62 +msgid "Misplaced {misplaced_count} item." +msgid_plural "Misplaced {misplaced_count} items." msgstr[0] "Mïspläçéd {misplaced_count} ïtém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт,#" msgstr[1] "Mïspläçéd {misplaced_count} ïtéms. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #" +#: utils.py:73 +msgid "Misplaced {misplaced_count} item. Misplaced item was returned to item bank." +msgid_plural "Misplaced {misplaced_count} items. Misplaced items were returned to item bank." +msgstr[0] "Mïspläçéd {misplaced_count} ïtém. Mïspläçéd ïtém wäs rétürnéd tö ïtém ßänk. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#" +msgstr[1] "Mïspläçéd {misplaced_count} ïtéms. Mïspläçéd ïtéms wéré rétürnéd tö ïtém ßänk. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#" + #: utils.py:40 msgid "Did not place {missing_count} required item." msgid_plural "Did not place {missing_count} required items." diff --git a/drag_and_drop_v2/utils.py b/drag_and_drop_v2/utils.py index f35e50739..6c0e297c5 100644 --- a/drag_and_drop_v2/utils.py +++ b/drag_and_drop_v2/utils.py @@ -59,6 +59,17 @@ def misplaced(number, ngettext=ngettext_fallback): """ Formats "misplaced items" message """ + return ngettext( + 'Misplaced {misplaced_count} item.', + 'Misplaced {misplaced_count} items.', + number + ).format(misplaced_count=number) + + @staticmethod + def misplaced_returned(number, ngettext=ngettext_fallback): + """ + Formats "misplaced items returned to bank" message + """ return ngettext( 'Misplaced {misplaced_count} item. Misplaced item was returned to item bank.', 'Misplaced {misplaced_count} items. Misplaced items were returned to item bank.', diff --git a/tests/integration/test_base.py b/tests/integration/test_base.py index 16567be16..941273570 100644 --- a/tests/integration/test_base.py +++ b/tests/integration/test_base.py @@ -126,6 +126,9 @@ def _get_keyboard_help_dialog(self): def _get_reset_button(self): return self._page.find_element_by_css_selector('.reset-button') + def _get_show_answer_button(self): + return self._page.find_element_by_css_selector('.show-answer-button') + def _get_submit_button(self): return self._page.find_element_by_css_selector('.submit-answer-button') @@ -392,12 +395,21 @@ def assert_placed_item(self, item_value, zone_title, assessment_mode=False): self.assertDraggable(item_value) self.assertEqual(item.get_attribute('class'), 'option') self.assertEqual(item.get_attribute('tabindex'), '0') - self.assertEqual(item_description.text, 'Placed in: {}'.format(zone_title)) + description = 'Placed in: {}' else: self.assertNotDraggable(item_value) self.assertEqual(item.get_attribute('class'), 'option fade') self.assertIsNone(item.get_attribute('tabindex')) - self.assertEqual(item_description.text, 'Correctly placed in: {}'.format(zone_title)) + description = 'Correctly placed in: {}' + + # An item with multiple drop zones could be located in any one of these + # zones. In that case, zone_title will be a list, and we need to check + # whether the zone info in the description of the item matches any of + # the zones in that list. + if isinstance(zone_title, list): + self.assertIn(item_description.text, [description.format(title) for title in zone_title]) + else: + self.assertEqual(item_description.text, description.format(zone_title)) def assert_reverted_item(self, item_value): item = self._get_item_by_value(item_value) diff --git a/tests/integration/test_events.py b/tests/integration/test_events.py index 150d4b29c..fd60858af 100644 --- a/tests/integration/test_events.py +++ b/tests/integration/test_events.py @@ -5,7 +5,8 @@ from workbench.runtime import WorkbenchRuntime 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, + TOP_ZONE_TITLE, TOP_ZONE_ID, MIDDLE_ZONE_TITLE, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID, + ITEM_CORRECT_FEEDBACK, ITEM_INCORRECT_FEEDBACK, ITEM_TOP_ZONE_NAME, ITEM_MIDDLE_ZONE_NAME, ) from tests.integration.test_base import BaseIntegrationTest, DefaultDataTestMixin, InteractionTestBase, ItemDefinition @@ -146,6 +147,20 @@ def test_event(self): self.assertEqual(name, event['name']) self.assertEqual(published_data, event['data']) + def test_grade(self): + """ + Test grading after submitting solution in assessment mode + """ + 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) + @ddt class ItemDroppedEventTest(DefaultDataTestMixin, BaseEventsTests): diff --git a/tests/integration/test_interaction.py b/tests/integration/test_interaction.py index 3f555b7a4..04bd4b272 100644 --- a/tests/integration/test_interaction.py +++ b/tests/integration/test_interaction.py @@ -442,7 +442,7 @@ def test_keyboard_help(self): self._switch_to_block(1) # Test mouse and keyboard interaction - self.interact_with_keyboard_help(scroll_down=900) + self.interact_with_keyboard_help(scroll_down=1200) self.interact_with_keyboard_help(scroll_down=0, use_keyboard=True) diff --git a/tests/integration/test_interaction_assessment.py b/tests/integration/test_interaction_assessment.py index c6b21497a..d8ed2b0bc 100644 --- a/tests/integration/test_interaction_assessment.py +++ b/tests/integration/test_interaction_assessment.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + # Imports ########################################################### from ddt import ddt, data @@ -7,7 +9,6 @@ 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 ( @@ -55,6 +56,14 @@ def click_submit(self): submit_button.click() self.wait_for_ajax() + def click_show_answer(self): + show_answer_button = self._get_show_answer_button() + + self._wait_until_enabled(show_answer_button) + + show_answer_button.click() + self.wait_for_ajax() + @ddt class AssessmentInteractionTest( @@ -150,6 +159,58 @@ def test_max_attempts_reached_submit_and_reset_disabled(self): self.assertEqual(submit_button.get_attribute('disabled'), 'true') self.assertEqual(reset_button.get_attribute('disabled'), 'true') + def _assert_show_answer_item_placement(self): + zones = dict(self.all_zones) + for item in self._get_items_with_zone(self.items_map).values(): + zone_titles = [zones[zone_id] for zone_id in item.zone_ids] + # When showing answers, correct items are placed as if assessment_mode=False + self.assert_placed_item(item.item_id, zone_titles, assessment_mode=False) + + for item_definition in self._get_items_without_zone(self.items_map).values(): + self.assertNotDraggable(item_definition.item_id) + item = self._get_item_by_value(item_definition.item_id) + self.assertEqual(item.get_attribute('aria-grabbed'), 'false') + self.assertEqual(item.get_attribute('class'), 'option fade') + + item_content = item.find_element_by_css_selector('.item-content') + item_description_id = '-item-{}-description'.format(item_definition.item_id) + self.assertEqual(item_content.get_attribute('aria-describedby'), item_description_id) + + describedby_text = (u'Press "Enter", "Space", "Ctrl-m", or "⌘-m" on an item to select it for dropping, ' + 'then navigate to the zone you want to drop it on.') + self.assertEqual(item.find_element_by_css_selector('.sr').text, describedby_text) + + def test_show_answer(self): + """ + Test "Show Answer" button is shown in assessment mode, enabled when no + more attempts remaining, is disabled and displays correct answers when + clicked. + """ + show_answer_button = self._get_show_answer_button() + self.assertTrue(show_answer_button.is_displayed()) + + self.place_item(0, TOP_ZONE_ID, Keys.RETURN) + for _ in xrange(self.MAX_ATTEMPTS-1): + self.assertEqual(show_answer_button.get_attribute('disabled'), 'true') + self.click_submit() + + # Place an incorrect item on the final attempt. + self.place_item(1, TOP_ZONE_ID, Keys.RETURN) + self.click_submit() + + # A feedback popup should open upon final submission. + popup = self._get_popup() + self.assertTrue(popup.is_displayed()) + + self.assertIsNone(show_answer_button.get_attribute('disabled')) + self.click_show_answer() + + # The popup should be closed upon clicking Show Answer. + self.assertFalse(popup.is_displayed()) + + self.assertEqual(show_answer_button.get_attribute('disabled'), 'true') + self._assert_show_answer_item_placement() + def test_do_attempt_feedback_is_updated(self): """ Test updating overall feedback after submitting solution in assessment mode @@ -174,7 +235,7 @@ def test_do_attempt_feedback_is_updated(self): feedback_lines = [ "FEEDBACK", FeedbackMessages.correctly_placed(1), - FeedbackMessages.misplaced(1), + FeedbackMessages.misplaced_returned(1), FeedbackMessages.not_placed(2), START_FEEDBACK ] @@ -199,26 +260,6 @@ def test_do_attempt_feedback_is_updated(self): 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) - 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) diff --git a/tests/unit/test_advanced.py b/tests/unit/test_advanced.py index 14bb4c458..7d4ad64a7 100644 --- a/tests/unit/test_advanced.py +++ b/tests/unit/test_advanced.py @@ -190,6 +190,14 @@ def test_do_attempt_not_available(self): self.assertEqual(res.status_code, 400) + def test_show_answer_not_available(self): + """ + Tests that do_attempt handler returns 400 error for standard mode DnDv2 + """ + res = self.call_handler(self.SHOW_ANSWER_HANDLER, expect_json=False) + + self.assertEqual(res.status_code, 400) + @ddt.ddt class AssessmentModeFixture(BaseDragAndDropAjaxFixture): @@ -205,6 +213,12 @@ def _submit_solution(self, solution): data = self._make_submission(item_id, zone_id) self.call_handler(self.DROP_ITEM_HANDLER, data) + def _get_all_solutions(self): # pylint: disable=no-self-use + raise NotImplementedError() + + def _get_all_decoys(self): # pylint: disable=no-self-use + raise NotImplementedError() + def _submit_complete_solution(self): # pylint: disable=no-self-use raise NotImplementedError() @@ -402,6 +416,51 @@ def test_get_user_state_does_not_include_correctness(self): self.assertEqual(self.block.item_state, original_item_state) + @ddt.data( + (None, 10, True), + (0, 12, True), + (3, 3, False), + ) + @ddt.unpack + def test_show_answer_validation(self, max_attempts, attempts, expect_validation_error): + """ + Test that show_answer returns a 409 when max_attempts = None, or when + there are still attempts remaining. + """ + self.block.max_attempts = max_attempts + self.block.attempts = attempts + res = self.call_handler(self.SHOW_ANSWER_HANDLER, data={}, expect_json=False) + + if expect_validation_error: + self.assertEqual(res.status_code, 409) + else: + self.assertEqual(res.status_code, 200) + + def test_get_correct_state(self): + """ + Test that _get_correct_state returns one of the possible correct + solutions for the configuration. + """ + self._set_final_attempt() + self._submit_incorrect_solution() + self.call_handler(self.DO_ATTEMPT_HANDLER, data={}) + + self.assertFalse(self.block.attempts_remain) # precondition check + + res = self.call_handler(self.SHOW_ANSWER_HANDLER, data={}) + + self.assertIn('items', res) + + decoys = self._get_all_decoys() + solution = {} + for item_id, item_state in res['items'].iteritems(): + self.assertIn('correct', item_state) + self.assertIn('zone', item_state) + self.assertNotIn(int(item_id), decoys) + solution[int(item_id)] = item_state['zone'] + + self.assertIn(solution, self._get_all_solutions()) + class TestDragAndDropHtmlData(StandardModeFixture, unittest.TestCase): FOLDER = "html" @@ -468,6 +527,12 @@ def _assert_item_and_overall_feedback(self, res, expected_item_feedback, expecte self.assertEqual(res[self.FEEDBACK_KEY], expected_item_feedback) self.assertEqual(res[self.OVERALL_FEEDBACK_KEY], expected_overall_feedback) + def _get_all_solutions(self): + return [{0: self.ZONE_1, 1: self.ZONE_2, 2: self.ZONE_2}] + + def _get_all_decoys(self): + return [3, 4] + def _submit_complete_solution(self): self._submit_solution({0: self.ZONE_1, 1: self.ZONE_2, 2: self.ZONE_2}) @@ -486,15 +551,35 @@ def test_do_attempt_feedback_incorrect_not_placed(self): expected_item_feedback = [self._make_feedback_message(self.FEEDBACK[0]['incorrect'])] expected_overall_feedback = [ self._make_feedback_message( - FeedbackMessages.correctly_placed(1), FeedbackMessages.MessageClasses.CORRECTLY_PLACED + FeedbackMessages.correctly_placed(1), + FeedbackMessages.MessageClasses.CORRECTLY_PLACED + ), + self._make_feedback_message( + FeedbackMessages.misplaced_returned(1), + FeedbackMessages.MessageClasses.MISPLACED + ), + self._make_feedback_message( + FeedbackMessages.not_placed(1), + FeedbackMessages.MessageClasses.NOT_PLACED + ), + self._make_feedback_message( + self.INITIAL_FEEDBACK, + None ), - self._make_feedback_message(FeedbackMessages.misplaced(1), FeedbackMessages.MessageClasses.MISPLACED), - self._make_feedback_message(FeedbackMessages.not_placed(1), FeedbackMessages.MessageClasses.NOT_PLACED), - self._make_feedback_message(self.INITIAL_FEEDBACK, None), ] self._assert_item_and_overall_feedback(res, expected_item_feedback, expected_overall_feedback) + def test_do_attempt_shows_correct_misplaced_feedback_at_last_attempt(self): + self._set_final_attempt() + self._submit_solution({0: self.ZONE_2}) + res = self._do_attempt() + misplaced_message = self._make_feedback_message( + FeedbackMessages.misplaced(1), + FeedbackMessages.MessageClasses.MISPLACED + ) + self.assertIn(misplaced_message, res[self.OVERALL_FEEDBACK_KEY]) + def test_do_attempt_no_item_state(self): """ Test do_attempt overall feedback when no item state is saved - no items were ever dropped. @@ -517,11 +602,21 @@ def test_do_attempt_feedback_correct_and_decoy(self): expected_item_feedback = [] expected_overall_feedback = [ self._make_feedback_message( - FeedbackMessages.correctly_placed(2), FeedbackMessages.MessageClasses.CORRECTLY_PLACED + FeedbackMessages.correctly_placed(2), + FeedbackMessages.MessageClasses.CORRECTLY_PLACED + ), + self._make_feedback_message( + FeedbackMessages.misplaced_returned(1), + FeedbackMessages.MessageClasses.MISPLACED + ), + self._make_feedback_message( + FeedbackMessages.not_placed(1), + FeedbackMessages.MessageClasses.NOT_PLACED + ), + self._make_feedback_message( + self.INITIAL_FEEDBACK, + None ), - self._make_feedback_message(FeedbackMessages.misplaced(1), FeedbackMessages.MessageClasses.MISPLACED), - self._make_feedback_message(FeedbackMessages.not_placed(1), FeedbackMessages.MessageClasses.NOT_PLACED), - self._make_feedback_message(self.INITIAL_FEEDBACK, None), ] self._assert_item_and_overall_feedback(res, expected_item_feedback, expected_overall_feedback) diff --git a/tests/utils.py b/tests/utils.py index 00d7a4709..59e420cd6 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -47,6 +47,7 @@ class TestCaseMixin(object): DROP_ITEM_HANDLER = 'drop_item' DO_ATTEMPT_HANDLER = 'do_attempt' RESET_HANDLER = 'reset' + SHOW_ANSWER_HANDLER = 'show_answer' USER_STATE_HANDLER = 'get_user_state' def patch_workbench(self):