Commit 90c54f0a by Eugeny Kolpakov Committed by GitHub

Merge pull request #87 from open-craft/ekolpakov/assessment-submit

Click handlers and backend processing for assessment mode
parents 34b92c63 a0028a7b
...@@ -12,7 +12,8 @@ install: ...@@ -12,7 +12,8 @@ install:
- "pip install dist/xblock-drag-and-drop-v2-2.0.7.tar.gz" - "pip install dist/xblock-drag-and-drop-v2-2.0.7.tar.gz"
script: script:
- pep8 drag_and_drop_v2 tests --max-line-length=120 - pep8 drag_and_drop_v2 tests --max-line-length=120
- pylint drag_and_drop_v2 tests - pylint drag_and_drop_v2
- pylint tests --rcfile=tests/pylintrc
- python run_tests.py - python run_tests.py
notifications: notifications:
email: false email: false
......
...@@ -96,11 +96,19 @@ and Drop component to a lesson, then click the `EDIT` button. ...@@ -96,11 +96,19 @@ and Drop component to a lesson, then click the `EDIT` button.
![Edit view](/doc/img/edit-view.png) ![Edit view](/doc/img/edit-view.png)
In the first step, you can set some basic properties of the component, In the first step, you can set some basic properties of the component, such as
such as the title, the mode, the maximum number of attempts, the maximum score, the title, the problem mode, the maximum number of attempts, the maximum score,
the problem text to render above the background image, the introductory feedback the problem text to render above the background image, the introductory feedback
(shown initially), and the final feedback (shown after the learner successfully (shown initially), and the final feedback (shown after the learner successfully
completes the drag and drop problem). completes the drag and drop problem, or when the learner runs out of attempts).
There are two problem modes available:
* **Standard**: In this mode, the learner gets immediate feedback on each
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.
![Drop zone edit](/doc/img/edit-view-zones.png) ![Drop zone edit](/doc/img/edit-view-zones.png)
...@@ -126,13 +134,14 @@ potentially, overlap the zones below. ...@@ -126,13 +134,14 @@ potentially, overlap the zones below.
![Drag item edit](/doc/img/edit-view-items.png) ![Drag item edit](/doc/img/edit-view-items.png)
In the final step, you define the background and text color for drag In the final step, you define the background and text color for drag items, as
items, as well as the drag items themselves. A drag item can contain well as the drag items themselves. A drag item can contain either text or an
either text or an image. You can define custom success and error image. You can define custom success and error feedback for each item. In
feedback for each item. The feedback text is displayed in a popup standard mode, the feedback text is displayed in a popup after the learner drops
after the learner drops the item on a zone - the success feedback is the item on a zone - the success feedback is shown if the item is dropped on a
shown if the item is dropped on a correct zone, while the error correct zone, while the error feedback is shown when dropping the item on an
feedback is shown when dropping the item on an incorrect drop zone. incorrect drop zone. In assessment mode, the success and error feedback texts
are not used.
You can select any number of zones for an item to belong to using You can select any number of zones for an item to belong to using
the checkboxes; all zones defined in the previous step are available. the checkboxes; all zones defined in the previous step are available.
......
""" Drag and Drop v2 XBlock """
from .drag_and_drop_v2 import DragAndDropBlock from .drag_and_drop_v2 import DragAndDropBlock
""" Default data for Drag and Drop v2 XBlock """
from .utils import _ from .utils import _
TARGET_IMG_DESCRIPTION = _( TARGET_IMG_DESCRIPTION = _(
...@@ -27,100 +28,90 @@ START_FEEDBACK = _("Drag the items onto the image above.") ...@@ -27,100 +28,90 @@ START_FEEDBACK = _("Drag the items onto the image above.")
FINISH_FEEDBACK = _("Good work! You have completed this drag and drop problem.") FINISH_FEEDBACK = _("Good work! You have completed this drag and drop problem.")
DEFAULT_DATA = { DEFAULT_DATA = {
"targetImgDescription": TARGET_IMG_DESCRIPTION, "targetImgDescription": TARGET_IMG_DESCRIPTION,
"zones": [ "zones": [
{ {
"uid": TOP_ZONE_ID, "uid": TOP_ZONE_ID,
"title": TOP_ZONE_TITLE, "title": TOP_ZONE_TITLE,
"description": TOP_ZONE_DESCRIPTION, "description": TOP_ZONE_DESCRIPTION,
"x": 160, "x": 160,
"y": 30, "y": 30,
"width": 196, "width": 196,
"height": 178, "height": 178,
},
{
"uid": MIDDLE_ZONE_ID,
"title": MIDDLE_ZONE_TITLE,
"description": MIDDLE_ZONE_DESCRIPTION,
"x": 86,
"y": 210,
"width": 340,
"height": 138,
},
{
"uid": BOTTOM_ZONE_ID,
"title": BOTTOM_ZONE_TITLE,
"description": BOTTOM_ZONE_DESCRIPTION,
"x": 15,
"y": 350,
"width": 485,
"height": 135,
}
],
"items": [
{
"displayName": _("Goes to the top"),
"feedback": {
"incorrect": ITEM_INCORRECT_FEEDBACK,
"correct": ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE)
},
"zones": [TOP_ZONE_ID],
"imageURL": "",
"id": 0,
},
{
"displayName": _("Goes to the middle"),
"feedback": {
"incorrect": ITEM_INCORRECT_FEEDBACK,
"correct": ITEM_CORRECT_FEEDBACK.format(zone=MIDDLE_ZONE_TITLE)
},
"zones": [MIDDLE_ZONE_ID],
"imageURL": "",
"id": 1,
},
{
"displayName": _("Goes to the bottom"),
"feedback": {
"incorrect": ITEM_INCORRECT_FEEDBACK,
"correct": ITEM_CORRECT_FEEDBACK.format(zone=BOTTOM_ZONE_TITLE)
},
"zones": [BOTTOM_ZONE_ID],
"imageURL": "",
"id": 2,
},
{
"displayName": _("Goes anywhere"),
"feedback": {
"incorrect": "",
"correct": ITEM_ANY_ZONE_FEEDBACK
},
"zones": [TOP_ZONE_ID, BOTTOM_ZONE_ID, MIDDLE_ZONE_ID],
"imageURL": "",
"id": 3
},
{
"displayName": _("I don't belong anywhere"),
"feedback": {
"incorrect": ITEM_NO_ZONE_FEEDBACK,
"correct": ""
},
"zones": [],
"imageURL": "",
"id": 4,
},
],
"feedback": {
"start": START_FEEDBACK,
"finish": FINISH_FEEDBACK,
}, },
{
"uid": MIDDLE_ZONE_ID,
"title": MIDDLE_ZONE_TITLE,
"description": MIDDLE_ZONE_DESCRIPTION,
"x": 86,
"y": 210,
"width": 340,
"height": 138,
},
{
"uid": BOTTOM_ZONE_ID,
"title": BOTTOM_ZONE_TITLE,
"description": BOTTOM_ZONE_DESCRIPTION,
"x": 15,
"y": 350,
"width": 485,
"height": 135,
}
],
"items": [
{
"displayName": _("Goes to the top"),
"feedback": {
"incorrect": ITEM_INCORRECT_FEEDBACK,
"correct": ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE)
},
"zones": [
TOP_ZONE_ID
],
"imageURL": "",
"id": 0,
},
{
"displayName": _("Goes to the middle"),
"feedback": {
"incorrect": ITEM_INCORRECT_FEEDBACK,
"correct": ITEM_CORRECT_FEEDBACK.format(zone=MIDDLE_ZONE_TITLE)
},
"zones": [
MIDDLE_ZONE_ID
],
"imageURL": "",
"id": 1,
},
{
"displayName": _("Goes to the bottom"),
"feedback": {
"incorrect": ITEM_INCORRECT_FEEDBACK,
"correct": ITEM_CORRECT_FEEDBACK.format(zone=BOTTOM_ZONE_TITLE)
},
"zones": [
BOTTOM_ZONE_ID
],
"imageURL": "",
"id": 2,
},
{
"displayName": _("Goes anywhere"),
"feedback": {
"incorrect": "",
"correct": ITEM_ANY_ZONE_FEEDBACK
},
"zones": [
TOP_ZONE_ID,
BOTTOM_ZONE_ID,
MIDDLE_ZONE_ID
],
"imageURL": "",
"id": 3
},
{
"displayName": _("I don't belong anywhere"),
"feedback": {
"incorrect": ITEM_NO_ZONE_FEEDBACK,
"correct": ""
},
"zones": [],
"imageURL": "",
"id": 4,
},
],
"feedback": {
"start": START_FEEDBACK,
"finish": FINISH_FEEDBACK,
},
} }
...@@ -284,6 +284,57 @@ ...@@ -284,6 +284,57 @@
border-top: solid 1px #bdbdbd; border-top: solid 1px #bdbdbd;
} }
.xblock--drag-and-drop .feedback p {
margin: 2px 0;
padding: 0.5em;
border: 2px solid #999;
}
.xblock--drag-and-drop .feedback p.correct {
color: #166e36;
border: 2px solid #166e36;
}
.xblock--drag-and-drop .feedback p.partial {
color: #166e36;
border: 2px solid #166e36;
}
.xblock--drag-and-drop .feedback p.incorrect {
color: #b20610;
border: 2px solid #b20610;
}
/* Font Awesome icons have different width - margin-right tries to compensate it */
.xblock--drag-and-drop .feedback p:before {
content: "\f129";
font-family: FontAwesome;
margin-right: 0.7em;
margin-left: 0.3em;
}
.xblock--drag-and-drop .feedback p.correct:before {
content: "\f00c";
font-family: FontAwesome;
margin-right: 0.3em;
margin-left: 0;
}
.xblock--drag-and-drop .feedback p.partial:before {
content: "\f069";
font-family: FontAwesome;
margin-right: 0.3em;
margin-left: 0;
}
.xblock--drag-and-drop .feedback p.incorrect:before {
content: "\f00d";
font-family: FontAwesome;
margin-right: 0.45em;
margin-left: 0.1em;
}
.xblock--drag-and-drop .popup { .xblock--drag-and-drop .popup {
position: absolute; position: absolute;
display: none; display: none;
......
...@@ -227,12 +227,21 @@ function DragAndDropTemplates(configuration) { ...@@ -227,12 +227,21 @@ function DragAndDropTemplates(configuration) {
}; };
var feedbackTemplate = function(ctx) { var feedbackTemplate = function(ctx) {
var feedback_display = ctx.feedback_html ? 'block' : 'none';
var properties = { attributes: { 'aria-live': 'polite' } }; var properties = { attributes: { 'aria-live': 'polite' } };
var messages = ctx.overall_feedback_messages || [];
var feedback_display = messages.length > 0 ? 'block' : 'none';
var feedback_messages = messages.map(function(message) {
var selector = "p.message";
if (message.message_class) {
selector += "."+message.message_class;
}
return h(selector, {innerHTML: message.message}, []);
});
return ( return (
h('section.feedback', properties, [ h('section.feedback', properties, [
h('h3.title1', { style: { display: feedback_display } }, gettext('Feedback')), h('h3.title1', { style: { display: feedback_display } }, gettext('Feedback')),
h('p.message', { style: { display: feedback_display }, innerHTML: ctx.feedback_html }) h('div.messages', { style: { display: feedback_display } }, feedback_messages)
]) ])
); );
}; };
...@@ -266,20 +275,23 @@ function DragAndDropTemplates(configuration) { ...@@ -266,20 +275,23 @@ function DragAndDropTemplates(configuration) {
var submitAnswerTemplate = function(ctx) { var submitAnswerTemplate = function(ctx) {
var attemptsUsedId = "attempts-used-"+configuration.url_name; var attemptsUsedId = "attempts-used-"+configuration.url_name;
var attemptsUsedDisplay = (ctx.max_attempts && ctx.max_attempts > 0) ? 'inline': 'none'; var attemptsUsedDisplay = (ctx.max_attempts && ctx.max_attempts > 0) ? 'inline': 'none';
var button_enabled = ctx.items.some(function(item) {return item.is_placed;}) &&
(ctx.max_attempts === null || ctx.max_attempts > ctx.num_attempts);
return ( return (
h("section.action-toolbar-item.submit-answer", {}, [ h("section.action-toolbar-item.submit-answer", {}, [
h( h(
"button.btn-brand.submit-answer-button", "button.btn-brand.submit-answer-button",
{disabled: !button_enabled, attributes: {"aria-describedby": attemptsUsedId}}, {
gettext("Submit") disabled: ctx.disable_submit_button || ctx.submit_spinner,
attributes: {"aria-describedby": attemptsUsedId}},
[
(ctx.submit_spinner ? h("span.fa.fa-spin.fa-spinner") : null),
gettext("Submit")
]
), ),
h( h(
"span.attempts-used#"+attemptsUsedId, {style: {display: attemptsUsedDisplay}}, "span.attempts-used#"+attemptsUsedId, {style: {display: attemptsUsedDisplay}},
gettext("You have used {used} of {total} attempts.") gettext("You have used {used} of {total} attempts.")
.replace("{used}", ctx.num_attempts).replace("{total}", ctx.max_attempts) .replace("{used}", ctx.attempts).replace("{total}", ctx.max_attempts)
) )
]) ])
); );
...@@ -444,6 +456,7 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -444,6 +456,7 @@ function DragAndDropBlock(runtime, element, configuration) {
// Set up event handlers: // Set up event handlers:
$(document).on('keydown mousedown touchstart', closePopup); $(document).on('keydown mousedown touchstart', closePopup);
$element.on('click', '.submit-answer-button', doAttempt);
$element.on('click', '.keyboard-help-button', showKeyboardHelp); $element.on('click', '.keyboard-help-button', showKeyboardHelp);
$element.on('keydown', '.keyboard-help-button', function(evt) { $element.on('keydown', '.keyboard-help-button', function(evt) {
runOnKey(evt, RET, showKeyboardHelp); runOnKey(evt, RET, showKeyboardHelp);
...@@ -897,7 +910,7 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -897,7 +910,7 @@ function DragAndDropBlock(runtime, element, configuration) {
if (!zone) { if (!zone) {
return; return;
} }
var url = runtime.handlerUrl(element, 'do_attempt'); var url = runtime.handlerUrl(element, 'drop_item');
var data = { var data = {
val: item_id, val: item_id,
zone: zone, zone: zone,
...@@ -969,6 +982,45 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -969,6 +982,45 @@ function DragAndDropBlock(runtime, element, configuration) {
}); });
}; };
var doAttempt = function(evt) {
evt.preventDefault();
state.submit_spinner = true;
applyState();
$.ajax({
type: 'POST',
url: runtime.handlerUrl(element, "do_attempt"),
data: '{}'
}).done(function(data){
state.attempts = data.attempts;
state.overall_feedback = data.overall_feedback;
if (attemptsRemain()) {
data.misplaced_items.forEach(function(misplaced_item_id) {
delete state.items[misplaced_item_id]
});
} else {
state.finished = true;
}
focusFirstDraggable();
}).always(function() {
state.submit_spinner = false;
applyState();
});
};
var canSubmitAttempt = function() {
return Object.keys(state.items).length > 0 && attemptsRemain();
};
var canReset = function() {
return Object.keys(state.items).length > 0 &&
(configuration.mode !== DragAndDropBlock.ASSESSMENT_MODE || attemptsRemain())
};
var attemptsRemain = function() {
return !configuration.max_attempts || configuration.max_attempts > state.attempts;
};
var render = function() { var render = function() {
var items = configuration.items.map(function(item) { var items = configuration.items.map(function(item) {
var item_user_state = state.items[item.id]; var item_user_state = state.items[item.id];
...@@ -1028,7 +1080,7 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -1028,7 +1080,7 @@ function DragAndDropBlock(runtime, element, configuration) {
show_title: configuration.show_title, show_title: configuration.show_title,
mode: configuration.mode, mode: configuration.mode,
max_attempts: configuration.max_attempts, max_attempts: configuration.max_attempts,
num_attempts: state.num_attempts, attempts: state.attempts,
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,
...@@ -1042,8 +1094,10 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -1042,8 +1094,10 @@ function DragAndDropBlock(runtime, element, configuration) {
last_action_correct: state.last_action_correct, last_action_correct: state.last_action_correct,
item_bank_focusable: item_bank_focusable, item_bank_focusable: item_bank_focusable,
popup_html: state.feedback || '', popup_html: state.feedback || '',
feedback_html: $.trim(state.overall_feedback), overall_feedback_messages: state.overall_feedback,
disable_reset_button: Object.keys(state.items).length == 0, disable_reset_button: !canReset(),
disable_submit_button: !canSubmitAttempt(),
submit_spinner: state.submit_spinner
}; };
return renderView(context); return renderView(context);
......
...@@ -286,10 +286,6 @@ msgid "" ...@@ -286,10 +286,6 @@ msgid ""
msgstr "" msgstr ""
#: templates/html/js_templates.html #: templates/html/js_templates.html
msgid "Zones"
msgstr ""
#: templates/html/js_templates.html
msgid "Use text that is clear and descriptive of the item to be placed" msgid "Use text that is clear and descriptive of the item to be placed"
msgstr "" msgstr ""
...@@ -323,6 +319,7 @@ msgstr "" ...@@ -323,6 +319,7 @@ msgstr ""
msgid "Final Feedback" msgid "Final Feedback"
msgstr "" msgstr ""
#: templates/html/js_templates.html
#: templates/html/drag_and_drop_edit.html #: templates/html/drag_and_drop_edit.html
msgid "Zones" msgid "Zones"
msgstr "" msgstr ""
...@@ -421,7 +418,10 @@ msgid "Correctly placed in: {zone_title}" ...@@ -421,7 +418,10 @@ msgid "Correctly placed in: {zone_title}"
msgstr "" msgstr ""
#: public/js/drag_and_drop.js #: public/js/drag_and_drop.js
msgid "Reset problem" msgid "Reset"
msgstr ""
msgid "Submit"
msgstr "" msgstr ""
#: public/js/drag_and_drop.js #: public/js/drag_and_drop.js
...@@ -485,3 +485,29 @@ msgstr "" ...@@ -485,3 +485,29 @@ msgstr ""
#: public/js/drag_and_drop_edit.js #: public/js/drag_and_drop_edit.js
msgid "Error: " msgid "Error: "
msgstr "" msgstr ""
#: utils.py:18
msgid "Final attempt was used, highest score is {score}"
msgstr ""
#: utils.py:19
msgid "Misplaced items were returned to item bank."
msgstr ""
#: utils.py:24
msgid "Correctly placed {correct_count} item."
msgid_plural "Correctly placed {correct_count} items."
msgstr[0] ""
msgstr[1] ""
#: utils.py:32
msgid "Misplaced {misplaced_count} item."
msgid_plural "Misplaced {misplaced_count} items."
msgstr[0] ""
msgstr[1] ""
#: utils.py:40
msgid "Did not place {missing_count} required item."
msgid_plural "Did not place {missing_count} required items."
msgstr[0] ""
msgstr[1] ""
...@@ -164,6 +164,7 @@ msgstr "" ...@@ -164,6 +164,7 @@ msgstr ""
"Ìf thé välüé ïs nöt sét, ïnfïnïté ättémpts äré ällöwéd. Ⱡ'σяєм #" "Ìf thé välüé ïs nöt sét, ïnfïnïté ättémpts äré ällöwéd. Ⱡ'σяєм #"
#: drag_and_drop_v2.py #: drag_and_drop_v2.py
#: templates/html/drag_and_drop_edit.html
msgid "Show title" msgid "Show title"
msgstr "Shöw tïtlé Ⱡ'σяєм ιρѕυм ∂σłσ#" msgstr "Shöw tïtlé Ⱡ'σяєм ιρѕυм ∂σłσ#"
...@@ -173,6 +174,7 @@ msgstr "" ...@@ -173,6 +174,7 @@ msgstr ""
"Dïspläý thé tïtlé tö thé léärnér? Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тє#" "Dïspläý thé tïtlé tö thé léärnér? Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тє#"
#: drag_and_drop_v2.py #: drag_and_drop_v2.py
#: templates/html/drag_and_drop_edit.html
msgid "Problem text" msgid "Problem text"
msgstr "Prößlém téxt Ⱡ'σяєм ιρѕυм ∂σłσя ѕ#" msgstr "Prößlém téxt Ⱡ'σяєм ιρѕυм ∂σłσя ѕ#"
...@@ -349,6 +351,7 @@ msgstr "" ...@@ -349,6 +351,7 @@ msgstr ""
"äütömätïç wïdth): Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σ#" "äütömätïç wïdth): Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σ#"
#: templates/html/js_templates.html #: templates/html/js_templates.html
#: templates/html/drag_and_drop_edit.html
msgid "Zones" msgid "Zones"
msgstr "Zönés Ⱡ'σяєм ιρѕ#" msgstr "Zönés Ⱡ'σяєм ιρѕ#"
...@@ -379,22 +382,10 @@ msgid "Problem mode" ...@@ -379,22 +382,10 @@ msgid "Problem mode"
msgstr "Prößlém mödé Ⱡ'σяєм ιρѕυм ∂σłσя ѕ#" msgstr "Prößlém mödé Ⱡ'σяєм ιρѕυм ∂σłσя ѕ#"
#: templates/html/drag_and_drop_edit.html #: templates/html/drag_and_drop_edit.html
msgid "Show title"
msgstr "Shöw tïtlé Ⱡ'σяєм ιρѕυм ∂σłσ#"
#: templates/html/drag_and_drop_edit.html
msgid "Maximum score" msgid "Maximum score"
msgstr "Mäxïmüm sçöré Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#" msgstr "Mäxïmüm sçöré Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
#: templates/html/drag_and_drop_edit.html #: templates/html/drag_and_drop_edit.html
msgid "Problem text"
msgstr "Prößlém téxt Ⱡ'σяєм ιρѕυм ∂σłσя ѕ#"
#: templates/html/drag_and_drop_edit.html
msgid "Show \"Problem\" heading"
msgstr "Shöw \"Prößlém\" héädïng Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢#"
#: templates/html/drag_and_drop_edit.html
msgid "Introductory Feedback" msgid "Introductory Feedback"
msgstr "Ìntrödüçtörý Féédßäçk Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #" msgstr "Ìntrödüçtörý Féédßäçk Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #"
...@@ -403,10 +394,6 @@ msgid "Final Feedback" ...@@ -403,10 +394,6 @@ msgid "Final Feedback"
msgstr "Fïnäl Féédßäçk Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт#" msgstr "Fïnäl Féédßäçk Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт#"
#: templates/html/drag_and_drop_edit.html #: templates/html/drag_and_drop_edit.html
msgid "Zones"
msgstr "Zönés Ⱡ'σяєм ιρѕ#"
#: templates/html/drag_and_drop_edit.html
msgid "Background URL" msgid "Background URL"
msgstr "Bäçkgröünd ÛRL Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт#" msgstr "Bäçkgröünd ÛRL Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт#"
...@@ -506,8 +493,11 @@ msgid "Correctly placed in: {zone_title}" ...@@ -506,8 +493,11 @@ msgid "Correctly placed in: {zone_title}"
msgstr "Çörréçtlý pläçéd ïn: {zone_title} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #" msgstr "Çörréçtlý pläçéd ïn: {zone_title} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #"
#: public/js/drag_and_drop.js #: public/js/drag_and_drop.js
msgid "Reset problem" msgid "Reset"
msgstr "Rését prößlém Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#" msgstr "Rését Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
msgid "Submit"
msgstr "Süßmït Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
#: public/js/drag_and_drop.js #: public/js/drag_and_drop.js
msgid "Feedback" msgid "Feedback"
...@@ -584,3 +574,30 @@ msgstr "Nöné Ⱡ'σяєм ι#" ...@@ -584,3 +574,30 @@ msgstr "Nöné Ⱡ'σяєм ι#"
#: public/js/drag_and_drop_edit.js #: public/js/drag_and_drop_edit.js
msgid "Error: " msgid "Error: "
msgstr "Érrör: Ⱡ'σяєм ιρѕυм #" msgstr "Érrör: Ⱡ'σяєм ιρѕυм #"
#: utils.py:18
msgid "Fïnäl ättémpt wäs üséd, hïghést sçöré ïs {score} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #"
msgstr ""
#: utils.py:19
msgid "Mïspläçéd ïtéms wéré rétürnéd tö ïtém ßänk. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #"
msgstr ""
#: utils.py:24
msgid "Çörréçtlý pläçéd {correct_count} ïtém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#"
msgid_plural "Çörréçtlý pläçéd {correct_count} ïtéms. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє#"
msgstr[0] ""
msgstr[1] ""
#: utils.py:32
msgid "Mïspläçéd {misplaced_count} ïtém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт,#"
msgid_plural "Mïspläçéd {misplaced_count} ïtéms. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #"
msgstr[0] ""
msgstr[1] ""
#: utils.py:40
msgid "Dïd nöt pläçé {missing_count} réqüïréd ïtém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢#"
msgid_plural "Dïd nöt pläçé {missing_count} réqüïréd ïtéms. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#"
msgstr[0] ""
msgstr[1] ""
\ No newline at end of file
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# """ Drag and Drop v2 XBlock - Utils """
from collections import namedtuple
# Make '_' a no-op so we can scrape strings
def _(text): def _(text):
""" Dummy `gettext` replacement to make string extraction tools scrape strings marked for translation """
return text return text
def ngettext_fallback(text_singular, text_plural, number):
""" Dummy `ngettext` replacement to make string extraction tools scrape strings marked for translation """
if number == 1:
return text_singular
else:
return text_plural
class DummyTranslationService(object):
"""
Dummy drop-in replacement for i18n XBlock service
"""
gettext = _
ngettext = ngettext_fallback
class FeedbackMessages(object):
"""
Feedback messages collection
"""
class MessageClasses(object):
"""
Namespace for message classes
"""
CORRECT_SOLUTION = "correct"
PARTIAL_SOLUTION = "partial"
INCORRECT_SOLUTION = "incorrect"
CORRECTLY_PLACED = CORRECT_SOLUTION
MISPLACED = INCORRECT_SOLUTION
NOT_PLACED = INCORRECT_SOLUTION
FINAL_ATTEMPT_TPL = _('Final attempt was used, highest score is {score}')
MISPLACED_ITEMS_RETURNED = _('Misplaced item(s) were returned to item bank.')
@staticmethod
def correctly_placed(number, ngettext=ngettext_fallback):
"""
Formats "correctly placed items" message
"""
return ngettext(
'Correctly placed {correct_count} item.',
'Correctly placed {correct_count} items.',
number
).format(correct_count=number)
@staticmethod
def misplaced(number, ngettext=ngettext_fallback):
"""
Formats "misplaced items" 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.',
number
).format(misplaced_count=number)
@staticmethod
def not_placed(number, ngettext=ngettext_fallback):
"""
Formats "did not place required items" message
"""
return ngettext(
'Did not place {missing_count} required item.',
'Did not place {missing_count} required items.',
number
).format(missing_count=number)
FeedbackMessage = namedtuple("FeedbackMessage", ["message", "message_class"]) # pylint: disable=invalid-name
...@@ -6,18 +6,19 @@ max-line-length=120 ...@@ -6,18 +6,19 @@ max-line-length=120
[MESSAGES CONTROL] [MESSAGES CONTROL]
disable= disable=
attribute-defined-outside-init,
locally-disabled, locally-disabled,
missing-docstring,
too-many-ancestors, too-many-ancestors,
too-many-arguments,
too-many-branches,
too-many-instance-attributes, too-many-instance-attributes,
too-few-public-methods, too-few-public-methods,
too-many-public-methods, too-many-public-methods,
unused-argument, unused-argument
invalid-name,
no-member
[SIMILARITIES] [SIMILARITIES]
min-similarity-lines=4 min-similarity-lines=4
[OPTIONS]
good-names=_,__,log,loader
method-rgx=_?[a-z_][a-z0-9_]{2,40}$
function-rgx=_?[a-z_][a-z0-9_]{2,40}$
method-name-hint=_?[a-z_][a-z0-9_]{2,40}$
function-name-hint=_?[a-z_][a-z0-9_]{2,40}$
...@@ -17,6 +17,7 @@ from drag_and_drop_v2.default_data import ( ...@@ -17,6 +17,7 @@ from drag_and_drop_v2.default_data import (
ITEM_CORRECT_FEEDBACK, ITEM_INCORRECT_FEEDBACK, ITEM_NO_ZONE_FEEDBACK, ITEM_CORRECT_FEEDBACK, ITEM_INCORRECT_FEEDBACK, ITEM_NO_ZONE_FEEDBACK,
ITEM_ANY_ZONE_FEEDBACK, START_FEEDBACK, FINISH_FEEDBACK ITEM_ANY_ZONE_FEEDBACK, START_FEEDBACK, FINISH_FEEDBACK
) )
from drag_and_drop_v2.utils import FeedbackMessages
from .test_base import BaseIntegrationTest from .test_base import BaseIntegrationTest
...@@ -481,6 +482,23 @@ class DefaultAssessmentDataTestMixin(DefaultDataTestMixin): ...@@ -481,6 +482,23 @@ class DefaultAssessmentDataTestMixin(DefaultDataTestMixin):
""".format(max_attempts=self.MAX_ATTEMPTS) """.format(max_attempts=self.MAX_ATTEMPTS)
class AssessmentTestMixin(object):
"""
Provides helper methods for assessment tests
"""
@staticmethod
def _wait_until_enabled(element):
wait = WebDriverWait(element, 2)
wait.until(lambda e: e.is_displayed() and e.get_attribute('disabled') is None)
def click_submit(self):
submit_button = self._get_submit_button()
self._wait_until_enabled(submit_button)
submit_button.click()
@ddt @ddt
class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest): class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest):
""" """
...@@ -512,7 +530,9 @@ class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseInt ...@@ -512,7 +530,9 @@ class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseInt
@ddt @ddt
class AssessmentInteractionTest(DefaultAssessmentDataTestMixin, InteractionTestBase, BaseIntegrationTest): class AssessmentInteractionTest(
DefaultAssessmentDataTestMixin, AssessmentTestMixin, InteractionTestBase, BaseIntegrationTest
):
""" """
Testing interactions with Drag and Drop XBlock against default data in assessment mode. Testing interactions with Drag and Drop XBlock against default data in assessment mode.
All interactions are tested using mouse (action_key=None) and four different keyboard action keys. All interactions are tested using mouse (action_key=None) and four different keyboard action keys.
...@@ -562,6 +582,97 @@ class AssessmentInteractionTest(DefaultAssessmentDataTestMixin, InteractionTestB ...@@ -562,6 +582,97 @@ class AssessmentInteractionTest(DefaultAssessmentDataTestMixin, InteractionTestB
self.assertEqual(submit_button.get_attribute('disabled'), None) self.assertEqual(submit_button.get_attribute('disabled'), None)
def test_misplaced_items_returned_to_bank(self):
"""
Test items placed to incorrect zones are returned to item bank after submitting solution
"""
correct_items = {0: TOP_ZONE_ID}
misplaced_items = {1: BOTTOM_ZONE_ID, 2: MIDDLE_ZONE_ID}
for item_id, zone_id in correct_items.iteritems():
self.place_item(item_id, zone_id)
for item_id, zone_id in misplaced_items.iteritems():
self.place_item(item_id, zone_id)
self.click_submit()
for item_id in correct_items:
self.assert_placed_item(item_id, TOP_ZONE_TITLE, assessment_mode=True)
for item_id in misplaced_items:
self.assert_reverted_item(item_id)
def test_max_attempts_reached_submit_and_reset_disabled(self):
"""
Test "Submit" and "Reset" buttons are disabled when no more attempts remaining
"""
self.place_item(0, TOP_ZONE_ID)
submit_button, reset_button = self._get_submit_button(), self._get_reset_button()
attempts_info = self._get_attempts_info()
for index in xrange(self.MAX_ATTEMPTS):
expected_text = "You have used {num} of {max} attempts.".format(num=index, max=self.MAX_ATTEMPTS)
self.assertEqual(attempts_info.text, expected_text) # precondition check
self.assertEqual(submit_button.get_attribute('disabled'), None)
self.assertEqual(reset_button.get_attribute('disabled'), None)
self.click_submit()
self.assertEqual(submit_button.get_attribute('disabled'), 'true')
self.assertEqual(reset_button.get_attribute('disabled'), 'true')
def test_do_attempt_feedback_is_updated(self):
"""
Test updating overall feedback after submitting solution in assessment mode
"""
# used keyboard mode to avoid bug/feature with selenium "selecting" everything instead of dragging an element
self.place_item(0, TOP_ZONE_ID, Keys.RETURN)
self.click_submit()
feedback_lines = [
"FEEDBACK",
FeedbackMessages.correctly_placed(1),
FeedbackMessages.not_placed(3),
START_FEEDBACK
]
expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback)
self.place_item(1, BOTTOM_ZONE_ID, Keys.RETURN)
self.click_submit()
feedback_lines = [
"FEEDBACK",
FeedbackMessages.correctly_placed(1),
FeedbackMessages.misplaced(1),
FeedbackMessages.not_placed(2),
FeedbackMessages.MISPLACED_ITEMS_RETURNED,
START_FEEDBACK
]
expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback)
# reach final attempt
for _ in xrange(self.MAX_ATTEMPTS-3):
self.click_submit()
self.place_item(1, MIDDLE_ZONE_ID, Keys.RETURN)
self.place_item(2, BOTTOM_ZONE_ID, Keys.RETURN)
self.place_item(3, TOP_ZONE_ID, Keys.RETURN)
self.click_submit()
feedback_lines = [
"FEEDBACK",
FeedbackMessages.correctly_placed(4),
FINISH_FEEDBACK,
FeedbackMessages.FINAL_ATTEMPT_TPL.format(score=1.0)
]
expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback)
class MultipleValidOptionsInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest): class MultipleValidOptionsInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest):
......
[REPORTS]
reports=no
[FORMAT]
max-line-length=120
[MESSAGES CONTROL]
disable=
attribute-defined-outside-init,
locally-disabled,
missing-docstring,
abstract-class-little-used,
too-many-ancestors,
too-few-public-methods,
too-many-public-methods,
invalid-name,
no-member
[SIMILARITIES]
min-similarity-lines=4
[OPTIONS]
max-args=6
...@@ -47,16 +47,22 @@ ...@@ -47,16 +47,22 @@
"id": 1 "id": 1
}, },
{ {
"displayName": "3",
"imageURL": "",
"expandedImageURL": "",
"id": 2
},
{
"displayName": "X", "displayName": "X",
"imageURL": "/static/test_url_expansion", "imageURL": "/static/test_url_expansion",
"expandedImageURL": "/course/test-course/assets/test_url_expansion", "expandedImageURL": "/course/test-course/assets/test_url_expansion",
"id": 2 "id": 3
}, },
{ {
"displayName": "", "displayName": "",
"imageURL": "http://placehold.it/200x100", "imageURL": "http://placehold.it/200x100",
"expandedImageURL": "http://placehold.it/200x100", "expandedImageURL": "http://placehold.it/200x100",
"id": 3 "id": 4
} }
] ]
} }
...@@ -41,6 +41,16 @@ ...@@ -41,6 +41,16 @@
"id": 1 "id": 1
}, },
{ {
"displayName": "3",
"feedback": {
"incorrect": "No 3",
"correct": "Yes 3"
},
"zone": "zone-2",
"imageURL": "",
"id": 2
},
{
"displayName": "X", "displayName": "X",
"feedback": { "feedback": {
"incorrect": "", "incorrect": "",
...@@ -48,7 +58,7 @@ ...@@ -48,7 +58,7 @@
}, },
"zone": "none", "zone": "none",
"imageURL": "/static/test_url_expansion", "imageURL": "/static/test_url_expansion",
"id": 2 "id": 3
}, },
{ {
"displayName": "", "displayName": "",
...@@ -58,7 +68,7 @@ ...@@ -58,7 +68,7 @@
}, },
"zone": "none", "zone": "none",
"imageURL": "http://placehold.it/200x100", "imageURL": "http://placehold.it/200x100",
"id": 3 "id": 4
} }
], ],
......
...@@ -69,20 +69,20 @@ class BasicTests(TestCaseMixin, unittest.TestCase): ...@@ -69,20 +69,20 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
self.assertEqual(self.call_handler("get_user_state"), { self.assertEqual(self.call_handler("get_user_state"), {
'items': {}, 'items': {},
'finished': False, 'finished': False,
"num_attempts": 0, "attempts": 0,
'overall_feedback': START_FEEDBACK, 'overall_feedback': [{"message": START_FEEDBACK, "message_class": None}]
}) })
assert_user_state_empty() assert_user_state_empty()
# Drag three items into the correct spot: # Drag three items into the correct spot:
data = {"val": 0, "zone": TOP_ZONE_ID, "x_percent": "33%", "y_percent": "11%"} data = {"val": 0, "zone": TOP_ZONE_ID, "x_percent": "33%", "y_percent": "11%"}
self.call_handler('do_attempt', data) self.call_handler(self.DROP_ITEM_HANDLER, data)
data = {"val": 1, "zone": MIDDLE_ZONE_ID, "x_percent": "67%", "y_percent": "80%"} data = {"val": 1, "zone": MIDDLE_ZONE_ID, "x_percent": "67%", "y_percent": "80%"}
self.call_handler('do_attempt', data) self.call_handler(self.DROP_ITEM_HANDLER, data)
data = {"val": 2, "zone": BOTTOM_ZONE_ID, "x_percent": "99%", "y_percent": "95%"} data = {"val": 2, "zone": BOTTOM_ZONE_ID, "x_percent": "99%", "y_percent": "95%"}
self.call_handler('do_attempt', data) self.call_handler(self.DROP_ITEM_HANDLER, data)
data = {"val": 3, "zone": MIDDLE_ZONE_ID, "x_percent": "67%", "y_percent": "80%"} data = {"val": 3, "zone": MIDDLE_ZONE_ID, "x_percent": "67%", "y_percent": "80%"}
self.call_handler('do_attempt', data) self.call_handler(self.DROP_ITEM_HANDLER, data)
# Check the result: # Check the result:
self.assertTrue(self.block.completed) self.assertTrue(self.block.completed)
...@@ -100,8 +100,8 @@ class BasicTests(TestCaseMixin, unittest.TestCase): ...@@ -100,8 +100,8 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
'3': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, "zone": MIDDLE_ZONE_ID}, '3': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, "zone": MIDDLE_ZONE_ID},
}, },
'finished': True, 'finished': True,
"num_attempts": 0, "attempts": 0,
'overall_feedback': FINISH_FEEDBACK, 'overall_feedback': [{"message": FINISH_FEEDBACK, "message_class": None}],
}) })
# Reset to initial conditions # Reset to initial conditions
......
import json import json
import random
import re import re
from mock import patch from mock import patch
...@@ -31,10 +32,23 @@ def make_block(): ...@@ -31,10 +32,23 @@ def make_block():
return drag_and_drop_v2.DragAndDropBlock(runtime, field_data, scope_ids=scope_ids) return drag_and_drop_v2.DragAndDropBlock(runtime, field_data, scope_ids=scope_ids)
def generate_max_and_attempts(count=100):
for _ in xrange(count):
max_attempts = random.randint(1, 100)
attempts = random.randint(0, 100)
expect_validation_error = max_attempts <= attempts
yield max_attempts, attempts, expect_validation_error
class TestCaseMixin(object): class TestCaseMixin(object):
""" Helpful mixins for unittest TestCase subclasses """ """ Helpful mixins for unittest TestCase subclasses """
maxDiff = None maxDiff = None
DROP_ITEM_HANDLER = 'drop_item'
DO_ATTEMPT_HANDLER = 'do_attempt'
RESET_HANDLER = 'reset'
USER_STATE_HANDLER = 'get_user_state'
def patch_workbench(self): def patch_workbench(self):
self.apply_patch( self.apply_patch(
'workbench.runtime.WorkbenchRuntime.local_resource_url', 'workbench.runtime.WorkbenchRuntime.local_resource_url',
......
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