Commit 85c6143c by Tim Krones Committed by GitHub

Merge pull request #101 from arbrandes/SOL-1998

[SOL-1998] Implement Show Answer button
parents 07add130 4c6fb7d7
...@@ -108,7 +108,8 @@ There are two problem modes available: ...@@ -108,7 +108,8 @@ There are two problem modes available:
attempt to place an item, and the number of attempts is not limited. 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 * **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 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) ![Drop zone edit](/doc/img/edit-view-zones.png)
......
...@@ -438,6 +438,28 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -438,6 +438,28 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
return self._get_user_state() return self._get_user_state()
@XBlock.json_handler @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=''): def expand_static_url(self, url, suffix=''):
""" AJAX-accessible handler for expanding URLs to static [image] files """ """ AJAX-accessible handler for expanding URLs to static [image] files """
return {'url': self._expand_static_url(url)} return {'url': self._expand_static_url(url)}
...@@ -527,7 +549,14 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -527,7 +549,14 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
FeedbackMessages.correctly_placed, FeedbackMessages.correctly_placed,
FeedbackMessages.MessageClasses.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) _add_msg_if_exists(missing_ids, FeedbackMessages.not_placed, FeedbackMessages.MessageClasses.NOT_PLACED)
if self.attempts_remain and (misplaced_ids or missing_ids): if self.attempts_remain and (misplaced_ids or missing_ids):
...@@ -723,6 +752,31 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -723,6 +752,31 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
'overall_feedback': self._present_feedback(overall_feedback_msgs) '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): def _get_item_state(self):
""" """
Returns a copy of the user item state. Returns a copy of the user item state.
...@@ -855,4 +909,13 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -855,4 +909,13 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
""" """
A canned scenario for display in the workbench. A canned scenario for display in the workbench.
""" """
return [("Drag-and-drop-v2 scenario", "<vertical_demo><drag-and-drop-v2/></vertical_demo>")] return [
(
"Drag-and-drop-v2 standard",
"<vertical_demo><drag-and-drop-v2/></vertical_demo>"
),
(
"Drag-and-drop-v2 assessment",
"<vertical_demo><drag-and-drop-v2 mode='assessment' max_attempts='3'/></vertical_demo>"
),
]
...@@ -568,6 +568,7 @@ ...@@ -568,6 +568,7 @@
.ltr .xblock--drag-and-drop .actions-toolbar .action-toolbar-item.sidebar-buttons { .ltr .xblock--drag-and-drop .actions-toolbar .action-toolbar-item.sidebar-buttons {
float: right; float: right;
padding-right: -5px; padding-right: -5px;
padding-top: 5px;
} }
.rtl .xblock--drag-and-drop .actions-toolbar .action-toolbar-item.sidebar-buttons { .rtl .xblock--drag-and-drop .actions-toolbar .action-toolbar-item.sidebar-buttons {
...@@ -623,10 +624,6 @@ ...@@ -623,10 +624,6 @@
display: block; display: block;
} }
.xblock--drag-and-drop .reset-button {
margin-top: 3px;
}
/*** ACTIONS TOOLBAR END ***/ /*** ACTIONS TOOLBAR END ***/
/*** KEYBOARD HELP ***/ /*** KEYBOARD HELP ***/
......
...@@ -110,7 +110,7 @@ function DragAndDropTemplates(configuration) { ...@@ -110,7 +110,7 @@ function DragAndDropTemplates(configuration) {
if (item.is_placed) { if (item.is_placed) {
var zone_title = (zone.title || "Unknown Zone"); // This "Unknown" text should never be seen, so does not need i18n var zone_title = (zone.title || "Unknown Zone"); // This "Unknown" text should never be seen, so does not need i18n
var description_content; 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. // 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); description_content = gettext('Placed in: {zone_title}').replace('{zone_title}', zone_title);
} else { } else {
...@@ -180,9 +180,8 @@ function DragAndDropTemplates(configuration) { ...@@ -180,9 +180,8 @@ function DragAndDropTemplates(configuration) {
var zoneTemplate = function(zone, ctx) { var zoneTemplate = function(zone, ctx) {
var className = ctx.display_zone_labels ? 'zone-name' : 'zone-name sr'; var className = ctx.display_zone_labels ? 'zone-name' : 'zone-name sr';
var selector = ctx.display_zone_borders ? 'div.zone.zone-with-borders' : 'div.zone'; var selector = ctx.display_zone_borders ? 'div.zone.zone-with-borders' : 'div.zone';
// If zone is aligned, mark its item alignment // Mark item alignment and render its placed items as children
// and render its placed items as children var item_wrapper = 'div.item-wrapper.item-align.item-align-' + zone.align;
var item_wrapper = 'div.item-wrapper';
var is_item_in_zone = function(i) { return i.is_placed && (i.zone === zone.uid); }; 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 items_in_zone = $.grep(ctx.items, is_item_in_zone);
var zone_description_id = 'zone-' + zone.uid + '-description'; var zone_description_id = 'zone-' + zone.uid + '-description';
...@@ -199,12 +198,7 @@ function DragAndDropTemplates(configuration) { ...@@ -199,12 +198,7 @@ function DragAndDropTemplates(configuration) {
gettext('Items placed here: ') + items_in_zone.map(function (item) { return item.displayName; }).join(", ") 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 ( return (
h( h(
selector, selector,
...@@ -343,14 +337,21 @@ function DragAndDropTemplates(configuration) { ...@@ -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 ( return (
h('span.sidebar-button-wrapper', {}, [ h('span.sidebar-button-wrapper', {}, [
h( h(
'button.unbutton.btn-default.btn-small.'+buttonClass, '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 buttonText
] ]
) )
...@@ -359,10 +360,21 @@ function DragAndDropTemplates(configuration) { ...@@ -359,10 +360,21 @@ function DragAndDropTemplates(configuration) {
}; };
var sidebarTemplate = function(ctx) { 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( return(
h("section.action-toolbar-item.sidebar-buttons", {}, [ h("section.action-toolbar-item.sidebar-buttons", {}, [
sidebarButtonTemplate("keyboard-help-button", "fa-question", gettext('Keyboard Help')), sidebarButtonTemplate("keyboard-help-button", "fa-question", gettext('Keyboard Help')),
sidebarButtonTemplate("reset-button", "fa-refresh", gettext('Reset'), ctx.disable_reset_button), sidebarButtonTemplate("reset-button", "fa-refresh", gettext('Reset'), ctx.disable_reset_button),
showAnswerButton,
]) ])
) )
}; };
...@@ -434,9 +446,8 @@ function DragAndDropTemplates(configuration) { ...@@ -434,9 +446,8 @@ function DragAndDropTemplates(configuration) {
var mainTemplate = function(ctx) { var mainTemplate = function(ctx) {
var problemTitle = ctx.show_title ? h('h3.problem-title', {innerHTML: ctx.title_html}) : null; 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; var problemHeader = ctx.show_problem_header ? h('h4.title1', gettext('Problem')) : null;
// Render only items in the bank here, including placeholders. Placed
// Render only items_in_bank and items_placed_unaligned here; // items will be rendered by zoneTemplate.
// items placed in aligned zones will be rendered by zoneTemplate.
var is_item_placed = function(i) { return i.is_placed; }; var is_item_placed = function(i) { return i.is_placed; };
var items_placed = $.grep(ctx.items, is_item_placed); var items_placed = $.grep(ctx.items, is_item_placed);
var items_in_bank = $.grep(ctx.items, is_item_placed, true); var items_in_bank = $.grep(ctx.items, is_item_placed, true);
...@@ -561,6 +572,10 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -561,6 +572,10 @@ function DragAndDropBlock(runtime, element, configuration) {
$element.on('keydown', '.reset-button', function(evt) { $element.on('keydown', '.reset-button', function(evt) {
runOnKey(evt, RET, resetProblem); 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 // 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. // to watch for load events on any child element, since load events do not bubble.
...@@ -1098,6 +1113,26 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -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) { var doAttempt = function(evt) {
evt.preventDefault(); evt.preventDefault();
state.submit_spinner = true; state.submit_spinner = true;
...@@ -1147,6 +1182,10 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -1147,6 +1182,10 @@ function DragAndDropBlock(runtime, element, configuration) {
return any_items_placed && (configuration.mode !== DragAndDropBlock.ASSESSMENT_MODE || attemptsRemain()); return any_items_placed && (configuration.mode !== DragAndDropBlock.ASSESSMENT_MODE || attemptsRemain());
}; };
var canShowAnswer = function() {
return configuration.mode === DragAndDropBlock.ASSESSMENT_MODE && !attemptsRemain();
};
var attemptsRemain = function() { var attemptsRemain = function() {
return !configuration.max_attempts || configuration.max_attempts > state.attempts; return !configuration.max_attempts || configuration.max_attempts > state.attempts;
}; };
...@@ -1207,7 +1246,7 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -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 // 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. // 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; configuration.mode === DragAndDropBlock.ASSESSMENT_MODE;
var context = { var context = {
...@@ -1220,6 +1259,7 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -1220,6 +1259,7 @@ function DragAndDropBlock(runtime, element, configuration) {
problem_html: configuration.problem_text, problem_html: configuration.problem_text,
show_problem_header: configuration.show_problem_header, show_problem_header: configuration.show_problem_header,
show_submit_answer: configuration.mode == DragAndDropBlock.ASSESSMENT_MODE, 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_src: configuration.target_img_expanded_url,
target_img_description: configuration.target_img_description, target_img_description: configuration.target_img_description,
display_zone_labels: configuration.display_zone_labels, display_zone_labels: configuration.display_zone_labels,
...@@ -1233,8 +1273,11 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -1233,8 +1273,11 @@ function DragAndDropBlock(runtime, element, configuration) {
feedback_messages: state.feedback, feedback_messages: state.feedback,
overall_feedback_messages: state.overall_feedback, overall_feedback_messages: state.overall_feedback,
disable_reset_button: !canReset(), disable_reset_button: !canReset(),
disable_show_answer_button: !canShowAnswer(),
disable_submit_button: !canSubmitAttempt(), 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); return renderView(context);
......
...@@ -217,6 +217,14 @@ msgid "Max number of attempts reached" ...@@ -217,6 +217,14 @@ msgid "Max number of attempts reached"
msgstr "" msgstr ""
#: drag_and_drop_v2.py #: 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" msgid "Unknown DnDv2 mode {mode} - course is misconfigured"
msgstr "" msgstr ""
...@@ -446,6 +454,14 @@ msgid "Reset" ...@@ -446,6 +454,14 @@ msgid "Reset"
msgstr "" msgstr ""
#: public/js/drag_and_drop.js #: 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" msgid "Submit"
msgstr "" msgstr ""
...@@ -529,7 +545,13 @@ msgid_plural "Correctly placed {correct_count} items." ...@@ -529,7 +545,13 @@ msgid_plural "Correctly placed {correct_count} items."
msgstr[0] "" msgstr[0] ""
msgstr[1] "" 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 "Misplaced {misplaced_count} item. Misplaced item was returned to item bank."
msgid_plural "Misplaced {misplaced_count} items. Misplaced items were returned to item bank." msgid_plural "Misplaced {misplaced_count} items. Misplaced items were returned to item bank."
msgstr[0] "" msgstr[0] ""
......
...@@ -272,6 +272,14 @@ msgid "Max number of attempts reached" ...@@ -272,6 +272,14 @@ msgid "Max number of attempts reached"
msgstr "Mäx nümßér öf ättémpts réäçhéd Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#" msgstr "Mäx nümßér öf ättémpts réäçhéd Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#"
#: drag_and_drop_v2.py #: 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" msgid "Unknown DnDv2 mode {mode} - course is misconfigured"
msgstr "Ûnknöwn DnDv2 mödé {mode} - çöürsé ïs mïsçönfïgüréd Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#" msgstr "Ûnknöwn DnDv2 mödé {mode} - çöürsé ïs mïsçönfïgüréd Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#"
...@@ -520,6 +528,14 @@ msgid "Reset" ...@@ -520,6 +528,14 @@ msgid "Reset"
msgstr "Rését Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#" msgstr "Rését Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
#: public/js/drag_and_drop.js #: 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" msgid "Submit"
msgstr "Süßmït Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#" msgstr "Süßmït Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
...@@ -617,12 +633,18 @@ msgid_plural "Correctly placed {correct_count} items." ...@@ -617,12 +633,18 @@ msgid_plural "Correctly placed {correct_count} items."
msgstr[0] "Çörréçtlý pläçéd {correct_count} ïtém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#" msgstr[0] "Çörréçtlý pläçéd {correct_count} ïtém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#"
msgstr[1] "Çörréçtlý pläçéd {correct_count} ïtéms. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє#" msgstr[1] "Çörréçtlý pläçéd {correct_count} ïtéms. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє#"
#: utils.py:32 #: utils.py:62
msgid "Misplaced {misplaced_count} item. Misplaced item was returned to item bank." msgid "Misplaced {misplaced_count} item."
msgid_plural "Misplaced {misplaced_count} items. Misplaced items were returned to item bank." msgid_plural "Misplaced {misplaced_count} items."
msgstr[0] "Mïspläçéd {misplaced_count} ïtém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт,#" msgstr[0] "Mïspläçéd {misplaced_count} ïtém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт,#"
msgstr[1] "Mïspläçéd {misplaced_count} ïtéms. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #" 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 #: utils.py:40
msgid "Did not place {missing_count} required item." msgid "Did not place {missing_count} required item."
msgid_plural "Did not place {missing_count} required items." msgid_plural "Did not place {missing_count} required items."
......
...@@ -60,6 +60,17 @@ class FeedbackMessages(object): ...@@ -60,6 +60,17 @@ class FeedbackMessages(object):
Formats "misplaced items" message Formats "misplaced items" message
""" """
return ngettext( 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} item. Misplaced item was returned to item bank.',
'Misplaced {misplaced_count} items. Misplaced items were returned to item bank.', 'Misplaced {misplaced_count} items. Misplaced items were returned to item bank.',
number number
......
...@@ -126,6 +126,9 @@ class BaseIntegrationTest(SeleniumBaseTest): ...@@ -126,6 +126,9 @@ class BaseIntegrationTest(SeleniumBaseTest):
def _get_reset_button(self): def _get_reset_button(self):
return self._page.find_element_by_css_selector('.reset-button') 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): def _get_submit_button(self):
return self._page.find_element_by_css_selector('.submit-answer-button') return self._page.find_element_by_css_selector('.submit-answer-button')
...@@ -392,12 +395,21 @@ class InteractionTestBase(object): ...@@ -392,12 +395,21 @@ class InteractionTestBase(object):
self.assertDraggable(item_value) self.assertDraggable(item_value)
self.assertEqual(item.get_attribute('class'), 'option') self.assertEqual(item.get_attribute('class'), 'option')
self.assertEqual(item.get_attribute('tabindex'), '0') self.assertEqual(item.get_attribute('tabindex'), '0')
self.assertEqual(item_description.text, 'Placed in: {}'.format(zone_title)) description = 'Placed in: {}'
else: else:
self.assertNotDraggable(item_value) self.assertNotDraggable(item_value)
self.assertEqual(item.get_attribute('class'), 'option fade') self.assertEqual(item.get_attribute('class'), 'option fade')
self.assertIsNone(item.get_attribute('tabindex')) 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): def assert_reverted_item(self, item_value):
item = self._get_item_by_value(item_value) item = self._get_item_by_value(item_value)
......
...@@ -5,7 +5,8 @@ from selenium.webdriver.common.keys import Keys ...@@ -5,7 +5,8 @@ from selenium.webdriver.common.keys import Keys
from workbench.runtime import WorkbenchRuntime from workbench.runtime import WorkbenchRuntime
from drag_and_drop_v2.default_data import ( 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, ITEM_TOP_ZONE_NAME, ITEM_MIDDLE_ZONE_NAME,
) )
from tests.integration.test_base import BaseIntegrationTest, DefaultDataTestMixin, InteractionTestBase, ItemDefinition from tests.integration.test_base import BaseIntegrationTest, DefaultDataTestMixin, InteractionTestBase, ItemDefinition
...@@ -146,6 +147,20 @@ class AssessmentEventsFiredTest( ...@@ -146,6 +147,20 @@ class AssessmentEventsFiredTest(
self.assertEqual(name, event['name']) self.assertEqual(name, event['name'])
self.assertEqual(published_data, event['data']) 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 @ddt
class ItemDroppedEventTest(DefaultDataTestMixin, BaseEventsTests): class ItemDroppedEventTest(DefaultDataTestMixin, BaseEventsTests):
......
...@@ -442,7 +442,7 @@ class MultipleBlocksDataInteraction(ParameterizedTestsMixin, InteractionTestBase ...@@ -442,7 +442,7 @@ class MultipleBlocksDataInteraction(ParameterizedTestsMixin, InteractionTestBase
self._switch_to_block(1) self._switch_to_block(1)
# Test mouse and keyboard interaction # 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) self.interact_with_keyboard_help(scroll_down=0, use_keyboard=True)
......
# -*- coding: utf-8 -*-
# Imports ########################################################### # Imports ###########################################################
from ddt import ddt, data from ddt import ddt, data
...@@ -7,7 +9,6 @@ import time ...@@ -7,7 +9,6 @@ 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
from workbench.runtime import WorkbenchRuntime
from xblockutils.resources import ResourceLoader from xblockutils.resources import ResourceLoader
from drag_and_drop_v2.default_data import ( from drag_and_drop_v2.default_data import (
...@@ -55,6 +56,14 @@ class AssessmentTestMixin(object): ...@@ -55,6 +56,14 @@ class AssessmentTestMixin(object):
submit_button.click() submit_button.click()
self.wait_for_ajax() 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 @ddt
class AssessmentInteractionTest( class AssessmentInteractionTest(
...@@ -150,6 +159,58 @@ class AssessmentInteractionTest( ...@@ -150,6 +159,58 @@ class AssessmentInteractionTest(
self.assertEqual(submit_button.get_attribute('disabled'), 'true') self.assertEqual(submit_button.get_attribute('disabled'), 'true')
self.assertEqual(reset_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): def test_do_attempt_feedback_is_updated(self):
""" """
Test updating overall feedback after submitting solution in assessment mode Test updating overall feedback after submitting solution in assessment mode
...@@ -174,7 +235,7 @@ class AssessmentInteractionTest( ...@@ -174,7 +235,7 @@ class AssessmentInteractionTest(
feedback_lines = [ feedback_lines = [
"FEEDBACK", "FEEDBACK",
FeedbackMessages.correctly_placed(1), FeedbackMessages.correctly_placed(1),
FeedbackMessages.misplaced(1), FeedbackMessages.misplaced_returned(1),
FeedbackMessages.not_placed(2), FeedbackMessages.not_placed(2),
START_FEEDBACK START_FEEDBACK
] ]
...@@ -199,26 +260,6 @@ class AssessmentInteractionTest( ...@@ -199,26 +260,6 @@ class AssessmentInteractionTest(
expected_feedback = "\n".join(feedback_lines) expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback) 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): def test_per_item_feedback_multiple_misplaced(self):
self.place_item(0, MIDDLE_ZONE_ID, Keys.RETURN) self.place_item(0, MIDDLE_ZONE_ID, Keys.RETURN)
self.place_item(1, BOTTOM_ZONE_ID, Keys.RETURN) self.place_item(1, BOTTOM_ZONE_ID, Keys.RETURN)
......
...@@ -190,6 +190,14 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture): ...@@ -190,6 +190,14 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
self.assertEqual(res.status_code, 400) 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 @ddt.ddt
class AssessmentModeFixture(BaseDragAndDropAjaxFixture): class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
...@@ -205,6 +213,12 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture): ...@@ -205,6 +213,12 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
data = self._make_submission(item_id, zone_id) data = self._make_submission(item_id, zone_id)
self.call_handler(self.DROP_ITEM_HANDLER, data) 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 def _submit_complete_solution(self): # pylint: disable=no-self-use
raise NotImplementedError() raise NotImplementedError()
...@@ -402,6 +416,51 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture): ...@@ -402,6 +416,51 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
self.assertEqual(self.block.item_state, original_item_state) 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): class TestDragAndDropHtmlData(StandardModeFixture, unittest.TestCase):
FOLDER = "html" FOLDER = "html"
...@@ -468,6 +527,12 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase): ...@@ -468,6 +527,12 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
self.assertEqual(res[self.FEEDBACK_KEY], expected_item_feedback) self.assertEqual(res[self.FEEDBACK_KEY], expected_item_feedback)
self.assertEqual(res[self.OVERALL_FEEDBACK_KEY], expected_overall_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): def _submit_complete_solution(self):
self._submit_solution({0: self.ZONE_1, 1: self.ZONE_2, 2: self.ZONE_2}) self._submit_solution({0: self.ZONE_1, 1: self.ZONE_2, 2: self.ZONE_2})
...@@ -486,15 +551,35 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase): ...@@ -486,15 +551,35 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
expected_item_feedback = [self._make_feedback_message(self.FEEDBACK[0]['incorrect'])] expected_item_feedback = [self._make_feedback_message(self.FEEDBACK[0]['incorrect'])]
expected_overall_feedback = [ expected_overall_feedback = [
self._make_feedback_message( 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) 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): def test_do_attempt_no_item_state(self):
""" """
Test do_attempt overall feedback when no item state is saved - no items were ever dropped. Test do_attempt overall feedback when no item state is saved - no items were ever dropped.
...@@ -517,11 +602,21 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase): ...@@ -517,11 +602,21 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
expected_item_feedback = [] expected_item_feedback = []
expected_overall_feedback = [ expected_overall_feedback = [
self._make_feedback_message( 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) self._assert_item_and_overall_feedback(res, expected_item_feedback, expected_overall_feedback)
......
...@@ -47,6 +47,7 @@ class TestCaseMixin(object): ...@@ -47,6 +47,7 @@ class TestCaseMixin(object):
DROP_ITEM_HANDLER = 'drop_item' DROP_ITEM_HANDLER = 'drop_item'
DO_ATTEMPT_HANDLER = 'do_attempt' DO_ATTEMPT_HANDLER = 'do_attempt'
RESET_HANDLER = 'reset' RESET_HANDLER = 'reset'
SHOW_ANSWER_HANDLER = 'show_answer'
USER_STATE_HANDLER = 'get_user_state' USER_STATE_HANDLER = 'get_user_state'
def patch_workbench(self): def patch_workbench(self):
......
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