Commit 85c6143c by Tim Krones Committed by GitHub

Merge pull request #101 from arbrandes/SOL-1998

[SOL-1998] Implement Show Answer button
parents 07add130 4c6fb7d7
......@@ -108,7 +108,8 @@ There are two problem modes available:
attempt to place an item, and the number of attempts is not limited.
* **Assessment**: In this mode, the learner places all items on the board and
then clicks a "Submit" button to get feedback. The number of attempts can be
limited.
limited. When all attempts are used, the learner can click a "Show Answer"
button to temporarily place items on their correct drop zones.
![Drop zone edit](/doc/img/edit-view-zones.png)
......
......@@ -438,6 +438,28 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
return self._get_user_state()
@XBlock.json_handler
def show_answer(self, data, suffix=''):
"""
Returns correct answer in assessment mode.
Raises:
* JsonHandlerError with 400 error code in standard mode.
* JsonHandlerError with 409 error code if there are still attempts left
"""
if self.mode != Constants.ASSESSMENT_MODE:
raise JsonHandlerError(
400,
self.i18n_service.gettext("show_answer handler should only be called for assessment mode")
)
if self.attempts_remain:
raise JsonHandlerError(
409,
self.i18n_service.gettext("There are attempts remaining")
)
return self._get_correct_state()
@XBlock.json_handler
def expand_static_url(self, url, suffix=''):
""" AJAX-accessible handler for expanding URLs to static [image] files """
return {'url': self._expand_static_url(url)}
......@@ -527,7 +549,14 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
FeedbackMessages.correctly_placed,
FeedbackMessages.MessageClasses.CORRECTLY_PLACED
)
_add_msg_if_exists(misplaced_ids, FeedbackMessages.misplaced, FeedbackMessages.MessageClasses.MISPLACED)
# Misplaced items are not returned to the bank on the final attempt.
if self.attempts_remain:
misplaced_template = FeedbackMessages.misplaced_returned
else:
misplaced_template = FeedbackMessages.misplaced
_add_msg_if_exists(misplaced_ids, misplaced_template, FeedbackMessages.MessageClasses.MISPLACED)
_add_msg_if_exists(missing_ids, FeedbackMessages.not_placed, FeedbackMessages.MessageClasses.NOT_PLACED)
if self.attempts_remain and (misplaced_ids or missing_ids):
......@@ -723,6 +752,31 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
'overall_feedback': self._present_feedback(overall_feedback_msgs)
}
def _get_correct_state(self):
"""
Returns one of the possible correct states for the configured data.
"""
state = {}
items = copy.deepcopy(self.data.get('items', []))
for item in items:
zones = item.get('zones')
# For backwards compatibility
if zones is None:
zones = []
zone = item.get('zone')
if zone is not None and zone != 'none':
zones.append(zone)
if zones:
zone = zones.pop()
state[str(item['id'])] = {
'zone': zone,
'correct': True,
}
return {'items': state}
def _get_item_state(self):
"""
Returns a copy of the user item state.
......@@ -855,4 +909,13 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
"""
A canned scenario for display in the workbench.
"""
return [("Drag-and-drop-v2 scenario", "<vertical_demo><drag-and-drop-v2/></vertical_demo>")]
return [
(
"Drag-and-drop-v2 standard",
"<vertical_demo><drag-and-drop-v2/></vertical_demo>"
),
(
"Drag-and-drop-v2 assessment",
"<vertical_demo><drag-and-drop-v2 mode='assessment' max_attempts='3'/></vertical_demo>"
),
]
......@@ -568,6 +568,7 @@
.ltr .xblock--drag-and-drop .actions-toolbar .action-toolbar-item.sidebar-buttons {
float: right;
padding-right: -5px;
padding-top: 5px;
}
.rtl .xblock--drag-and-drop .actions-toolbar .action-toolbar-item.sidebar-buttons {
......@@ -623,10 +624,6 @@
display: block;
}
.xblock--drag-and-drop .reset-button {
margin-top: 3px;
}
/*** ACTIONS TOOLBAR END ***/
/*** KEYBOARD HELP ***/
......
......@@ -110,7 +110,7 @@ function DragAndDropTemplates(configuration) {
if (item.is_placed) {
var zone_title = (zone.title || "Unknown Zone"); // This "Unknown" text should never be seen, so does not need i18n
var description_content;
if (configuration.mode === DragAndDropBlock.ASSESSMENT_MODE) {
if (configuration.mode === DragAndDropBlock.ASSESSMENT_MODE && !ctx.showing_answer) {
// In assessment mode placed items will "stick" even when not in correct zone.
description_content = gettext('Placed in: {zone_title}').replace('{zone_title}', zone_title);
} else {
......@@ -180,9 +180,8 @@ function DragAndDropTemplates(configuration) {
var zoneTemplate = function(zone, ctx) {
var className = ctx.display_zone_labels ? 'zone-name' : 'zone-name sr';
var selector = ctx.display_zone_borders ? 'div.zone.zone-with-borders' : 'div.zone';
// If zone is aligned, mark its item alignment
// and render its placed items as children
var item_wrapper = 'div.item-wrapper';
// Mark item alignment and render its placed items as children
var item_wrapper = 'div.item-wrapper.item-align.item-align-' + zone.align;
var is_item_in_zone = function(i) { return i.is_placed && (i.zone === zone.uid); };
var items_in_zone = $.grep(ctx.items, is_item_in_zone);
var zone_description_id = 'zone-' + zone.uid + '-description';
......@@ -199,12 +198,7 @@ function DragAndDropTemplates(configuration) {
gettext('Items placed here: ') + items_in_zone.map(function (item) { return item.displayName; }).join(", ")
);
}
if (zone.align !== 'none') {
item_wrapper += '.item-align.item-align-' + zone.align;
//items_in_zone = $.grep(ctx.items, is_item_in_zone);
} else {
items_in_zone = [];
}
return (
h(
selector,
......@@ -343,14 +337,21 @@ function DragAndDropTemplates(configuration) {
);
};
var sidebarButtonTemplate = function(buttonClass, iconClass, buttonText, disabled) {
var sidebarButtonTemplate = function(buttonClass, iconClass, buttonText, disabled, spinner) {
if (spinner) {
iconClass = 'fa-spin.fa-spinner';
}
return (
h('span.sidebar-button-wrapper', {}, [
h(
'button.unbutton.btn-default.btn-small.'+buttonClass,
{disabled: disabled || false, attributes: {tabindex: 0}},
{disabled: disabled || spinner || false, attributes: {tabindex: 0}},
[
h("span.btn-icon.fa."+iconClass, {attributes: {"aria-hidden": true}}, []),
h(
"span.btn-icon.fa." + iconClass,
{attributes: {"aria-hidden": true}},
[]
),
buttonText
]
)
......@@ -359,10 +360,21 @@ function DragAndDropTemplates(configuration) {
};
var sidebarTemplate = function(ctx) {
var showAnswerButton = null;
if (ctx.show_show_answer) {
showAnswerButton = sidebarButtonTemplate(
"show-answer-button",
"fa-info-circle",
gettext('Show Answer'),
ctx.showing_answer ? true : ctx.disable_show_answer_button,
ctx.show_answer_spinner
);
}
return(
h("section.action-toolbar-item.sidebar-buttons", {}, [
sidebarButtonTemplate("keyboard-help-button", "fa-question", gettext('Keyboard Help')),
sidebarButtonTemplate("reset-button", "fa-refresh", gettext('Reset'), ctx.disable_reset_button),
showAnswerButton,
])
)
};
......@@ -434,9 +446,8 @@ function DragAndDropTemplates(configuration) {
var mainTemplate = function(ctx) {
var problemTitle = ctx.show_title ? h('h3.problem-title', {innerHTML: ctx.title_html}) : null;
var problemHeader = ctx.show_problem_header ? h('h4.title1', gettext('Problem')) : null;
// Render only items_in_bank and items_placed_unaligned here;
// items placed in aligned zones will be rendered by zoneTemplate.
// Render only items in the bank here, including placeholders. Placed
// items will be rendered by zoneTemplate.
var is_item_placed = function(i) { return i.is_placed; };
var items_placed = $.grep(ctx.items, is_item_placed);
var items_in_bank = $.grep(ctx.items, is_item_placed, true);
......@@ -561,6 +572,10 @@ function DragAndDropBlock(runtime, element, configuration) {
$element.on('keydown', '.reset-button', function(evt) {
runOnKey(evt, RET, resetProblem);
});
$element.on('click', '.show-answer-button', showAnswer);
$element.on('keydown', '.show-answer-button', function(evt) {
runOnKey(evt, RET, showAnswer);
});
// For the next one, we need to use addEventListener with useCapture 'true' in order
// to watch for load events on any child element, since load events do not bubble.
......@@ -1098,6 +1113,26 @@ function DragAndDropBlock(runtime, element, configuration) {
});
};
var showAnswer = function(evt) {
evt.preventDefault();
state.show_answer_spinner = true;
applyState();
$.ajax({
type: 'POST',
url: runtime.handlerUrl(element, 'show_answer'),
data: '{}',
}).done(function(data) {
state.items = data.items;
state.showing_answer = true;
delete state.feedback;
}).always(function() {
state.show_answer_spinner = false;
applyState();
$root.find('.item-bank').focus();
});
};
var doAttempt = function(evt) {
evt.preventDefault();
state.submit_spinner = true;
......@@ -1147,6 +1182,10 @@ function DragAndDropBlock(runtime, element, configuration) {
return any_items_placed && (configuration.mode !== DragAndDropBlock.ASSESSMENT_MODE || attemptsRemain());
};
var canShowAnswer = function() {
return configuration.mode === DragAndDropBlock.ASSESSMENT_MODE && !attemptsRemain();
};
var attemptsRemain = function() {
return !configuration.max_attempts || configuration.max_attempts > state.attempts;
};
......@@ -1207,7 +1246,7 @@ function DragAndDropBlock(runtime, element, configuration) {
// In assessment mode, it is possible to move items back to the bank, so the bank should be able to
// gain focus while keyboard placement is in progress.
var item_bank_focusable = state.keyboard_placement_mode &&
var item_bank_focusable = (state.keyboard_placement_mode || state.showing_answer) &&
configuration.mode === DragAndDropBlock.ASSESSMENT_MODE;
var context = {
......@@ -1220,6 +1259,7 @@ function DragAndDropBlock(runtime, element, configuration) {
problem_html: configuration.problem_text,
show_problem_header: configuration.show_problem_header,
show_submit_answer: configuration.mode == DragAndDropBlock.ASSESSMENT_MODE,
show_show_answer: configuration.mode == DragAndDropBlock.ASSESSMENT_MODE,
target_img_src: configuration.target_img_expanded_url,
target_img_description: configuration.target_img_description,
display_zone_labels: configuration.display_zone_labels,
......@@ -1233,8 +1273,11 @@ function DragAndDropBlock(runtime, element, configuration) {
feedback_messages: state.feedback,
overall_feedback_messages: state.overall_feedback,
disable_reset_button: !canReset(),
disable_show_answer_button: !canShowAnswer(),
disable_submit_button: !canSubmitAttempt(),
submit_spinner: state.submit_spinner
submit_spinner: state.submit_spinner,
showing_answer: state.showing_answer,
show_answer_spinner: state.show_answer_spinner
};
return renderView(context);
......
......@@ -217,6 +217,14 @@ msgid "Max number of attempts reached"
msgstr ""
#: drag_and_drop_v2.py
msgid "show_answer handler should only be called for assessment mode"
msgstr ""
#: drag_and_drop_v2.py
msgid "There are attempts remaining"
msgstr ""
#: drag_and_drop_v2.py
msgid "Unknown DnDv2 mode {mode} - course is misconfigured"
msgstr ""
......@@ -446,6 +454,14 @@ msgid "Reset"
msgstr ""
#: public/js/drag_and_drop.js
msgid "Show Answer"
msgstr ""
#: public/js/drag_and_drop.js
msgid "Hide Answer"
msgstr ""
#: public/js/drag_and_drop.js
msgid "Submit"
msgstr ""
......@@ -529,7 +545,13 @@ msgid_plural "Correctly placed {correct_count} items."
msgstr[0] ""
msgstr[1] ""
#: utils.py:32
#: utils.py:62
msgid "Misplaced {misplaced_count} item."
msgid_plural "Misplaced {misplaced_count} items."
msgstr[0] ""
msgstr[1] ""
#: utils.py:73
msgid "Misplaced {misplaced_count} item. Misplaced item was returned to item bank."
msgid_plural "Misplaced {misplaced_count} items. Misplaced items were returned to item bank."
msgstr[0] ""
......
......@@ -272,6 +272,14 @@ msgid "Max number of attempts reached"
msgstr "Mäx nümßér öf ättémpts réäçhéd Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#"
#: drag_and_drop_v2.py
msgid "show_answer handler should only be called for assessment mode"
msgstr "shöw_änswér händlér shöüld önlý ßé çälléd för ässéssmént mödé Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#"
#: drag_and_drop_v2.py
msgid "There are attempts remaining"
msgstr "Théré äré ättémpts rémäïnïng Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢#"
#: drag_and_drop_v2.py
msgid "Unknown DnDv2 mode {mode} - course is misconfigured"
msgstr "Ûnknöwn DnDv2 mödé {mode} - çöürsé ïs mïsçönfïgüréd Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#"
......@@ -520,6 +528,14 @@ msgid "Reset"
msgstr "Rését Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
#: public/js/drag_and_drop.js
msgid "Show Answer"
msgstr "Shöw Ànswér Ⱡ'σяєм ιρѕυм ∂σłσя #"
#: public/js/drag_and_drop.js
msgid "Hide Answer"
msgstr "Hïdé Ànswér Ⱡ'σяєм ιρѕυм ∂σłσя #"
#: public/js/drag_and_drop.js
msgid "Submit"
msgstr "Süßmït Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
......@@ -617,12 +633,18 @@ msgid_plural "Correctly placed {correct_count} items."
msgstr[0] "Çörréçtlý pläçéd {correct_count} ïtém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#"
msgstr[1] "Çörréçtlý pläçéd {correct_count} ïtéms. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє#"
#: utils.py:32
msgid "Misplaced {misplaced_count} item. Misplaced item was returned to item bank."
msgid_plural "Misplaced {misplaced_count} items. Misplaced items were returned to item bank."
#: utils.py:62
msgid "Misplaced {misplaced_count} item."
msgid_plural "Misplaced {misplaced_count} items."
msgstr[0] "Mïspläçéd {misplaced_count} ïtém. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт,#"
msgstr[1] "Mïspläçéd {misplaced_count} ïtéms. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #"
#: utils.py:73
msgid "Misplaced {misplaced_count} item. Misplaced item was returned to item bank."
msgid_plural "Misplaced {misplaced_count} items. Misplaced items were returned to item bank."
msgstr[0] "Mïspläçéd {misplaced_count} ïtém. Mïspläçéd ïtém wäs rétürnéd tö ïtém ßänk. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#"
msgstr[1] "Mïspläçéd {misplaced_count} ïtéms. Mïspläçéd ïtéms wéré rétürnéd tö ïtém ßänk. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#"
#: utils.py:40
msgid "Did not place {missing_count} required item."
msgid_plural "Did not place {missing_count} required items."
......
......@@ -60,6 +60,17 @@ class FeedbackMessages(object):
Formats "misplaced items" message
"""
return ngettext(
'Misplaced {misplaced_count} item.',
'Misplaced {misplaced_count} items.',
number
).format(misplaced_count=number)
@staticmethod
def misplaced_returned(number, ngettext=ngettext_fallback):
"""
Formats "misplaced items returned to bank" message
"""
return ngettext(
'Misplaced {misplaced_count} item. Misplaced item was returned to item bank.',
'Misplaced {misplaced_count} items. Misplaced items were returned to item bank.',
number
......
......@@ -126,6 +126,9 @@ class BaseIntegrationTest(SeleniumBaseTest):
def _get_reset_button(self):
return self._page.find_element_by_css_selector('.reset-button')
def _get_show_answer_button(self):
return self._page.find_element_by_css_selector('.show-answer-button')
def _get_submit_button(self):
return self._page.find_element_by_css_selector('.submit-answer-button')
......@@ -392,12 +395,21 @@ class InteractionTestBase(object):
self.assertDraggable(item_value)
self.assertEqual(item.get_attribute('class'), 'option')
self.assertEqual(item.get_attribute('tabindex'), '0')
self.assertEqual(item_description.text, 'Placed in: {}'.format(zone_title))
description = 'Placed in: {}'
else:
self.assertNotDraggable(item_value)
self.assertEqual(item.get_attribute('class'), 'option fade')
self.assertIsNone(item.get_attribute('tabindex'))
self.assertEqual(item_description.text, 'Correctly placed in: {}'.format(zone_title))
description = 'Correctly placed in: {}'
# An item with multiple drop zones could be located in any one of these
# zones. In that case, zone_title will be a list, and we need to check
# whether the zone info in the description of the item matches any of
# the zones in that list.
if isinstance(zone_title, list):
self.assertIn(item_description.text, [description.format(title) for title in zone_title])
else:
self.assertEqual(item_description.text, description.format(zone_title))
def assert_reverted_item(self, item_value):
item = self._get_item_by_value(item_value)
......
......@@ -5,7 +5,8 @@ from selenium.webdriver.common.keys import Keys
from workbench.runtime import WorkbenchRuntime
from drag_and_drop_v2.default_data import (
TOP_ZONE_TITLE, TOP_ZONE_ID, MIDDLE_ZONE_TITLE, MIDDLE_ZONE_ID, ITEM_CORRECT_FEEDBACK, ITEM_INCORRECT_FEEDBACK,
TOP_ZONE_TITLE, TOP_ZONE_ID, MIDDLE_ZONE_TITLE, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID,
ITEM_CORRECT_FEEDBACK, ITEM_INCORRECT_FEEDBACK,
ITEM_TOP_ZONE_NAME, ITEM_MIDDLE_ZONE_NAME,
)
from tests.integration.test_base import BaseIntegrationTest, DefaultDataTestMixin, InteractionTestBase, ItemDefinition
......@@ -146,6 +147,20 @@ class AssessmentEventsFiredTest(
self.assertEqual(name, event['name'])
self.assertEqual(published_data, event['data'])
def test_grade(self):
"""
Test grading after submitting solution in assessment mode
"""
self.place_item(0, TOP_ZONE_ID, Keys.RETURN) # Correctly placed item
self.place_item(1, BOTTOM_ZONE_ID, Keys.RETURN) # Incorrectly placed item
self.place_item(4, MIDDLE_ZONE_ID, Keys.RETURN) # Incorrectly placed decoy
self.click_submit()
events = self.publish.call_args_list
published_grade = next((event[0][2] for event in events if event[0][1] == 'grade'))
expected_grade = {'max_value': 1, 'value': (1.0 / 5.0)}
self.assertEqual(published_grade, expected_grade)
@ddt
class ItemDroppedEventTest(DefaultDataTestMixin, BaseEventsTests):
......
......@@ -442,7 +442,7 @@ class MultipleBlocksDataInteraction(ParameterizedTestsMixin, InteractionTestBase
self._switch_to_block(1)
# Test mouse and keyboard interaction
self.interact_with_keyboard_help(scroll_down=900)
self.interact_with_keyboard_help(scroll_down=1200)
self.interact_with_keyboard_help(scroll_down=0, use_keyboard=True)
......
# -*- coding: utf-8 -*-
# Imports ###########################################################
from ddt import ddt, data
......@@ -7,7 +9,6 @@ import time
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.common.keys import Keys
from workbench.runtime import WorkbenchRuntime
from xblockutils.resources import ResourceLoader
from drag_and_drop_v2.default_data import (
......@@ -55,6 +56,14 @@ class AssessmentTestMixin(object):
submit_button.click()
self.wait_for_ajax()
def click_show_answer(self):
show_answer_button = self._get_show_answer_button()
self._wait_until_enabled(show_answer_button)
show_answer_button.click()
self.wait_for_ajax()
@ddt
class AssessmentInteractionTest(
......@@ -150,6 +159,58 @@ class AssessmentInteractionTest(
self.assertEqual(submit_button.get_attribute('disabled'), 'true')
self.assertEqual(reset_button.get_attribute('disabled'), 'true')
def _assert_show_answer_item_placement(self):
zones = dict(self.all_zones)
for item in self._get_items_with_zone(self.items_map).values():
zone_titles = [zones[zone_id] for zone_id in item.zone_ids]
# When showing answers, correct items are placed as if assessment_mode=False
self.assert_placed_item(item.item_id, zone_titles, assessment_mode=False)
for item_definition in self._get_items_without_zone(self.items_map).values():
self.assertNotDraggable(item_definition.item_id)
item = self._get_item_by_value(item_definition.item_id)
self.assertEqual(item.get_attribute('aria-grabbed'), 'false')
self.assertEqual(item.get_attribute('class'), 'option fade')
item_content = item.find_element_by_css_selector('.item-content')
item_description_id = '-item-{}-description'.format(item_definition.item_id)
self.assertEqual(item_content.get_attribute('aria-describedby'), item_description_id)
describedby_text = (u'Press "Enter", "Space", "Ctrl-m", or "⌘-m" on an item to select it for dropping, '
'then navigate to the zone you want to drop it on.')
self.assertEqual(item.find_element_by_css_selector('.sr').text, describedby_text)
def test_show_answer(self):
"""
Test "Show Answer" button is shown in assessment mode, enabled when no
more attempts remaining, is disabled and displays correct answers when
clicked.
"""
show_answer_button = self._get_show_answer_button()
self.assertTrue(show_answer_button.is_displayed())
self.place_item(0, TOP_ZONE_ID, Keys.RETURN)
for _ in xrange(self.MAX_ATTEMPTS-1):
self.assertEqual(show_answer_button.get_attribute('disabled'), 'true')
self.click_submit()
# Place an incorrect item on the final attempt.
self.place_item(1, TOP_ZONE_ID, Keys.RETURN)
self.click_submit()
# A feedback popup should open upon final submission.
popup = self._get_popup()
self.assertTrue(popup.is_displayed())
self.assertIsNone(show_answer_button.get_attribute('disabled'))
self.click_show_answer()
# The popup should be closed upon clicking Show Answer.
self.assertFalse(popup.is_displayed())
self.assertEqual(show_answer_button.get_attribute('disabled'), 'true')
self._assert_show_answer_item_placement()
def test_do_attempt_feedback_is_updated(self):
"""
Test updating overall feedback after submitting solution in assessment mode
......@@ -174,7 +235,7 @@ class AssessmentInteractionTest(
feedback_lines = [
"FEEDBACK",
FeedbackMessages.correctly_placed(1),
FeedbackMessages.misplaced(1),
FeedbackMessages.misplaced_returned(1),
FeedbackMessages.not_placed(2),
START_FEEDBACK
]
......@@ -199,26 +260,6 @@ class AssessmentInteractionTest(
expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback)
def test_grade(self):
"""
Test grading after submitting solution in assessment mode
"""
mock = Mock()
context = patch.object(WorkbenchRuntime, 'publish', mock)
context.start()
self.addCleanup(context.stop)
self.publish = mock
self.place_item(0, TOP_ZONE_ID, Keys.RETURN) # Correctly placed item
self.place_item(1, BOTTOM_ZONE_ID, Keys.RETURN) # Incorrectly placed item
self.place_item(4, MIDDLE_ZONE_ID, Keys.RETURN) # Incorrectly placed decoy
self.click_submit()
events = self.publish.call_args_list
published_grade = next((event[0][2] for event in events if event[0][1] == 'grade'))
expected_grade = {'max_value': 1, 'value': (1.0 / 5.0)}
self.assertEqual(published_grade, expected_grade)
def test_per_item_feedback_multiple_misplaced(self):
self.place_item(0, MIDDLE_ZONE_ID, Keys.RETURN)
self.place_item(1, BOTTOM_ZONE_ID, Keys.RETURN)
......
......@@ -190,6 +190,14 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
self.assertEqual(res.status_code, 400)
def test_show_answer_not_available(self):
"""
Tests that do_attempt handler returns 400 error for standard mode DnDv2
"""
res = self.call_handler(self.SHOW_ANSWER_HANDLER, expect_json=False)
self.assertEqual(res.status_code, 400)
@ddt.ddt
class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
......@@ -205,6 +213,12 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
data = self._make_submission(item_id, zone_id)
self.call_handler(self.DROP_ITEM_HANDLER, data)
def _get_all_solutions(self): # pylint: disable=no-self-use
raise NotImplementedError()
def _get_all_decoys(self): # pylint: disable=no-self-use
raise NotImplementedError()
def _submit_complete_solution(self): # pylint: disable=no-self-use
raise NotImplementedError()
......@@ -402,6 +416,51 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
self.assertEqual(self.block.item_state, original_item_state)
@ddt.data(
(None, 10, True),
(0, 12, True),
(3, 3, False),
)
@ddt.unpack
def test_show_answer_validation(self, max_attempts, attempts, expect_validation_error):
"""
Test that show_answer returns a 409 when max_attempts = None, or when
there are still attempts remaining.
"""
self.block.max_attempts = max_attempts
self.block.attempts = attempts
res = self.call_handler(self.SHOW_ANSWER_HANDLER, data={}, expect_json=False)
if expect_validation_error:
self.assertEqual(res.status_code, 409)
else:
self.assertEqual(res.status_code, 200)
def test_get_correct_state(self):
"""
Test that _get_correct_state returns one of the possible correct
solutions for the configuration.
"""
self._set_final_attempt()
self._submit_incorrect_solution()
self.call_handler(self.DO_ATTEMPT_HANDLER, data={})
self.assertFalse(self.block.attempts_remain) # precondition check
res = self.call_handler(self.SHOW_ANSWER_HANDLER, data={})
self.assertIn('items', res)
decoys = self._get_all_decoys()
solution = {}
for item_id, item_state in res['items'].iteritems():
self.assertIn('correct', item_state)
self.assertIn('zone', item_state)
self.assertNotIn(int(item_id), decoys)
solution[int(item_id)] = item_state['zone']
self.assertIn(solution, self._get_all_solutions())
class TestDragAndDropHtmlData(StandardModeFixture, unittest.TestCase):
FOLDER = "html"
......@@ -468,6 +527,12 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
self.assertEqual(res[self.FEEDBACK_KEY], expected_item_feedback)
self.assertEqual(res[self.OVERALL_FEEDBACK_KEY], expected_overall_feedback)
def _get_all_solutions(self):
return [{0: self.ZONE_1, 1: self.ZONE_2, 2: self.ZONE_2}]
def _get_all_decoys(self):
return [3, 4]
def _submit_complete_solution(self):
self._submit_solution({0: self.ZONE_1, 1: self.ZONE_2, 2: self.ZONE_2})
......@@ -486,15 +551,35 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
expected_item_feedback = [self._make_feedback_message(self.FEEDBACK[0]['incorrect'])]
expected_overall_feedback = [
self._make_feedback_message(
FeedbackMessages.correctly_placed(1), FeedbackMessages.MessageClasses.CORRECTLY_PLACED
FeedbackMessages.correctly_placed(1),
FeedbackMessages.MessageClasses.CORRECTLY_PLACED
),
self._make_feedback_message(
FeedbackMessages.misplaced_returned(1),
FeedbackMessages.MessageClasses.MISPLACED
),
self._make_feedback_message(
FeedbackMessages.not_placed(1),
FeedbackMessages.MessageClasses.NOT_PLACED
),
self._make_feedback_message(
self.INITIAL_FEEDBACK,
None
),
self._make_feedback_message(FeedbackMessages.misplaced(1), FeedbackMessages.MessageClasses.MISPLACED),
self._make_feedback_message(FeedbackMessages.not_placed(1), FeedbackMessages.MessageClasses.NOT_PLACED),
self._make_feedback_message(self.INITIAL_FEEDBACK, None),
]
self._assert_item_and_overall_feedback(res, expected_item_feedback, expected_overall_feedback)
def test_do_attempt_shows_correct_misplaced_feedback_at_last_attempt(self):
self._set_final_attempt()
self._submit_solution({0: self.ZONE_2})
res = self._do_attempt()
misplaced_message = self._make_feedback_message(
FeedbackMessages.misplaced(1),
FeedbackMessages.MessageClasses.MISPLACED
)
self.assertIn(misplaced_message, res[self.OVERALL_FEEDBACK_KEY])
def test_do_attempt_no_item_state(self):
"""
Test do_attempt overall feedback when no item state is saved - no items were ever dropped.
......@@ -517,11 +602,21 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
expected_item_feedback = []
expected_overall_feedback = [
self._make_feedback_message(
FeedbackMessages.correctly_placed(2), FeedbackMessages.MessageClasses.CORRECTLY_PLACED
FeedbackMessages.correctly_placed(2),
FeedbackMessages.MessageClasses.CORRECTLY_PLACED
),
self._make_feedback_message(
FeedbackMessages.misplaced_returned(1),
FeedbackMessages.MessageClasses.MISPLACED
),
self._make_feedback_message(
FeedbackMessages.not_placed(1),
FeedbackMessages.MessageClasses.NOT_PLACED
),
self._make_feedback_message(
self.INITIAL_FEEDBACK,
None
),
self._make_feedback_message(FeedbackMessages.misplaced(1), FeedbackMessages.MessageClasses.MISPLACED),
self._make_feedback_message(FeedbackMessages.not_placed(1), FeedbackMessages.MessageClasses.NOT_PLACED),
self._make_feedback_message(self.INITIAL_FEEDBACK, None),
]
self._assert_item_and_overall_feedback(res, expected_item_feedback, expected_overall_feedback)
......
......@@ -47,6 +47,7 @@ class TestCaseMixin(object):
DROP_ITEM_HANDLER = 'drop_item'
DO_ATTEMPT_HANDLER = 'do_attempt'
RESET_HANDLER = 'reset'
SHOW_ANSWER_HANDLER = 'show_answer'
USER_STATE_HANDLER = 'get_user_state'
def patch_workbench(self):
......
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