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):