Commit 73ea5963 by Eugeny Kolpakov Committed by GitHub

Merge pull request #108 from open-craft/mtyaka/score-display

Display grade information
parents 24c6ceda bc4a6f47
...@@ -7,6 +7,9 @@ before_install: ...@@ -7,6 +7,9 @@ before_install:
- "sh -e /etc/init.d/xvfb start" - "sh -e /etc/init.d/xvfb start"
install: install:
- "sh install_test_deps.sh" - "sh install_test_deps.sh"
# Remove the next two lines after our test suite is compatible with selenium 3.
- "pip uninstall -y selenium"
- "pip install selenium==2.53.0"
- "pip uninstall -y xblock-drag-and-drop-v2" - "pip uninstall -y xblock-drag-and-drop-v2"
- "python setup.py sdist" - "python setup.py sdist"
- "pip install dist/xblock-drag-and-drop-v2-2.0.11.tar.gz" - "pip install dist/xblock-drag-and-drop-v2-2.0.11.tar.gz"
......
...@@ -500,13 +500,13 @@ This command scrapes all the strings in all `*.py` files in `drag_and_drop_v2` f ...@@ -500,13 +500,13 @@ This command scrapes all the strings in all `*.py` files in `drag_and_drop_v2` f
in `drag_and_drop_v2` folder: in `drag_and_drop_v2` folder:
``` ```
~/xblock-drag-and-drop-v2/drag_and_drop_v2$ find . -name "*.py" | xargs xgettext --language=python ~/xblock-drag-and-drop-v2/drag_and_drop_v2$ find . -name "*.py" | xargs xgettext --language=python --add-comments="Translators:"
``` ```
Javascript command is a little bit more verbose: Javascript command is a little bit more verbose:
``` ```
~/xblock-drag-and-drop-v2/drag_and_drop_v2$ find . -name "*.js" -o -path ./public/js/vendor -prune -a -type f | xargs xgettext --language=javascript --from-code=utf-8 ~/xblock-drag-and-drop-v2/drag_and_drop_v2$ find . -name "*.js" -o -path ./public/js/vendor -prune -a -type f | xargs xgettext --language=javascript --from-code=utf-8 --add-comments="Translators:"
``` ```
Note that both commands generate partial `messages.po` file - JS or python only, while `test.po` is supposed to contain Note that both commands generate partial `messages.po` file - JS or python only, while `test.po` is supposed to contain
......
...@@ -107,8 +107,8 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -107,8 +107,8 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
) )
weight = Float( weight = Float(
display_name=_("Maximum score"), display_name=_("Problem Weight"),
help=_("The maximum score the learner can receive for the problem."), help=_("Defines the number of points the problem is worth."),
scope=Scope.settings, scope=Scope.settings,
default=1, default=1,
) )
...@@ -172,6 +172,23 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -172,6 +172,23 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
block_settings_key = 'drag-and-drop-v2' block_settings_key = 'drag-and-drop-v2'
has_score = True has_score = True
def max_score(self): # pylint: disable=no-self-use
"""
Return the problem's max score, which for DnDv2 always equals 1.
Required by the grading system in the LMS.
"""
return 1
def _learner_raw_score(self):
"""
Calculate raw score for learner submission.
As it is calculated as ratio of correctly placed (or left in bank in case of decoys) items to
total number of items, it lays in interval [0..1]
"""
correct_count, total_count = self._get_item_stats()
return correct_count / float(total_count)
@XBlock.supports("multi_device") # Enable this block for use in the mobile app via webview @XBlock.supports("multi_device") # Enable this block for use in the mobile app via webview
def student_view(self, context): def student_view(self, context):
""" """
...@@ -232,6 +249,8 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -232,6 +249,8 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
"mode": self.mode, "mode": self.mode,
"zones": self.zones, "zones": self.zones,
"max_attempts": self.max_attempts, "max_attempts": self.max_attempts,
"graded": getattr(self, 'graded', False),
"weighted_max_score": self.max_score() * self.weight,
"max_items_per_zone": self.max_items_per_zone, "max_items_per_zone": self.max_items_per_zone,
# SDK doesn't supply url_name. # SDK doesn't supply url_name.
"url_name": getattr(self, 'url_name', ''), "url_name": getattr(self, 'url_name', ''),
...@@ -413,6 +432,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -413,6 +432,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
return { return {
'correct': correct, 'correct': correct,
'attempts': self.attempts, 'attempts': self.attempts,
'grade': self._get_grade_if_set(),
'misplaced_items': list(misplaced_ids), 'misplaced_items': list(misplaced_ids),
'feedback': self._present_feedback(feedback_msgs), 'feedback': self._present_feedback(feedback_msgs),
'overall_feedback': self._present_feedback(overall_feedback_msgs) 'overall_feedback': self._present_feedback(overall_feedback_msgs)
...@@ -571,9 +591,14 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -571,9 +591,14 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
feedback_msgs.append(FeedbackMessage(problem_feedback_message, problem_feedback_class)) feedback_msgs.append(FeedbackMessage(problem_feedback_message, problem_feedback_class))
if not self.attempts_remain: if self.weight > 0:
if self.attempts_remain:
grade_feedback_template = FeedbackMessages.GRADE_FEEDBACK_TPL
else:
grade_feedback_template = FeedbackMessages.FINAL_ATTEMPT_TPL
feedback_msgs.append( feedback_msgs.append(
FeedbackMessage(FeedbackMessages.FINAL_ATTEMPT_TPL.format(score=self.grade), grade_feedback_class) FeedbackMessage(grade_feedback_template.format(score=self.grade), grade_feedback_class)
) )
return feedback_msgs, misplaced_ids return feedback_msgs, misplaced_ids
...@@ -608,6 +633,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -608,6 +633,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
return { return {
'correct': is_correct, 'correct': is_correct,
'grade': self._get_grade_if_set(),
'finished': self._is_answer_correct(), 'finished': self._is_answer_correct(),
'overall_feedback': self._present_feedback(overall_feedback), 'overall_feedback': self._present_feedback(overall_feedback),
'feedback': self._present_feedback([item_feedback]) 'feedback': self._present_feedback([item_feedback])
...@@ -654,7 +680,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -654,7 +680,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
""" """
# pylint: disable=fixme # pylint: disable=fixme
# TODO: (arguable) split this method into "clean" functions (with no side effects and implicit state) # TODO: (arguable) split this method into "clean" functions (with no side effects and implicit state)
# This method implicitly depends on self.item_state (via _is_answer_correct and _get_grade) # This method implicitly depends on self.item_state (via _is_answer_correct and _calculate_grade)
# and also updates self.grade if some conditions are met. As a result this method implies some order of # and also updates self.grade if some conditions are met. As a result this method implies some order of
# invocation: # invocation:
# * it should be called after learner-caused updates to self.item_state is applied # * it should be called after learner-caused updates to self.item_state is applied
...@@ -666,9 +692,10 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -666,9 +692,10 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
# There's no going back from "completed" status to "incomplete" # There's no going back from "completed" status to "incomplete"
self.completed = self.completed or self._is_answer_correct() or not self.attempts_remain self.completed = self.completed or self._is_answer_correct() or not self.attempts_remain
grade = self._get_grade() grade = self._calculate_grade()
# ... and from higher grade to lower # ... and from higher grade to lower
if grade > self.grade: current_grade = self._get_grade_if_set()
if current_grade is None or grade > current_grade:
self.grade = grade self.grade = grade
self._publish_grade() self._publish_grade()
...@@ -751,6 +778,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -751,6 +778,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
'items': item_state, 'items': item_state,
'finished': is_finished, 'finished': is_finished,
'attempts': self.attempts, 'attempts': self.attempts,
'grade': self._get_grade_if_set(),
'overall_feedback': self._present_feedback(overall_feedback_msgs) 'overall_feedback': self._present_feedback(overall_feedback_msgs)
} }
...@@ -872,12 +900,21 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -872,12 +900,21 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
return ItemStats(required, placed, correctly_placed, decoy, decoy_in_bank) return ItemStats(required, placed, correctly_placed, decoy, decoy_in_bank)
def _get_grade(self): def _calculate_grade(self):
""" """
Returns the student's grade for this block. Calculates the student's grade for this block based on current item state.
""" """
correct_count, total_count = self._get_item_stats() return self._learner_raw_score() * self.weight
return correct_count / float(total_count) * self.weight
def _get_grade_if_set(self):
"""
Returns student's grade if already explicitly set, otherwise returns None.
This is different from self.grade which returns 0 by default.
"""
if self.fields['grade'].is_set_on(self):
return self.grade
else:
return None
def _answer_correctness(self): def _answer_correctness(self):
""" """
......
...@@ -39,9 +39,10 @@ ...@@ -39,9 +39,10 @@
/* Header, instruction text, etc. */ /* Header, instruction text, etc. */
.xblock--drag-and-drop .problem-title { .xblock--drag-and-drop .problem-progress {
display: inline-block; display: inline-block;
margin: 0 0 15px 0; color: #5e5e5e;
font-size: 0.875em;
} }
.xblock--drag-and-drop .problem p { .xblock--drag-and-drop .problem p {
......
function DragAndDropTemplates(configuration) { function DragAndDropTemplates(configuration) {
"use strict"; "use strict";
var h = virtualDom.h; var h = virtualDom.h;
// Set up a mock for gettext if it isn't available in the client runtime:
if (!window.gettext) { window.gettext = function gettext_stub(string) { return string; }; }
var itemSpinnerTemplate = function(item) { var itemSpinnerTemplate = function(item) {
if (!item.xhr_active) { if (!item.xhr_active) {
...@@ -184,7 +182,7 @@ function DragAndDropTemplates(configuration) { ...@@ -184,7 +182,7 @@ function DragAndDropTemplates(configuration) {
var item_wrapper = 'div.item-wrapper.item-align.item-align-' + zone.align; 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 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 = configuration.url_name + '-zone-' + zone.uid + '-description';
if (items_in_zone.length == 0) { if (items_in_zone.length == 0) {
var zone_description = h( var zone_description = h(
'div', 'div',
...@@ -443,8 +441,63 @@ function DragAndDropTemplates(configuration) { ...@@ -443,8 +441,63 @@ function DragAndDropTemplates(configuration) {
) )
}; };
var progressTemplate = function(ctx) {
// Formats a number to 4 decimals without trailing zeros
// (1.00 -> '1'; 1.50 -> '1.5'; 1.333333333 -> '1.3333').
var formatNumber = function(n) { return n.toFixed(4).replace(/\.?0+$/, ''); };
var is_graded = ctx.graded && ctx.weighted_max_score > 0;
var progress_template;
if (ctx.grade !== null && ctx.weighted_max_score > 0) {
if (is_graded) {
progress_template = ngettext(
// Translators: {earned} is the number of points earned. {possible} is the total number of points (examples: 0/1, 1/1, 2/3, 5/10). The total number of points will always be at least 1. We pluralize based on the total number of points (example: 0/1 point; 1/2 points).
'{earned}/{possible} point (graded)',
'{earned}/{possible} points (graded)',
ctx.weighted_max_score
);
} else {
progress_template = ngettext(
// Translators: {earned} is the number of points earned. {possible} is the total number of points (examples: 0/1, 1/1, 2/3, 5/10). The total number of points will always be at least 1. We pluralize based on the total number of points (example: 0/1 point; 1/2 points).
'{earned}/{possible} point (ungraded)',
'{earned}/{possible} points (ungraded)',
ctx.weighted_max_score
);
}
progress_template = progress_template.replace('{earned}', formatNumber(ctx.grade));
} else {
if (is_graded) {
progress_template = ngettext(
// Translators: {possible} is the number of points possible (examples: 1, 3, 10).
'{possible} point possible (graded)',
'{possible} points possible (graded)',
ctx.weighted_max_score
);
} else {
progress_template = ngettext(
// Translators: {possible} is the number of points possible (examples: 1, 3, 10).
'{possible} point possible (ungraded)',
'{possible} points possible (ungraded)',
ctx.weighted_max_score
);
}
}
var progress_text = progress_template.replace('{possible}', formatNumber(ctx.weighted_max_score));
return h('div.problem-progress', {
id: configuration.url_name + '-problem-progress',
attributes: {'role': 'status', 'aria-live': 'polite'}
}, progress_text);
};
var mainTemplate = function(ctx) { var mainTemplate = function(ctx) {
var problemTitle = ctx.show_title ? h('h3.problem-title', {innerHTML: ctx.title_html}) : null; var problemProgress = progressTemplate(ctx);
var problemTitle = null;
if (ctx.show_title) {
problemTitle = h('h3.problem-title', {
innerHTML: ctx.title_html,
attributes: {'aria-describedby': problemProgress.properties.id}
});
}
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 the bank here, including placeholders. Placed
// items will be rendered by zoneTemplate. // items will be rendered by zoneTemplate.
...@@ -463,6 +516,7 @@ function DragAndDropTemplates(configuration) { ...@@ -463,6 +516,7 @@ function DragAndDropTemplates(configuration) {
return ( return (
h('section.themed-xblock.xblock--drag-and-drop', [ h('section.themed-xblock.xblock--drag-and-drop', [
problemTitle, problemTitle,
problemProgress,
h('section.problem', [ h('section.problem', [
problemHeader, problemHeader,
h('p', {innerHTML: ctx.problem_html}), h('p', {innerHTML: ctx.problem_html}),
...@@ -500,6 +554,12 @@ function DragAndDropTemplates(configuration) { ...@@ -500,6 +554,12 @@ function DragAndDropTemplates(configuration) {
function DragAndDropBlock(runtime, element, configuration) { function DragAndDropBlock(runtime, element, configuration) {
"use strict"; "use strict";
// Set up a mock for gettext if it isn't available in the client runtime:
if (!window.gettext) {
window.gettext = function gettext_stub(string) { return string; };
window.ngettext = function ngettext_stub(strA, strB, n) { return n == 1 ? strA : strB; };
}
DragAndDropBlock.STANDARD_MODE = 'standard'; DragAndDropBlock.STANDARD_MODE = 'standard';
DragAndDropBlock.ASSESSMENT_MODE = 'assessment'; DragAndDropBlock.ASSESSMENT_MODE = 'assessment';
...@@ -510,9 +570,6 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -510,9 +570,6 @@ function DragAndDropBlock(runtime, element, configuration) {
var renderView = DragAndDropTemplates(configuration); var renderView = DragAndDropTemplates(configuration);
// Set up a mock for gettext if it isn't available in the client runtime:
if (!window.gettext) { window.gettext = function gettext_stub(string) { return string; }; }
var $element = $(element); var $element = $(element);
element = $element[0]; // TODO: This line can be removed when we no longer support Dogwood. element = $element[0]; // TODO: This line can be removed when we no longer support Dogwood.
// It works around this Studio bug: https://github.com/edx/edx-platform/pull/11433 // It works around this Studio bug: https://github.com/edx/edx-platform/pull/11433
...@@ -1057,6 +1114,7 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -1057,6 +1114,7 @@ function DragAndDropBlock(runtime, element, configuration) {
if (configuration.mode === DragAndDropBlock.STANDARD_MODE) { if (configuration.mode === DragAndDropBlock.STANDARD_MODE) {
state.last_action_correct = data.correct; state.last_action_correct = data.correct;
state.feedback = data.feedback; state.feedback = data.feedback;
state.grade = data.grade;
if (!data.correct) { if (!data.correct) {
delete state.items[item_id]; delete state.items[item_id];
} }
...@@ -1144,6 +1202,7 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -1144,6 +1202,7 @@ function DragAndDropBlock(runtime, element, configuration) {
data: '{}' data: '{}'
}).done(function(data){ }).done(function(data){
state.attempts = data.attempts; state.attempts = data.attempts;
state.grade = data.grade;
state.feedback = data.feedback; state.feedback = data.feedback;
state.overall_feedback = data.overall_feedback; state.overall_feedback = data.overall_feedback;
state.last_action_correct = data.correct; state.last_action_correct = data.correct;
...@@ -1256,6 +1315,8 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -1256,6 +1315,8 @@ 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,
graded: configuration.graded,
weighted_max_score: configuration.weighted_max_score,
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,
...@@ -1268,6 +1329,7 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -1268,6 +1329,7 @@ function DragAndDropBlock(runtime, element, configuration) {
items: items, items: items,
// state - parts that can change: // state - parts that can change:
attempts: state.attempts, attempts: state.attempts,
grade: state.grade,
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,
feedback_messages: state.feedback, feedback_messages: state.feedback,
......
...@@ -51,6 +51,9 @@ ...@@ -51,6 +51,9 @@
<span>{% trans fields.weight.display_name %}</span> <span>{% trans fields.weight.display_name %}</span>
<input class="weight" type="number" step="0.1" value="{{ self.weight|unlocalize }}" /> <input class="weight" type="number" step="0.1" value="{{ self.weight|unlocalize }}" />
</label> </label>
<div id="weight-description-{{id_suffix}}" class="assessment-setting form-help">
{% trans fields.weight.help %}
</div>
<label class="h4"> <label class="h4">
<span>{% trans fields.question_text.display_name %}</span> <span>{% trans fields.question_text.display_name %}</span>
......
...@@ -160,11 +160,11 @@ msgid "Display the heading \"Problem\" above the problem text?" ...@@ -160,11 +160,11 @@ msgid "Display the heading \"Problem\" above the problem text?"
msgstr "" msgstr ""
#: drag_and_drop_v2.py #: drag_and_drop_v2.py
msgid "Maximum score" msgid "Problem Weight"
msgstr "" msgstr ""
#: drag_and_drop_v2.py #: drag_and_drop_v2.py
msgid "The maximum score the learner can receive for the problem." msgid "Defines the number of points the problem is worth."
msgstr "" msgstr ""
#: drag_and_drop_v2.py #: drag_and_drop_v2.py
...@@ -535,7 +535,40 @@ msgstr "" ...@@ -535,7 +535,40 @@ msgstr ""
msgid "Close item feedback popup" msgid "Close item feedback popup"
msgstr "" msgstr ""
#: utils.py:18
# Translators: {earned} is the number of points earned. {possible} is the total number of points (examples: 0/1, 1/1, 2/3, 5/10). The total number of points will always be at least 1. We pluralize based on the total number of points (example: 0/1 point; 1/2 points).
#: public/js/drag_and_drop.js:453
msgid "{earned}/{possible} point (graded)"
msgid_plural "{earned}/{possible} points (graded)"
msgstr[0] ""
msgstr[1] ""
# Translators: {earned} is the number of points earned. {possible} is the total number of points (examples: 0/1, 1/1, 2/3, 5/10). The total number of points will always be at least 1. We pluralize based on the total number of points (example: 0/1 point; 1/2 points).
#: public/js/drag_and_drop.js:459
msgid "{earned}/{possible} point (ungraded)"
msgid_plural "{earned}/{possible} points (ungraded)"
msgstr[0] ""
msgstr[1] ""
# Translators: {possible} is the number of points possible (examples: 1, 3, 10).
#: public/js/drag_and_drop.js:468
msgid "{possible} point possible (graded)"
msgid_plural "{possible} points possible (graded)"
msgstr[0] ""
msgstr[1] ""
# Translators: {possible} is the number of points possible (examples: 1, 3, 10).
#: public/js/drag_and_drop.js:474
msgid "{possible} point possible (ungraded)"
msgid_plural "{possible} points possible (ungraded)"
msgstr[0] ""
msgstr[1] ""
#: utils.py:44
msgid "Your highest score is {score}"
msgstr ""
#: utils.py:45
msgid "Final attempt was used, highest score is {score}" msgid "Final attempt was used, highest score is {score}"
msgstr "" msgstr ""
......
...@@ -197,14 +197,12 @@ msgstr "" ...@@ -197,14 +197,12 @@ msgstr ""
"ѕιт αмєт, ¢σηѕє¢тєтυя α#" "ѕιт αмєт, ¢σηѕє¢тєтυя α#"
#: drag_and_drop_v2.py #: drag_and_drop_v2.py
msgid "Maximum score" msgid "Problem Weight"
msgstr "Mäxïmüm sçöré Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#" msgstr "Prößlém Wéιght Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
#: drag_and_drop_v2.py #: drag_and_drop_v2.py
msgid "The maximum score the learner can receive for the problem." msgid "Defines the number of points the problem is worth."
msgstr "" msgstr "Défïnés thé nümßér öf pöïnts thé prößlém ïs wörth. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#"
"Thé mäxïmüm sçöré thé léärnér çän réçéïvé för thé prößlém Ⱡ'σяєм ιρѕυм ∂σłσя"
" ѕιт αмєт, ¢σηѕє¢тєтυя α#"
#: drag_and_drop_v2.py #: drag_and_drop_v2.py
msgid "Item background color" msgid "Item background color"
...@@ -619,11 +617,39 @@ msgstr "" ...@@ -619,11 +617,39 @@ msgstr ""
msgid "None" msgid "None"
msgstr "Nöné Ⱡ'σяєм ι#" msgstr "Nöné Ⱡ'σяєм ι#"
#: public/js/drag_and_drop.js:453
msgid "{earned}/{possible} point (graded)"
msgid_plural "{earned}/{possible} points (graded)"
msgstr[0] "{earned}/{possible} pöïnt (grädéd) Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢#"
msgstr[1] "{earned}/{possible} pöïnts (grädéd) Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢#"
#: public/js/drag_and_drop.js:459
msgid "{earned}/{possible} point (ungraded)"
msgid_plural "{earned}/{possible} points (ungraded)"
msgstr[0] "{earned}/{possible} pöïnt (üngrädéd) Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢ση#"
msgstr[1] "{earned}/{possible} pöïnts (üngrädéd) Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢ση#"
#: public/js/drag_and_drop.js:468
msgid "{possible} point possible (graded)"
msgid_plural "{possible} points possible (graded)"
msgstr[0] "{possible} pöïnt pössïßlé (grädéd) Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє#"
msgstr[1] "{possible} pöïnts pössïßlé (grädéd) Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє#"
#: public/js/drag_and_drop.js:474
msgid "{possible} point possible (ungraded)"
msgid_plural "{possible} points possible (ungraded)"
msgstr[0] "{possible} pöïnt pössïßlé (üngrädéd) Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#"
msgstr[1] "{possible} pöïnts pössïßlé (üngrädéd) Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#"
#: public/js/drag_and_drop_edit.js #: public/js/drag_and_drop_edit.js
msgid "Close item feedback popup" msgid "Close item feedback popup"
msgstr "Çlösé ïtém féédßäçk pöpüp Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#" msgstr "Çlösé ïtém féédßäçk pöpüp Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#"
#: utils.py:18 #: utils.py:44
msgid "Your highest score is {score}"
msgstr "Ýöür hïghést sçöré ïs {score} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#"
#: utils.py:45
msgid "Final attempt was used, highest score is {score}" msgid "Final attempt was used, highest score is {score}"
msgstr "Fïnäl ättémpt wäs üséd, hïghést sçöré ïs {score} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #" msgstr "Fïnäl ättémpt wäs üséd, hïghést sçöré ïs {score} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #"
......
...@@ -41,6 +41,7 @@ class FeedbackMessages(object): ...@@ -41,6 +41,7 @@ class FeedbackMessages(object):
MISPLACED = INCORRECT_SOLUTION MISPLACED = INCORRECT_SOLUTION
NOT_PLACED = INCORRECT_SOLUTION NOT_PLACED = INCORRECT_SOLUTION
GRADE_FEEDBACK_TPL = _('Your highest score is {score}')
FINAL_ATTEMPT_TPL = _('Final attempt was used, highest score is {score}') FINAL_ATTEMPT_TPL = _('Final attempt was used, highest score is {score}')
@staticmethod @staticmethod
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
# Imports ########################################################### # Imports ###########################################################
from ddt import ddt, data, unpack from ddt import ddt, data, unpack
import re
from selenium.common.exceptions import WebDriverException from selenium.common.exceptions import WebDriverException
from selenium.webdriver import ActionChains from selenium.webdriver import ActionChains
...@@ -271,6 +272,29 @@ class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, Paramet ...@@ -271,6 +272,29 @@ class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, Paramet
def test_keyboard_help(self, use_keyboard): def test_keyboard_help(self, use_keyboard):
self.interact_with_keyboard_help(use_keyboard=use_keyboard) self.interact_with_keyboard_help(use_keyboard=use_keyboard)
def test_grade_display(self):
items_with_zones = self._get_items_with_zone(self.items_map).values()
items_without_zones = self._get_items_without_zone(self.items_map).values()
total_items = len(items_with_zones) + len(items_without_zones)
progress = self._page.find_element_by_css_selector('.problem-progress')
self.assertEqual(progress.text, '1 point possible (ungraded)')
# Place items into correct zones one by one:
for idx, item in enumerate(items_with_zones):
self.place_item(item.item_id, item.zone_ids[0])
# The number of items in correct positions currently equals:
# the number of items already placed + any decoy items which should stay in the bank.
grade = (idx + 1 + len(items_without_zones)) / float(total_items)
formatted_grade = '{:.04f}'.format(grade) # display 4 decimal places
formatted_grade = re.sub(r'\.?0+$', '', formatted_grade) # remove trailing zeros
# Selenium does not see the refreshed text unless the text is in view (wtf??), so scroll back up.
self.scroll_down(pixels=0)
self.assertEqual(progress.text, '{}/1 point (ungraded)'.format(formatted_grade))
# After placing all items, we get the full score.
self.assertEqual(progress.text, '1/1 point (ungraded)')
class MultipleValidOptionsInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest): class MultipleValidOptionsInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest):
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
from ddt import ddt, data from ddt import ddt, data
from mock import Mock, patch from mock import Mock, patch
import time import time
import re
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
...@@ -250,15 +251,22 @@ class AssessmentInteractionTest( ...@@ -250,15 +251,22 @@ class AssessmentInteractionTest(
self.click_submit() self.click_submit()
# There are five items total (4 items with zones and one decoy item).
# We place the first item into correct zone and left the decoy item in the bank,
# which means the current grade is 2/5.
expected_grade = 2.0 / 5.0
feedback_lines = [ feedback_lines = [
"FEEDBACK", "FEEDBACK",
FeedbackMessages.correctly_placed(1), FeedbackMessages.correctly_placed(1),
FeedbackMessages.not_placed(3), FeedbackMessages.not_placed(3),
START_FEEDBACK START_FEEDBACK,
FeedbackMessages.GRADE_FEEDBACK_TPL.format(score=expected_grade)
] ]
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)
# Place the item into incorrect zone. The score does not change.
self.place_item(1, BOTTOM_ZONE_ID, Keys.RETURN) self.place_item(1, BOTTOM_ZONE_ID, Keys.RETURN)
self.click_submit() self.click_submit()
...@@ -267,7 +275,8 @@ class AssessmentInteractionTest( ...@@ -267,7 +275,8 @@ class AssessmentInteractionTest(
FeedbackMessages.correctly_placed(1), FeedbackMessages.correctly_placed(1),
FeedbackMessages.misplaced_returned(1), FeedbackMessages.misplaced_returned(1),
FeedbackMessages.not_placed(2), FeedbackMessages.not_placed(2),
START_FEEDBACK START_FEEDBACK,
FeedbackMessages.GRADE_FEEDBACK_TPL.format(score=expected_grade)
] ]
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)
...@@ -281,11 +290,15 @@ class AssessmentInteractionTest( ...@@ -281,11 +290,15 @@ class AssessmentInteractionTest(
self.place_item(3, TOP_ZONE_ID, Keys.RETURN) self.place_item(3, TOP_ZONE_ID, Keys.RETURN)
self.click_submit() self.click_submit()
# All items are correctly placed, so we get the full score (1.0).
expected_grade = 1.0
feedback_lines = [ feedback_lines = [
"FEEDBACK", "FEEDBACK",
FeedbackMessages.correctly_placed(4), FeedbackMessages.correctly_placed(4),
FINISH_FEEDBACK, FINISH_FEEDBACK,
FeedbackMessages.FINAL_ATTEMPT_TPL.format(score=1.0) FeedbackMessages.FINAL_ATTEMPT_TPL.format(score=expected_grade)
] ]
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)
...@@ -328,6 +341,34 @@ class AssessmentInteractionTest( ...@@ -328,6 +341,34 @@ class AssessmentInteractionTest(
self.assert_button_enabled(submit_button, enabled=True) self.assert_button_enabled(submit_button, enabled=True)
def test_grade_display(self):
progress = self._page.find_element_by_css_selector('.problem-progress')
self.assertEqual(progress.text, '1 point possible (ungraded)')
items_with_zones = self._get_items_with_zone(self.items_map).values()
items_without_zones = self._get_items_without_zone(self.items_map).values()
total_items = len(items_with_zones) + len(items_without_zones)
# Place items into correct zones one by one:
for idx, item in enumerate(items_with_zones):
self.place_item(item.item_id, item.zone_ids[0])
# The number of items in correct positions currently equals:
# the number of items already placed + any decoy items which should stay in the bank.
grade = (idx + 1 + len(items_without_zones)) / float(total_items)
formatted_grade = '{:.04f}'.format(grade) # display 4 decimal places
formatted_grade = re.sub(r'\.?0+$', '', formatted_grade) # remove trailing zeros
expected_progress = '{}/1 point (ungraded)'.format(formatted_grade)
# Selenium does not see the refreshed text unless the text is in view (wtf??), so scroll back up.
self.scroll_down(pixels=0)
# Grade does NOT change until we submit.
self.assertNotEqual(progress.text, expected_progress)
self.click_submit()
self.scroll_down(pixels=0)
self.assertEqual(progress.text, expected_progress)
# After placing all items, we get the full score.
self.assertEqual(progress.text, '1/1 point (ungraded)')
class TestMaxItemsPerZoneAssessment(TestMaxItemsPerZone): class TestMaxItemsPerZoneAssessment(TestMaxItemsPerZone):
assessment_mode = True assessment_mode = True
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
"title": "DnDv2 XBlock with plain text instructions", "title": "DnDv2 XBlock with plain text instructions",
"mode": "assessment", "mode": "assessment",
"max_attempts": 10, "max_attempts": 10,
"graded": false,
"weighted_max_score": 5,
"show_title": true, "show_title": true,
"problem_text": "Can you solve this drag-and-drop problem?", "problem_text": "Can you solve this drag-and-drop problem?",
"show_problem_header": true, "show_problem_header": true,
......
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
"show_title": true, "show_title": true,
"question_text": "Can you solve this drag-and-drop problem?", "question_text": "Can you solve this drag-and-drop problem?",
"show_question_header": true, "show_question_header": true,
"weight": 1, "weight": 5,
"item_background_color": "", "item_background_color": "",
"item_text_color": "", "item_text_color": "",
"url_name": "test" "url_name": "test"
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
"title": "DnDv2 XBlock with HTML instructions", "title": "DnDv2 XBlock with HTML instructions",
"mode": "standard", "mode": "standard",
"max_attempts": 0, "max_attempts": 0,
"graded": false,
"weighted_max_score": 1,
"show_title": false, "show_title": false,
"problem_text": "Solve this <strong>drag-and-drop</strong> problem.", "problem_text": "Solve this <strong>drag-and-drop</strong> problem.",
"show_problem_header": false, "show_problem_header": false,
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
"title": "Drag and Drop", "title": "Drag and Drop",
"mode": "standard", "mode": "standard",
"max_attempts": null, "max_attempts": null,
"graded": false,
"weighted_max_score": 1,
"show_title": true, "show_title": true,
"problem_text": "", "problem_text": "",
"show_problem_header": true, "show_problem_header": true,
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
"title": "DnDv2 XBlock with plain text instructions", "title": "DnDv2 XBlock with plain text instructions",
"mode": "standard", "mode": "standard",
"max_attempts": 0, "max_attempts": 0,
"graded": false,
"weighted_max_score": 1,
"show_title": true, "show_title": true,
"problem_text": "Can you solve this drag-and-drop problem?", "problem_text": "Can you solve this drag-and-drop problem?",
"show_problem_header": true, "show_problem_header": true,
......
...@@ -66,6 +66,7 @@ class BaseDragAndDropAjaxFixture(TestCaseMixin): ...@@ -66,6 +66,7 @@ class BaseDragAndDropAjaxFixture(TestCaseMixin):
self.assertEqual(self.block.get_configuration(), self.expected_configuration()) self.assertEqual(self.block.get_configuration(), self.expected_configuration())
@ddt.ddt
class StandardModeFixture(BaseDragAndDropAjaxFixture): class StandardModeFixture(BaseDragAndDropAjaxFixture):
""" """
Common tests for drag and drop in standard mode Common tests for drag and drop in standard mode
...@@ -95,11 +96,15 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture): ...@@ -95,11 +96,15 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
res = self.call_handler(self.DROP_ITEM_HANDLER, data) res = self.call_handler(self.DROP_ITEM_HANDLER, data)
item_feedback_message = self._make_item_feedback_message(item_id) item_feedback_message = self._make_item_feedback_message(item_id)
expected_feedback = [item_feedback_message] if item_feedback_message else [] expected_feedback = [item_feedback_message] if item_feedback_message else []
# the item was dropped into wrong zone, but we have two items that were correctly left in the bank,
# so the score is 2 / 4.0.
expected_grade = 2 / 4.0
self.assertEqual(res, { self.assertEqual(res, {
"overall_feedback": [self._make_feedback_message(message=self.INITIAL_FEEDBACK)], "overall_feedback": [self._make_feedback_message(message=self.INITIAL_FEEDBACK)],
"finished": False, "finished": False,
"correct": False, "correct": False,
"grade": expected_grade,
"feedback": expected_feedback "feedback": expected_feedback
}) })
...@@ -109,11 +114,15 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture): ...@@ -109,11 +114,15 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
res = self.call_handler(self.DROP_ITEM_HANDLER, data) res = self.call_handler(self.DROP_ITEM_HANDLER, data)
item_feedback_message = self._make_item_feedback_message(item_id) item_feedback_message = self._make_item_feedback_message(item_id)
expected_feedback = [item_feedback_message] if item_feedback_message else [] expected_feedback = [item_feedback_message] if item_feedback_message else []
# the item was dropped into wrong zone, but we have two items that were correctly left in the bank,
# so the score is 2 / 4.0.
expected_grade = 2 / 4.0
self.assertEqual(res, { self.assertEqual(res, {
"overall_feedback": [self._make_feedback_message(message=self.INITIAL_FEEDBACK)], "overall_feedback": [self._make_feedback_message(message=self.INITIAL_FEEDBACK)],
"finished": False, "finished": False,
"correct": False, "correct": False,
"grade": expected_grade,
"feedback": expected_feedback "feedback": expected_feedback
}) })
...@@ -123,15 +132,22 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture): ...@@ -123,15 +132,22 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
res = self.call_handler(self.DROP_ITEM_HANDLER, data) res = self.call_handler(self.DROP_ITEM_HANDLER, data)
item_feedback_message = self._make_item_feedback_message(item_id, key="correct") item_feedback_message = self._make_item_feedback_message(item_id, key="correct")
expected_feedback = [item_feedback_message] if item_feedback_message else [] expected_feedback = [item_feedback_message] if item_feedback_message else []
# Item 0 is in correct zone, items 2 and 3 don't belong to any zone so it is correct to leave them in the bank.
# The only item that is not in correct position yet is item 1. The grade is therefore 3/4.
expected_grade = 3 / 4.0
self.assertEqual(res, { self.assertEqual(res, {
"overall_feedback": [self._make_feedback_message(message=self.INITIAL_FEEDBACK)], "overall_feedback": [self._make_feedback_message(message=self.INITIAL_FEEDBACK)],
"finished": False, "finished": False,
"correct": True, "correct": True,
"grade": expected_grade,
"feedback": expected_feedback "feedback": expected_feedback
}) })
def test_grading(self): @ddt.data(*[random.randint(1, 50) for _ in xrange(5)]) # pylint: disable=star-args
def test_grading(self, weight):
self.block.weight = weight
published_grades = [] published_grades = []
def mock_publish(_, event, params): def mock_publish(_, event, params):
...@@ -139,35 +155,54 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture): ...@@ -139,35 +155,54 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
published_grades.append(params) published_grades.append(params)
self.block.runtime.publish = mock_publish self.block.runtime.publish = mock_publish
# Before the user starts working on the problem, grade should equal zero.
self.assertEqual(0, self.block.grade)
# Drag the first item into the correct zone.
self.call_handler(self.DROP_ITEM_HANDLER, {"val": 0, "zone": self.ZONE_1}) self.call_handler(self.DROP_ITEM_HANDLER, {"val": 0, "zone": self.ZONE_1})
self.assertEqual(1, len(published_grades)) self.assertEqual(1, len(published_grades))
self.assertEqual({'value': 0.75, 'max_value': 1}, published_grades[-1]) # The DnD test block has four items defined in the data fixtures:
# 1 item that belongs to ZONE_1, 1 item that belongs to ZONE_2, and two decoy items.
# After we drop the first item into ZONE_1, 3 out of 4 items are already in correct positions
# (1st item in ZONE_1 and two decoy items left in the bank). The grade at this point is therefore 3/4 * weight.
self.assertEqual(0.75 * weight, self.block.grade)
self.assertEqual({'value': 0.75 * weight, 'max_value': weight}, published_grades[-1])
# Drag the second item into correct zone.
self.call_handler(self.DROP_ITEM_HANDLER, {"val": 1, "zone": self.ZONE_2}) self.call_handler(self.DROP_ITEM_HANDLER, {"val": 1, "zone": self.ZONE_2})
self.assertEqual(2, len(published_grades)) self.assertEqual(2, len(published_grades))
self.assertEqual({'value': 1, 'max_value': 1}, published_grades[-1]) # All items are now placed in the right place, the user therefore gets the full grade.
self.assertEqual(weight, self.block.grade)
self.assertEqual({'value': weight, 'max_value': weight}, published_grades[-1])
def test_drop_item_final(self): def test_drop_item_final(self):
data = {"val": 0, "zone": self.ZONE_1} data = {"val": 0, "zone": self.ZONE_1}
self.call_handler(self.DROP_ITEM_HANDLER, data) self.call_handler(self.DROP_ITEM_HANDLER, data)
# Item 0 is in correct zone, items 2 and 3 don't belong to any zone so it is correct to leave them in the bank.
# The only item that is not in correct position yet is item 1. The grade is therefore 3/4.
expected_grade = 3 / 4.0
expected_state = { expected_state = {
"items": { "items": {
"0": {"correct": True, "zone": self.ZONE_1} "0": {"correct": True, "zone": self.ZONE_1}
}, },
"finished": False, "finished": False,
"attempts": 0, "attempts": 0,
"grade": expected_grade,
'overall_feedback': [self._make_feedback_message(message=self.INITIAL_FEEDBACK)], 'overall_feedback': [self._make_feedback_message(message=self.INITIAL_FEEDBACK)],
} }
self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET")) self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET"))
res = self.call_handler(self.DROP_ITEM_HANDLER, {"val": 1, "zone": self.ZONE_2}) res = self.call_handler(self.DROP_ITEM_HANDLER, {"val": 1, "zone": self.ZONE_2})
# All four items are in correct position, so the final grade is 4/4.
expected_grade = 4 / 4.0
self.assertEqual(res, { self.assertEqual(res, {
"overall_feedback": [self._make_feedback_message(message=self.FINAL_FEEDBACK)], "overall_feedback": [self._make_feedback_message(message=self.FINAL_FEEDBACK)],
"finished": True, "finished": True,
"correct": True, "correct": True,
"grade": expected_grade,
"feedback": [self._make_feedback_message(self.FEEDBACK[1]["correct"])] "feedback": [self._make_feedback_message(self.FEEDBACK[1]["correct"])]
}) })
...@@ -178,6 +213,7 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture): ...@@ -178,6 +213,7 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
}, },
"finished": True, "finished": True,
"attempts": 0, "attempts": 0,
"grade": expected_grade,
'overall_feedback': [self._make_feedback_message(self.FINAL_FEEDBACK)], 'overall_feedback': [self._make_feedback_message(self.FINAL_FEEDBACK)],
} }
self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET")) self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET"))
...@@ -267,7 +303,13 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture): ...@@ -267,7 +303,13 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
self._submit_partial_solution() self._submit_partial_solution()
res = self.call_handler(self.RESET_HANDLER, data={}) res = self.call_handler(self.RESET_HANDLER, data={})
expected_overall_feedback = [self._make_feedback_message(message=self.INITIAL_FEEDBACK)] expected_overall_feedback = [
self._make_feedback_message(message=self.INITIAL_FEEDBACK),
self._make_feedback_message(
FeedbackMessages.GRADE_FEEDBACK_TPL.format(score=self.block.grade),
FeedbackMessages.MessageClasses.PARTIAL_SOLUTION
)
]
self.assertEqual(res[self.OVERALL_FEEDBACK_KEY], expected_overall_feedback) self.assertEqual(res[self.OVERALL_FEEDBACK_KEY], expected_overall_feedback)
# pylint: disable=star-args # pylint: disable=star-args
...@@ -296,7 +338,10 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture): ...@@ -296,7 +338,10 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
self.assertEqual(self.block.attempts, attempts + 1) self.assertEqual(self.block.attempts, attempts + 1)
self.assertEqual(res['attempts'], self.block.attempts) self.assertEqual(res['attempts'], self.block.attempts)
def test_do_attempt_correct_mark_complete_and_publish_grade(self): @ddt.data(*[random.randint(1, 50) for _ in xrange(5)]) # pylint: disable=star-args
def test_do_attempt_correct_mark_complete_and_publish_grade(self, weight):
self.block.weight = weight
self._submit_complete_solution() self._submit_complete_solution()
with mock.patch('workbench.runtime.WorkbenchRuntime.publish', mock.Mock()) as patched_publish: with mock.patch('workbench.runtime.WorkbenchRuntime.publish', mock.Mock()) as patched_publish:
...@@ -304,12 +349,16 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture): ...@@ -304,12 +349,16 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
self.assertTrue(self.block.completed) self.assertTrue(self.block.completed)
patched_publish.assert_called_once_with(self.block, 'grade', { patched_publish.assert_called_once_with(self.block, 'grade', {
'value': self.block.weight, 'value': weight,
'max_value': self.block.weight, 'max_value': weight,
}) })
self.assertTrue(res['correct']) self.assertTrue(res['correct'])
self.assertEqual(res['grade'], weight)
@ddt.data(*[random.randint(1, 50) for _ in xrange(5)]) # pylint: disable=star-args
def test_do_attempt_incorrect_publish_grade(self, weight):
self.block.weight = weight
def test_do_attempt_incorrect_publish_grade(self):
correctness = self._submit_partial_solution() correctness = self._submit_partial_solution()
with mock.patch('workbench.runtime.WorkbenchRuntime.publish', mock.Mock()) as patched_publish: with mock.patch('workbench.runtime.WorkbenchRuntime.publish', mock.Mock()) as patched_publish:
...@@ -317,22 +366,25 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture): ...@@ -317,22 +366,25 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
self.assertFalse(self.block.completed) self.assertFalse(self.block.completed)
patched_publish.assert_called_once_with(self.block, 'grade', { patched_publish.assert_called_once_with(self.block, 'grade', {
'value': self.block.weight * correctness, 'value': weight * correctness,
'max_value': self.block.weight, 'max_value': weight,
}) })
self.assertFalse(res['correct']) self.assertFalse(res['correct'])
self.assertEqual(res['grade'], weight * correctness)
def test_do_attempt_post_correct_no_publish_grade(self): @ddt.data(*[random.randint(1, 50) for _ in xrange(5)]) # pylint: disable=star-args
self._submit_complete_solution() def test_do_attempt_post_correct_no_publish_grade(self, weight):
self.block.weight = weight
self._submit_complete_solution()
self.call_handler(self.DO_ATTEMPT_HANDLER, data={}) # sets self.complete self.call_handler(self.DO_ATTEMPT_HANDLER, data={}) # sets self.complete
self._reset_problem() self._reset_problem()
with mock.patch('workbench.runtime.WorkbenchRuntime.publish', mock.Mock()) as patched_publish: with mock.patch('workbench.runtime.WorkbenchRuntime.publish', mock.Mock()) as patched_publish:
self.call_handler(self.DO_ATTEMPT_HANDLER, data={}) self.call_handler(self.DO_ATTEMPT_HANDLER, data={})
self.assertTrue(self.block.completed) self.assertTrue(self.block.completed)
self.assertEqual(self.block.grade, weight)
self.assertFalse(patched_publish.called) self.assertFalse(patched_publish.called)
def test_get_user_state_finished_after_final_attempt(self): def test_get_user_state_finished_after_final_attempt(self):
...@@ -345,11 +397,14 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture): ...@@ -345,11 +397,14 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
res = self.call_handler(self.USER_STATE_HANDLER, data={}) res = self.call_handler(self.USER_STATE_HANDLER, data={})
self.assertTrue(res['finished']) self.assertTrue(res['finished'])
def test_do_attempt_incorrect_final_attempt_publish_grade(self): @ddt.data(*[random.randint(1, 50) for _ in xrange(5)]) # pylint: disable=star-args
def test_do_attempt_incorrect_final_attempt_publish_grade(self, weight):
self.block.weight = weight
self._set_final_attempt() self._set_final_attempt()
correctness = self._submit_partial_solution() correctness = self._submit_partial_solution()
expected_grade = self.block.weight * correctness expected_grade = weight * correctness
with mock.patch('workbench.runtime.WorkbenchRuntime.publish', mock.Mock()) as patched_publish: with mock.patch('workbench.runtime.WorkbenchRuntime.publish', mock.Mock()) as patched_publish:
res = self.call_handler(self.DO_ATTEMPT_HANDLER, data={}) res = self.call_handler(self.DO_ATTEMPT_HANDLER, data={})
...@@ -357,7 +412,7 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture): ...@@ -357,7 +412,7 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
self.assertTrue(self.block.completed) self.assertTrue(self.block.completed)
patched_publish.assert_called_once_with(self.block, 'grade', { patched_publish.assert_called_once_with(self.block, 'grade', {
'value': expected_grade, 'value': expected_grade,
'max_value': self.block.weight, 'max_value': weight,
}) })
expected_grade_feedback = self._make_feedback_message( expected_grade_feedback = self._make_feedback_message(
...@@ -365,13 +420,17 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture): ...@@ -365,13 +420,17 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
FeedbackMessages.MessageClasses.PARTIAL_SOLUTION FeedbackMessages.MessageClasses.PARTIAL_SOLUTION
) )
self.assertIn(expected_grade_feedback, res[self.OVERALL_FEEDBACK_KEY]) self.assertIn(expected_grade_feedback, res[self.OVERALL_FEEDBACK_KEY])
self.assertEqual(res['grade'], expected_grade)
@ddt.data(*[random.randint(1, 50) for _ in xrange(5)]) # pylint: disable=star-args
def test_do_attempt_incorrect_final_attempt_after_correct(self, weight):
self.block.weight = weight
def test_do_attempt_incorrect_final_attempt_after_correct(self):
self._submit_complete_solution() self._submit_complete_solution()
self.call_handler(self.DO_ATTEMPT_HANDLER, data={}) self.call_handler(self.DO_ATTEMPT_HANDLER, data={})
self.assertTrue(self.block.completed) # precondition check self.assertTrue(self.block.completed) # precondition check
self.assertEqual(self.block.grade, 1.0) # precondition check self.assertEqual(self.block.grade, weight) # precondition check
self._reset_problem() self._reset_problem()
...@@ -383,12 +442,12 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture): ...@@ -383,12 +442,12 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
res = self.call_handler(self.DO_ATTEMPT_HANDLER, data={}) res = self.call_handler(self.DO_ATTEMPT_HANDLER, data={})
expected_grade_feedback = self._make_feedback_message( expected_grade_feedback = self._make_feedback_message(
FeedbackMessages.FINAL_ATTEMPT_TPL.format(score=1.0), FeedbackMessages.FINAL_ATTEMPT_TPL.format(score=float(weight)),
FeedbackMessages.MessageClasses.PARTIAL_SOLUTION FeedbackMessages.MessageClasses.PARTIAL_SOLUTION
) )
self.assertFalse(patched_publish.called) self.assertFalse(patched_publish.called)
self.assertIn(expected_grade_feedback, res[self.OVERALL_FEEDBACK_KEY]) self.assertIn(expected_grade_feedback, res[self.OVERALL_FEEDBACK_KEY])
self.assertEqual(self.block.grade, 1.0) self.assertEqual(self.block.grade, weight)
def test_do_attempt_misplaced_ids(self): def test_do_attempt_misplaced_ids(self):
misplaced_ids = self._submit_incorrect_solution() misplaced_ids = self._submit_incorrect_solution()
...@@ -581,6 +640,10 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase): ...@@ -581,6 +640,10 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
self.INITIAL_FEEDBACK, self.INITIAL_FEEDBACK,
None None
), ),
self._make_feedback_message(
FeedbackMessages.GRADE_FEEDBACK_TPL.format(score=self.block.grade),
FeedbackMessages.MessageClasses.PARTIAL_SOLUTION
),
] ]
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)
...@@ -606,6 +669,10 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase): ...@@ -606,6 +669,10 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
expected_overall_feedback = [ expected_overall_feedback = [
self._make_feedback_message(FeedbackMessages.not_placed(3), FeedbackMessages.MessageClasses.NOT_PLACED), self._make_feedback_message(FeedbackMessages.not_placed(3), FeedbackMessages.MessageClasses.NOT_PLACED),
self._make_feedback_message(self.INITIAL_FEEDBACK, None), self._make_feedback_message(self.INITIAL_FEEDBACK, None),
self._make_feedback_message(
FeedbackMessages.GRADE_FEEDBACK_TPL.format(score=self.block.grade),
FeedbackMessages.MessageClasses.PARTIAL_SOLUTION
)
] ]
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)
...@@ -632,6 +699,10 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase): ...@@ -632,6 +699,10 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
self.INITIAL_FEEDBACK, self.INITIAL_FEEDBACK,
None None
), ),
self._make_feedback_message(
FeedbackMessages.GRADE_FEEDBACK_TPL.format(score=self.block.grade),
FeedbackMessages.MessageClasses.PARTIAL_SOLUTION
),
] ]
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)
...@@ -646,6 +717,30 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase): ...@@ -646,6 +717,30 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
FeedbackMessages.correctly_placed(3), FeedbackMessages.MessageClasses.CORRECTLY_PLACED FeedbackMessages.correctly_placed(3), FeedbackMessages.MessageClasses.CORRECTLY_PLACED
), ),
self._make_feedback_message(self.FINAL_FEEDBACK, FeedbackMessages.MessageClasses.CORRECT_SOLUTION), self._make_feedback_message(self.FINAL_FEEDBACK, FeedbackMessages.MessageClasses.CORRECT_SOLUTION),
self._make_feedback_message(
FeedbackMessages.GRADE_FEEDBACK_TPL.format(score=self.block.grade),
FeedbackMessages.MessageClasses.CORRECT_SOLUTION
),
]
self._assert_item_and_overall_feedback(res, expected_item_feedback, expected_overall_feedback)
def test_do_attempt_no_grade_feedback_with_zero_weight(self):
self.block.weight = 0
self.block.save()
self._submit_solution({0: self.ZONE_1}) # partial solution
self._do_attempt()
self._submit_solution({0: self.ZONE_1, 1: self.ZONE_2, 2: self.ZONE_2}) # correct solution
res = self._do_attempt()
expected_item_feedback = []
expected_overall_feedback = [
self._make_feedback_message(
FeedbackMessages.correctly_placed(3), FeedbackMessages.MessageClasses.CORRECTLY_PLACED
),
self._make_feedback_message(self.FINAL_FEEDBACK, FeedbackMessages.MessageClasses.CORRECT_SOLUTION)
] ]
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)
...@@ -661,13 +756,17 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase): ...@@ -661,13 +756,17 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
), ),
self._make_feedback_message(FeedbackMessages.not_placed(2), FeedbackMessages.MessageClasses.NOT_PLACED), self._make_feedback_message(FeedbackMessages.not_placed(2), FeedbackMessages.MessageClasses.NOT_PLACED),
self._make_feedback_message(self.INITIAL_FEEDBACK, None), self._make_feedback_message(self.INITIAL_FEEDBACK, None),
self._make_feedback_message(
FeedbackMessages.GRADE_FEEDBACK_TPL.format(score=self.block.grade),
FeedbackMessages.MessageClasses.PARTIAL_SOLUTION
),
] ]
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_keeps_highest_score(self): def test_do_attempt_keeps_highest_score(self):
self.assertFalse(self.block.completed) # precondition check self.assertFalse(self.block.completed) # precondition check
expected_score = 4.0 / 5.0 expected_score = 4.0 / 5.0 * self.block.weight
self._submit_solution({0: self.ZONE_1, 1: self.ZONE_2}) # partial solution, 0.8 score self._submit_solution({0: self.ZONE_1, 1: self.ZONE_2}) # partial solution, 0.8 score
self._do_attempt() self._do_attempt()
...@@ -690,7 +789,7 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase): ...@@ -690,7 +789,7 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
def test_do_attempt_check_score_with_decoy(self): def test_do_attempt_check_score_with_decoy(self):
self.assertFalse(self.block.completed) # precondition check self.assertFalse(self.block.completed) # precondition check
expected_score = 4.0 / 5.0 expected_score = 4.0 / 5.0 * self.block.weight
self._submit_solution({ self._submit_solution({
0: self.ZONE_1, 0: self.ZONE_1,
1: self.ZONE_2, 1: self.ZONE_2,
...@@ -701,14 +800,27 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase): ...@@ -701,14 +800,27 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
self.assertEqual(self.block.grade, expected_score) self.assertEqual(self.block.grade, expected_score)
def test_do_attempt_zero_score_with_all_decoys(self): def test_do_attempt_zero_score_with_all_decoys(self):
published_grades = []
def mock_publish(_, event, params):
if event == 'grade':
published_grades.append(params)
self.block.runtime.publish = mock_publish
self.assertFalse(self.block.completed) # precondition check self.assertFalse(self.block.completed) # precondition check
expected_score = 0
self._submit_solution({ self._submit_solution({
3: self.ZONE_1, 3: self.ZONE_1,
4: self.ZONE_2, 4: self.ZONE_2,
}) # incorrect solution, 0 score }) # incorrect solution, 0 score
self._do_attempt() res = self._do_attempt()
self.assertEqual(self.block.grade, expected_score)
self.assertEqual(res['grade'], 0)
self.assertEqual(self.block.grade, 0)
self.assertEqual(1, len(published_grades))
self.assertEqual({'value': 0, 'max_value': self.block.weight}, published_grades[-1])
user_state = self.call_handler('get_user_state', method="GET")
self.assertEqual(user_state['grade'], 0)
def test_do_attempt_correct_takes_decoy_into_account(self): def test_do_attempt_correct_takes_decoy_into_account(self):
self._submit_solution({0: self.ZONE_1, 1: self.ZONE_2, 2: self.ZONE_2, 3: self.ZONE_2}) self._submit_solution({0: self.ZONE_1, 1: self.ZONE_2, 2: self.ZONE_2, 3: self.ZONE_2})
......
import ddt import ddt
import unittest import unittest
import random
from drag_and_drop_v2.utils import Constants from drag_and_drop_v2.utils import Constants
from drag_and_drop_v2.default_data import ( from drag_and_drop_v2.default_data import (
...@@ -58,6 +59,8 @@ class BasicTests(TestCaseMixin, unittest.TestCase): ...@@ -58,6 +59,8 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
self.assertEqual(config, { self.assertEqual(config, {
"mode": Constants.STANDARD_MODE, "mode": Constants.STANDARD_MODE,
"max_attempts": None, "max_attempts": None,
"graded": False,
"weighted_max_score": 1,
"display_zone_borders": False, "display_zone_borders": False,
"display_zone_labels": False, "display_zone_labels": False,
"title": "Drag and Drop", "title": "Drag and Drop",
...@@ -86,13 +89,25 @@ class BasicTests(TestCaseMixin, unittest.TestCase): ...@@ -86,13 +89,25 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
) )
]) ])
@ddt.data(*[random.randint(1, 50) for _ in xrange(5)]) # pylint: disable=star-args
def test_grading_interface(self, weight):
"""
Test that the methods required by the LMS grading interface work as expected.
"""
self.block.weight = weight
# Max score is different from weight and should always equal 1 for drag and drop problems.
# See: https://openedx.atlassian.net/wiki/display/TNL/Robust+Grades+Design
self.assertEqual(self.block.max_score(), 1)
self.assertTrue(self.block.has_score)
def test_ajax_solve_and_reset(self): def test_ajax_solve_and_reset(self):
# Check assumptions / initial conditions: # Check assumptions / initial conditions:
self.assertFalse(self.block.completed) self.assertFalse(self.block.completed)
def assert_user_state_empty(): def assert_user_state_empty(grade=None):
self.assertEqual(self.block.item_state, {}) self.assertEqual(self.block.item_state, {})
self.assertEqual(self.call_handler("get_user_state"), { self.assertEqual(self.call_handler("get_user_state"), {
"grade": grade,
'items': {}, 'items': {},
'finished': False, 'finished': False,
"attempts": 0, "attempts": 0,
...@@ -127,13 +142,14 @@ class BasicTests(TestCaseMixin, unittest.TestCase): ...@@ -127,13 +142,14 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
}, },
'finished': True, 'finished': True,
"attempts": 0, "attempts": 0,
"grade": 1,
'overall_feedback': [{"message": FINISH_FEEDBACK, "message_class": None}], 'overall_feedback': [{"message": FINISH_FEEDBACK, "message_class": None}],
}) })
# Reset to initial conditions # Reset to initial conditions
self.call_handler('reset', {}) self.call_handler('reset', {})
self.assertTrue(self.block.completed) self.assertTrue(self.block.completed)
assert_user_state_empty() assert_user_state_empty(grade=1) # resetting student state does not reset the grade
def test_legacy_state_support(self): def test_legacy_state_support(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