Commit bc4a6f47 by Matjaz Gregoric Committed by E. Kolpakov

[SOL-2094] and [SOL-2030] Grading updates (backend and UI).

* The LMS expects that all graded blocks define a 'max_score' method which
  returns the highest possible grade. In our case, the highest possible grade is always equal 1
* Grade information is now displayed beneath the display name of the
  problem (similar to CAPA).
* Display highest grade in feedback area. In assessment mode, display the highest grade that the student achieved
  in the feedback area. Do not display grade feedback when weight == 0.
* Store and publish grade even when zero (student attempt completely wrong).
* Explicit method for calculating learner_raw score.
* Renamed "Maximum score" field to "Problem Weight" to be in line with CAPA problems and avoid confusion.
parent 24c6ceda
......@@ -7,6 +7,9 @@ before_install:
- "sh -e /etc/init.d/xvfb start"
install:
- "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"
- "python setup.py sdist"
- "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
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:
```
~/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
......
......@@ -107,8 +107,8 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
)
weight = Float(
display_name=_("Maximum score"),
help=_("The maximum score the learner can receive for the problem."),
display_name=_("Problem Weight"),
help=_("Defines the number of points the problem is worth."),
scope=Scope.settings,
default=1,
)
......@@ -172,6 +172,23 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
block_settings_key = 'drag-and-drop-v2'
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
def student_view(self, context):
"""
......@@ -232,6 +249,8 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
"mode": self.mode,
"zones": self.zones,
"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,
# SDK doesn't supply url_name.
"url_name": getattr(self, 'url_name', ''),
......@@ -413,6 +432,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
return {
'correct': correct,
'attempts': self.attempts,
'grade': self._get_grade_if_set(),
'misplaced_items': list(misplaced_ids),
'feedback': self._present_feedback(feedback_msgs),
'overall_feedback': self._present_feedback(overall_feedback_msgs)
......@@ -571,9 +591,14 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
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(
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
......@@ -608,6 +633,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
return {
'correct': is_correct,
'grade': self._get_grade_if_set(),
'finished': self._is_answer_correct(),
'overall_feedback': self._present_feedback(overall_feedback),
'feedback': self._present_feedback([item_feedback])
......@@ -654,7 +680,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
"""
# pylint: disable=fixme
# 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
# invocation:
# * it should be called after learner-caused updates to self.item_state is applied
......@@ -666,9 +692,10 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
# There's no going back from "completed" status to "incomplete"
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
if grade > self.grade:
current_grade = self._get_grade_if_set()
if current_grade is None or grade > current_grade:
self.grade = grade
self._publish_grade()
......@@ -751,6 +778,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
'items': item_state,
'finished': is_finished,
'attempts': self.attempts,
'grade': self._get_grade_if_set(),
'overall_feedback': self._present_feedback(overall_feedback_msgs)
}
......@@ -872,12 +900,21 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
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 correct_count / float(total_count) * self.weight
return self._learner_raw_score() * 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):
"""
......
......@@ -39,9 +39,10 @@
/* Header, instruction text, etc. */
.xblock--drag-and-drop .problem-title {
.xblock--drag-and-drop .problem-progress {
display: inline-block;
margin: 0 0 15px 0;
color: #5e5e5e;
font-size: 0.875em;
}
.xblock--drag-and-drop .problem p {
......
function DragAndDropTemplates(configuration) {
"use strict";
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) {
if (!item.xhr_active) {
......@@ -184,7 +182,7 @@ function DragAndDropTemplates(configuration) {
var item_wrapper = 'div.item-wrapper.item-align.item-align-' + zone.align;
var is_item_in_zone = function(i) { return i.is_placed && (i.zone === zone.uid); };
var items_in_zone = $.grep(ctx.items, is_item_in_zone);
var zone_description_id = 'zone-' + zone.uid + '-description';
var zone_description_id = configuration.url_name + '-zone-' + zone.uid + '-description';
if (items_in_zone.length == 0) {
var zone_description = h(
'div',
......@@ -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 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;
// Render only items in the bank here, including placeholders. Placed
// items will be rendered by zoneTemplate.
......@@ -463,6 +516,7 @@ function DragAndDropTemplates(configuration) {
return (
h('section.themed-xblock.xblock--drag-and-drop', [
problemTitle,
problemProgress,
h('section.problem', [
problemHeader,
h('p', {innerHTML: ctx.problem_html}),
......@@ -500,6 +554,12 @@ function DragAndDropTemplates(configuration) {
function DragAndDropBlock(runtime, element, configuration) {
"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.ASSESSMENT_MODE = 'assessment';
......@@ -510,9 +570,6 @@ function DragAndDropBlock(runtime, element, 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);
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
......@@ -1057,6 +1114,7 @@ function DragAndDropBlock(runtime, element, configuration) {
if (configuration.mode === DragAndDropBlock.STANDARD_MODE) {
state.last_action_correct = data.correct;
state.feedback = data.feedback;
state.grade = data.grade;
if (!data.correct) {
delete state.items[item_id];
}
......@@ -1144,6 +1202,7 @@ function DragAndDropBlock(runtime, element, configuration) {
data: '{}'
}).done(function(data){
state.attempts = data.attempts;
state.grade = data.grade;
state.feedback = data.feedback;
state.overall_feedback = data.overall_feedback;
state.last_action_correct = data.correct;
......@@ -1256,6 +1315,8 @@ function DragAndDropBlock(runtime, element, configuration) {
show_title: configuration.show_title,
mode: configuration.mode,
max_attempts: configuration.max_attempts,
graded: configuration.graded,
weighted_max_score: configuration.weighted_max_score,
problem_html: configuration.problem_text,
show_problem_header: configuration.show_problem_header,
show_submit_answer: configuration.mode == DragAndDropBlock.ASSESSMENT_MODE,
......@@ -1268,6 +1329,7 @@ function DragAndDropBlock(runtime, element, configuration) {
items: items,
// state - parts that can change:
attempts: state.attempts,
grade: state.grade,
last_action_correct: state.last_action_correct,
item_bank_focusable: item_bank_focusable,
feedback_messages: state.feedback,
......
......@@ -51,6 +51,9 @@
<span>{% trans fields.weight.display_name %}</span>
<input class="weight" type="number" step="0.1" value="{{ self.weight|unlocalize }}" />
</label>
<div id="weight-description-{{id_suffix}}" class="assessment-setting form-help">
{% trans fields.weight.help %}
</div>
<label class="h4">
<span>{% trans fields.question_text.display_name %}</span>
......
......@@ -160,11 +160,11 @@ msgid "Display the heading \"Problem\" above the problem text?"
msgstr ""
#: drag_and_drop_v2.py
msgid "Maximum score"
msgid "Problem Weight"
msgstr ""
#: 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 ""
#: drag_and_drop_v2.py
......@@ -535,7 +535,40 @@ msgstr ""
msgid "Close item feedback popup"
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}"
msgstr ""
......
......@@ -197,14 +197,12 @@ msgstr ""
"ѕιт αмєт, ¢σηѕє¢тєтυя α#"
#: drag_and_drop_v2.py
msgid "Maximum score"
msgstr "Mäxïmüm sçöré Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
msgid "Problem Weight"
msgstr "Prößlém Wéιght Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
#: drag_and_drop_v2.py
msgid "The maximum score the learner can receive for the problem."
msgstr ""
"Thé mäxïmüm sçöré thé léärnér çän réçéïvé för thé prößlém Ⱡ'σяєм ιρѕυм ∂σłσя"
" ѕιт αмєт, ¢σηѕє¢тєтυя α#"
msgid "Defines the number of points the problem is worth."
msgstr "Défïnés thé nümßér öf pöïnts thé prößlém ïs wörth. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#"
#: drag_and_drop_v2.py
msgid "Item background color"
......@@ -619,11 +617,39 @@ msgstr ""
msgid "None"
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
msgid "Close item feedback popup"
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}"
msgstr "Fïnäl ättémpt wäs üséd, hïghést sçöré ïs {score} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #"
......
......@@ -41,6 +41,7 @@ class FeedbackMessages(object):
MISPLACED = 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}')
@staticmethod
......
......@@ -4,6 +4,7 @@
# Imports ###########################################################
from ddt import ddt, data, unpack
import re
from selenium.common.exceptions import WebDriverException
from selenium.webdriver import ActionChains
......@@ -271,6 +272,29 @@ class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, Paramet
def test_keyboard_help(self, 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):
......
......@@ -5,6 +5,7 @@
from ddt import ddt, data
from mock import Mock, patch
import time
import re
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.common.keys import Keys
......@@ -250,15 +251,22 @@ class AssessmentInteractionTest(
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",
FeedbackMessages.correctly_placed(1),
FeedbackMessages.not_placed(3),
START_FEEDBACK
START_FEEDBACK,
FeedbackMessages.GRADE_FEEDBACK_TPL.format(score=expected_grade)
]
expected_feedback = "\n".join(feedback_lines)
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.click_submit()
......@@ -267,7 +275,8 @@ class AssessmentInteractionTest(
FeedbackMessages.correctly_placed(1),
FeedbackMessages.misplaced_returned(1),
FeedbackMessages.not_placed(2),
START_FEEDBACK
START_FEEDBACK,
FeedbackMessages.GRADE_FEEDBACK_TPL.format(score=expected_grade)
]
expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback)
......@@ -281,11 +290,15 @@ class AssessmentInteractionTest(
self.place_item(3, TOP_ZONE_ID, Keys.RETURN)
self.click_submit()
# All items are correctly placed, so we get the full score (1.0).
expected_grade = 1.0
feedback_lines = [
"FEEDBACK",
FeedbackMessages.correctly_placed(4),
FINISH_FEEDBACK,
FeedbackMessages.FINAL_ATTEMPT_TPL.format(score=1.0)
FeedbackMessages.FINAL_ATTEMPT_TPL.format(score=expected_grade)
]
expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback)
......@@ -328,6 +341,34 @@ class AssessmentInteractionTest(
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):
assessment_mode = True
......
......@@ -2,6 +2,8 @@
"title": "DnDv2 XBlock with plain text instructions",
"mode": "assessment",
"max_attempts": 10,
"graded": false,
"weighted_max_score": 5,
"show_title": true,
"problem_text": "Can you solve this drag-and-drop problem?",
"show_problem_header": true,
......
......@@ -5,7 +5,7 @@
"show_title": true,
"question_text": "Can you solve this drag-and-drop problem?",
"show_question_header": true,
"weight": 1,
"weight": 5,
"item_background_color": "",
"item_text_color": "",
"url_name": "test"
......
......@@ -2,6 +2,8 @@
"title": "DnDv2 XBlock with HTML instructions",
"mode": "standard",
"max_attempts": 0,
"graded": false,
"weighted_max_score": 1,
"show_title": false,
"problem_text": "Solve this <strong>drag-and-drop</strong> problem.",
"show_problem_header": false,
......
......@@ -2,6 +2,8 @@
"title": "Drag and Drop",
"mode": "standard",
"max_attempts": null,
"graded": false,
"weighted_max_score": 1,
"show_title": true,
"problem_text": "",
"show_problem_header": true,
......
......@@ -2,6 +2,8 @@
"title": "DnDv2 XBlock with plain text instructions",
"mode": "standard",
"max_attempts": 0,
"graded": false,
"weighted_max_score": 1,
"show_title": true,
"problem_text": "Can you solve this drag-and-drop problem?",
"show_problem_header": true,
......
import ddt
import unittest
import random
from drag_and_drop_v2.utils import Constants
from drag_and_drop_v2.default_data import (
......@@ -58,6 +59,8 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
self.assertEqual(config, {
"mode": Constants.STANDARD_MODE,
"max_attempts": None,
"graded": False,
"weighted_max_score": 1,
"display_zone_borders": False,
"display_zone_labels": False,
"title": "Drag and Drop",
......@@ -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):
# Check assumptions / initial conditions:
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.call_handler("get_user_state"), {
"grade": grade,
'items': {},
'finished': False,
"attempts": 0,
......@@ -127,13 +142,14 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
},
'finished': True,
"attempts": 0,
"grade": 1,
'overall_feedback': [{"message": FINISH_FEEDBACK, "message_class": None}],
})
# Reset to initial conditions
self.call_handler('reset', {})
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):
"""
......
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