Commit 909e31e8 by Adolfo R. Brandes

Implement Show Answer button

In assessment mode, when the learner has run out of attempts, enable a
"Show Answer" button.  Clicking it causes all items to move to a correct
placement.
parent d6b36969
...@@ -108,7 +108,8 @@ There are two problem modes available: ...@@ -108,7 +108,8 @@ There are two problem modes available:
attempt to place an item, and the number of attempts is not limited. attempt to place an item, and the number of attempts is not limited.
* **Assessment**: In this mode, the learner places all items on the board and * **Assessment**: In this mode, the learner places all items on the board and
then clicks a "Submit" button to get feedback. The number of attempts can be then clicks a "Submit" button to get feedback. The number of attempts can be
limited. limited. When all attempts are used, the learner can click a "Show Answer"
button to temporarily place items on their correct drop zones.
![Drop zone edit](/doc/img/edit-view-zones.png) ![Drop zone edit](/doc/img/edit-view-zones.png)
......
...@@ -438,6 +438,28 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -438,6 +438,28 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
return self._get_user_state() return self._get_user_state()
@XBlock.json_handler @XBlock.json_handler
def show_answer(self, data, suffix=''):
"""
Returns correct answer in assessment mode.
Raises:
* JsonHandlerError with 400 error code in standard mode.
* JsonHandlerError with 409 error code if there are still attempts left
"""
if self.mode != Constants.ASSESSMENT_MODE:
raise JsonHandlerError(
400,
self.i18n_service.gettext("show_answer handler should only be called for assessment mode")
)
if self.attempts_remain:
raise JsonHandlerError(
409,
self.i18n_service.gettext("There are attempts remaining")
)
return self._get_correct_state()
@XBlock.json_handler
def expand_static_url(self, url, suffix=''): def expand_static_url(self, url, suffix=''):
""" AJAX-accessible handler for expanding URLs to static [image] files """ """ AJAX-accessible handler for expanding URLs to static [image] files """
return {'url': self._expand_static_url(url)} return {'url': self._expand_static_url(url)}
...@@ -723,6 +745,31 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -723,6 +745,31 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
'overall_feedback': self._present_feedback(overall_feedback_msgs) 'overall_feedback': self._present_feedback(overall_feedback_msgs)
} }
def _get_correct_state(self):
"""
Returns one of the possible correct states for the configured data.
"""
state = {}
items = copy.deepcopy(self.data.get('items', []))
for item in items:
zones = item.get('zones')
# For backwards compatibility
if zones is None:
zones = []
zone = item.get('zone')
if zone is not None and zone != 'none':
zones.append(zone)
if zones:
zone = zones.pop()
state[str(item['id'])] = {
'zone': zone,
'correct': True,
}
return {'items': state}
def _get_item_state(self): def _get_item_state(self):
""" """
Returns a copy of the user item state. Returns a copy of the user item state.
...@@ -855,4 +902,13 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -855,4 +902,13 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
""" """
A canned scenario for display in the workbench. A canned scenario for display in the workbench.
""" """
return [("Drag-and-drop-v2 scenario", "<vertical_demo><drag-and-drop-v2/></vertical_demo>")] return [
(
"Drag-and-drop-v2 standard",
"<vertical_demo><drag-and-drop-v2/></vertical_demo>"
),
(
"Drag-and-drop-v2 assessment",
"<vertical_demo><drag-and-drop-v2 mode='assessment' max_attempts='3'/></vertical_demo>"
),
]
...@@ -110,7 +110,7 @@ function DragAndDropTemplates(configuration) { ...@@ -110,7 +110,7 @@ function DragAndDropTemplates(configuration) {
if (item.is_placed) { if (item.is_placed) {
var zone_title = (zone.title || "Unknown Zone"); // This "Unknown" text should never be seen, so does not need i18n var zone_title = (zone.title || "Unknown Zone"); // This "Unknown" text should never be seen, so does not need i18n
var description_content; var description_content;
if (configuration.mode === DragAndDropBlock.ASSESSMENT_MODE) { if (configuration.mode === DragAndDropBlock.ASSESSMENT_MODE && !ctx.showing_answer) {
// In assessment mode placed items will "stick" even when not in correct zone. // In assessment mode placed items will "stick" even when not in correct zone.
description_content = gettext('Placed in: {zone_title}').replace('{zone_title}', zone_title); description_content = gettext('Placed in: {zone_title}').replace('{zone_title}', zone_title);
} else { } else {
...@@ -180,9 +180,8 @@ function DragAndDropTemplates(configuration) { ...@@ -180,9 +180,8 @@ function DragAndDropTemplates(configuration) {
var zoneTemplate = function(zone, ctx) { var zoneTemplate = function(zone, ctx) {
var className = ctx.display_zone_labels ? 'zone-name' : 'zone-name sr'; var className = ctx.display_zone_labels ? 'zone-name' : 'zone-name sr';
var selector = ctx.display_zone_borders ? 'div.zone.zone-with-borders' : 'div.zone'; var selector = ctx.display_zone_borders ? 'div.zone.zone-with-borders' : 'div.zone';
// If zone is aligned, mark its item alignment // Mark item alignment and render its placed items as children
// and render its placed items as children var item_wrapper = 'div.item-wrapper.item-align.item-align-' + zone.align;
var item_wrapper = 'div.item-wrapper';
var is_item_in_zone = function(i) { return i.is_placed && (i.zone === zone.uid); }; var is_item_in_zone = function(i) { return i.is_placed && (i.zone === zone.uid); };
var items_in_zone = $.grep(ctx.items, is_item_in_zone); var items_in_zone = $.grep(ctx.items, is_item_in_zone);
var zone_description_id = 'zone-' + zone.uid + '-description'; var zone_description_id = 'zone-' + zone.uid + '-description';
...@@ -199,12 +198,7 @@ function DragAndDropTemplates(configuration) { ...@@ -199,12 +198,7 @@ function DragAndDropTemplates(configuration) {
gettext('Items placed here: ') + items_in_zone.map(function (item) { return item.displayName; }).join(", ") gettext('Items placed here: ') + items_in_zone.map(function (item) { return item.displayName; }).join(", ")
); );
} }
if (zone.align !== 'none') {
item_wrapper += '.item-align.item-align-' + zone.align;
//items_in_zone = $.grep(ctx.items, is_item_in_zone);
} else {
items_in_zone = [];
}
return ( return (
h( h(
selector, selector,
...@@ -343,14 +337,21 @@ function DragAndDropTemplates(configuration) { ...@@ -343,14 +337,21 @@ function DragAndDropTemplates(configuration) {
); );
}; };
var sidebarButtonTemplate = function(buttonClass, iconClass, buttonText, disabled) { var sidebarButtonTemplate = function(buttonClass, iconClass, buttonText, disabled, spinner) {
if (spinner) {
iconClass = 'fa-spin.fa-spinner';
}
return ( return (
h('span.sidebar-button-wrapper', {}, [ h('span.sidebar-button-wrapper', {}, [
h( h(
'button.unbutton.btn-default.btn-small.'+buttonClass, 'button.unbutton.btn-default.btn-small.'+buttonClass,
{disabled: disabled || false, attributes: {tabindex: 0}}, {disabled: disabled || spinner || false, attributes: {tabindex: 0}},
[ [
h("span.btn-icon.fa."+iconClass, {attributes: {"aria-hidden": true}}, []), h(
"span.btn-icon.fa." + iconClass,
{attributes: {"aria-hidden": true}},
[]
),
buttonText buttonText
] ]
) )
...@@ -359,10 +360,21 @@ function DragAndDropTemplates(configuration) { ...@@ -359,10 +360,21 @@ function DragAndDropTemplates(configuration) {
}; };
var sidebarTemplate = function(ctx) { var sidebarTemplate = function(ctx) {
var showAnswerButton = null;
if (ctx.show_show_answer) {
showAnswerButton = sidebarButtonTemplate(
"show-answer-button",
"fa-info-circle",
gettext('Show Answer'),
ctx.showing_answer ? true : ctx.disable_show_answer_button,
ctx.show_answer_spinner
);
}
return( return(
h("section.action-toolbar-item.sidebar-buttons", {}, [ h("section.action-toolbar-item.sidebar-buttons", {}, [
sidebarButtonTemplate("keyboard-help-button", "fa-question", gettext('Keyboard Help')), sidebarButtonTemplate("keyboard-help-button", "fa-question", gettext('Keyboard Help')),
sidebarButtonTemplate("reset-button", "fa-refresh", gettext('Reset'), ctx.disable_reset_button), sidebarButtonTemplate("reset-button", "fa-refresh", gettext('Reset'), ctx.disable_reset_button),
showAnswerButton,
]) ])
) )
}; };
...@@ -434,9 +446,8 @@ function DragAndDropTemplates(configuration) { ...@@ -434,9 +446,8 @@ function DragAndDropTemplates(configuration) {
var mainTemplate = function(ctx) { var mainTemplate = function(ctx) {
var problemTitle = ctx.show_title ? h('h3.problem-title', {innerHTML: ctx.title_html}) : null; var problemTitle = ctx.show_title ? h('h3.problem-title', {innerHTML: ctx.title_html}) : null;
var problemHeader = ctx.show_problem_header ? h('h4.title1', gettext('Problem')) : null; var problemHeader = ctx.show_problem_header ? h('h4.title1', gettext('Problem')) : null;
// Render only items in the bank here, including placeholders. Placed
// Render only items_in_bank and items_placed_unaligned here; // items will be rendered by zoneTemplate.
// items placed in aligned zones will be rendered by zoneTemplate.
var is_item_placed = function(i) { return i.is_placed; }; var is_item_placed = function(i) { return i.is_placed; };
var items_placed = $.grep(ctx.items, is_item_placed); var items_placed = $.grep(ctx.items, is_item_placed);
var items_in_bank = $.grep(ctx.items, is_item_placed, true); var items_in_bank = $.grep(ctx.items, is_item_placed, true);
...@@ -561,6 +572,10 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -561,6 +572,10 @@ function DragAndDropBlock(runtime, element, configuration) {
$element.on('keydown', '.reset-button', function(evt) { $element.on('keydown', '.reset-button', function(evt) {
runOnKey(evt, RET, resetProblem); runOnKey(evt, RET, resetProblem);
}); });
$element.on('click', '.show-answer-button', showAnswer);
$element.on('keydown', '.show-answer-button', function(evt) {
runOnKey(evt, RET, showAnswer);
});
// For the next one, we need to use addEventListener with useCapture 'true' in order // For the next one, we need to use addEventListener with useCapture 'true' in order
// to watch for load events on any child element, since load events do not bubble. // to watch for load events on any child element, since load events do not bubble.
...@@ -1098,6 +1113,26 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -1098,6 +1113,26 @@ function DragAndDropBlock(runtime, element, configuration) {
}); });
}; };
var showAnswer = function(evt) {
evt.preventDefault();
state.show_answer_spinner = true;
applyState();
$.ajax({
type: 'POST',
url: runtime.handlerUrl(element, 'show_answer'),
data: '{}',
}).done(function(data) {
state.items = data.items;
state.showing_answer = true;
delete state.feedback;
}).always(function() {
state.show_answer_spinner = false;
applyState();
$root.find('.item-bank').focus();
});
};
var doAttempt = function(evt) { var doAttempt = function(evt) {
evt.preventDefault(); evt.preventDefault();
state.submit_spinner = true; state.submit_spinner = true;
...@@ -1147,6 +1182,10 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -1147,6 +1182,10 @@ function DragAndDropBlock(runtime, element, configuration) {
return any_items_placed && (configuration.mode !== DragAndDropBlock.ASSESSMENT_MODE || attemptsRemain()); return any_items_placed && (configuration.mode !== DragAndDropBlock.ASSESSMENT_MODE || attemptsRemain());
}; };
var canShowAnswer = function() {
return configuration.mode === DragAndDropBlock.ASSESSMENT_MODE && !attemptsRemain();
};
var attemptsRemain = function() { var attemptsRemain = function() {
return !configuration.max_attempts || configuration.max_attempts > state.attempts; return !configuration.max_attempts || configuration.max_attempts > state.attempts;
}; };
...@@ -1207,7 +1246,7 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -1207,7 +1246,7 @@ function DragAndDropBlock(runtime, element, configuration) {
// In assessment mode, it is possible to move items back to the bank, so the bank should be able to // In assessment mode, it is possible to move items back to the bank, so the bank should be able to
// gain focus while keyboard placement is in progress. // gain focus while keyboard placement is in progress.
var item_bank_focusable = state.keyboard_placement_mode && var item_bank_focusable = (state.keyboard_placement_mode || state.showing_answer) &&
configuration.mode === DragAndDropBlock.ASSESSMENT_MODE; configuration.mode === DragAndDropBlock.ASSESSMENT_MODE;
var context = { var context = {
...@@ -1220,6 +1259,7 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -1220,6 +1259,7 @@ function DragAndDropBlock(runtime, element, configuration) {
problem_html: configuration.problem_text, problem_html: configuration.problem_text,
show_problem_header: configuration.show_problem_header, show_problem_header: configuration.show_problem_header,
show_submit_answer: configuration.mode == DragAndDropBlock.ASSESSMENT_MODE, show_submit_answer: configuration.mode == DragAndDropBlock.ASSESSMENT_MODE,
show_show_answer: configuration.mode == DragAndDropBlock.ASSESSMENT_MODE,
target_img_src: configuration.target_img_expanded_url, target_img_src: configuration.target_img_expanded_url,
target_img_description: configuration.target_img_description, target_img_description: configuration.target_img_description,
display_zone_labels: configuration.display_zone_labels, display_zone_labels: configuration.display_zone_labels,
...@@ -1233,8 +1273,11 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -1233,8 +1273,11 @@ function DragAndDropBlock(runtime, element, configuration) {
feedback_messages: state.feedback, feedback_messages: state.feedback,
overall_feedback_messages: state.overall_feedback, overall_feedback_messages: state.overall_feedback,
disable_reset_button: !canReset(), disable_reset_button: !canReset(),
disable_show_answer_button: !canShowAnswer(),
disable_submit_button: !canSubmitAttempt(), disable_submit_button: !canSubmitAttempt(),
submit_spinner: state.submit_spinner submit_spinner: state.submit_spinner,
showing_answer: state.showing_answer,
show_answer_spinner: state.show_answer_spinner
}; };
return renderView(context); return renderView(context);
......
...@@ -217,6 +217,14 @@ msgid "Max number of attempts reached" ...@@ -217,6 +217,14 @@ msgid "Max number of attempts reached"
msgstr "" msgstr ""
#: drag_and_drop_v2.py #: drag_and_drop_v2.py
msgid "show_answer handler should only be called for assessment mode"
msgstr ""
#: drag_and_drop_v2.py
msgid "There are attempts remaining"
msgstr ""
#: drag_and_drop_v2.py
msgid "Unknown DnDv2 mode {mode} - course is misconfigured" msgid "Unknown DnDv2 mode {mode} - course is misconfigured"
msgstr "" msgstr ""
...@@ -446,6 +454,14 @@ msgid "Reset" ...@@ -446,6 +454,14 @@ msgid "Reset"
msgstr "" msgstr ""
#: public/js/drag_and_drop.js #: public/js/drag_and_drop.js
msgid "Show Answer"
msgstr ""
#: public/js/drag_and_drop.js
msgid "Hide Answer"
msgstr ""
#: public/js/drag_and_drop.js
msgid "Submit" msgid "Submit"
msgstr "" msgstr ""
......
...@@ -272,6 +272,14 @@ msgid "Max number of attempts reached" ...@@ -272,6 +272,14 @@ msgid "Max number of attempts reached"
msgstr "Mäx nümßér öf ättémpts réäçhéd Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#" msgstr "Mäx nümßér öf ättémpts réäçhéd Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#"
#: drag_and_drop_v2.py #: drag_and_drop_v2.py
msgid "show_answer handler should only be called for assessment mode"
msgstr "shöw_änswér händlér shöüld önlý ßé çälléd för ässéssmént mödé Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#"
#: drag_and_drop_v2.py
msgid "There are attempts remaining"
msgstr "Théré äré ättémpts rémäïnïng Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢#"
#: drag_and_drop_v2.py
msgid "Unknown DnDv2 mode {mode} - course is misconfigured" msgid "Unknown DnDv2 mode {mode} - course is misconfigured"
msgstr "Ûnknöwn DnDv2 mödé {mode} - çöürsé ïs mïsçönfïgüréd Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#" msgstr "Ûnknöwn DnDv2 mödé {mode} - çöürsé ïs mïsçönfïgüréd Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#"
...@@ -520,6 +528,14 @@ msgid "Reset" ...@@ -520,6 +528,14 @@ msgid "Reset"
msgstr "Rését Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#" msgstr "Rését Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
#: public/js/drag_and_drop.js #: public/js/drag_and_drop.js
msgid "Show Answer"
msgstr "Shöw Ànswér Ⱡ'σяєм ιρѕυм ∂σłσя #"
#: public/js/drag_and_drop.js
msgid "Hide Answer"
msgstr "Hïdé Ànswér Ⱡ'σяєм ιρѕυм ∂σłσя #"
#: public/js/drag_and_drop.js
msgid "Submit" msgid "Submit"
msgstr "Süßmït Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#" msgstr "Süßmït Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
......
...@@ -126,6 +126,9 @@ class BaseIntegrationTest(SeleniumBaseTest): ...@@ -126,6 +126,9 @@ class BaseIntegrationTest(SeleniumBaseTest):
def _get_reset_button(self): def _get_reset_button(self):
return self._page.find_element_by_css_selector('.reset-button') return self._page.find_element_by_css_selector('.reset-button')
def _get_show_answer_button(self):
return self._page.find_element_by_css_selector('.show-answer-button')
def _get_submit_button(self): def _get_submit_button(self):
return self._page.find_element_by_css_selector('.submit-answer-button') return self._page.find_element_by_css_selector('.submit-answer-button')
...@@ -392,12 +395,21 @@ class InteractionTestBase(object): ...@@ -392,12 +395,21 @@ class InteractionTestBase(object):
self.assertDraggable(item_value) self.assertDraggable(item_value)
self.assertEqual(item.get_attribute('class'), 'option') self.assertEqual(item.get_attribute('class'), 'option')
self.assertEqual(item.get_attribute('tabindex'), '0') self.assertEqual(item.get_attribute('tabindex'), '0')
self.assertEqual(item_description.text, 'Placed in: {}'.format(zone_title)) description = 'Placed in: {}'
else: else:
self.assertNotDraggable(item_value) self.assertNotDraggable(item_value)
self.assertEqual(item.get_attribute('class'), 'option fade') self.assertEqual(item.get_attribute('class'), 'option fade')
self.assertIsNone(item.get_attribute('tabindex')) self.assertIsNone(item.get_attribute('tabindex'))
self.assertEqual(item_description.text, 'Correctly placed in: {}'.format(zone_title)) description = 'Correctly placed in: {}'
# An item with multiple drop zones could be located in any one of these
# zones. In that case, zone_title will be a list, and we need to check
# whether the zone info in the description of the item matches any of
# the zones in that list.
if isinstance(zone_title, list):
self.assertIn(item_description.text, [description.format(title) for title in zone_title])
else:
self.assertEqual(item_description.text, description.format(zone_title))
def assert_reverted_item(self, item_value): def assert_reverted_item(self, item_value):
item = self._get_item_by_value(item_value) item = self._get_item_by_value(item_value)
......
...@@ -442,7 +442,7 @@ class MultipleBlocksDataInteraction(ParameterizedTestsMixin, InteractionTestBase ...@@ -442,7 +442,7 @@ class MultipleBlocksDataInteraction(ParameterizedTestsMixin, InteractionTestBase
self._switch_to_block(1) self._switch_to_block(1)
# Test mouse and keyboard interaction # Test mouse and keyboard interaction
self.interact_with_keyboard_help(scroll_down=900) self.interact_with_keyboard_help(scroll_down=1200)
self.interact_with_keyboard_help(scroll_down=0, use_keyboard=True) self.interact_with_keyboard_help(scroll_down=0, use_keyboard=True)
......
# -*- coding: utf-8 -*-
# Imports ########################################################### # Imports ###########################################################
from ddt import ddt, data from ddt import ddt, data
...@@ -55,6 +57,14 @@ class AssessmentTestMixin(object): ...@@ -55,6 +57,14 @@ class AssessmentTestMixin(object):
submit_button.click() submit_button.click()
self.wait_for_ajax() self.wait_for_ajax()
def click_show_answer(self):
show_answer_button = self._get_show_answer_button()
self._wait_until_enabled(show_answer_button)
show_answer_button.click()
self.wait_for_ajax()
@ddt @ddt
class AssessmentInteractionTest( class AssessmentInteractionTest(
...@@ -150,6 +160,58 @@ class AssessmentInteractionTest( ...@@ -150,6 +160,58 @@ class AssessmentInteractionTest(
self.assertEqual(submit_button.get_attribute('disabled'), 'true') self.assertEqual(submit_button.get_attribute('disabled'), 'true')
self.assertEqual(reset_button.get_attribute('disabled'), 'true') self.assertEqual(reset_button.get_attribute('disabled'), 'true')
def _assert_show_answer_item_placement(self):
zones = dict(self.all_zones)
for item in self._get_items_with_zone(self.items_map).values():
zone_titles = [zones[zone_id] for zone_id in item.zone_ids]
# When showing answers, correct items are placed as if assessment_mode=False
self.assert_placed_item(item.item_id, zone_titles, assessment_mode=False)
for item_definition in self._get_items_without_zone(self.items_map).values():
self.assertNotDraggable(item_definition.item_id)
item = self._get_item_by_value(item_definition.item_id)
self.assertEqual(item.get_attribute('aria-grabbed'), 'false')
self.assertEqual(item.get_attribute('class'), 'option fade')
item_content = item.find_element_by_css_selector('.item-content')
item_description_id = '-item-{}-description'.format(item_definition.item_id)
self.assertEqual(item_content.get_attribute('aria-describedby'), item_description_id)
describedby_text = (u'Press "Enter", "Space", "Ctrl-m", or "⌘-m" on an item to select it for dropping, '
'then navigate to the zone you want to drop it on.')
self.assertEqual(item.find_element_by_css_selector('.sr').text, describedby_text)
def test_show_answer(self):
"""
Test "Show Answer" button is shown in assessment mode, enabled when no
more attempts remaining, is disabled and displays correct answers when
clicked.
"""
show_answer_button = self._get_show_answer_button()
self.assertTrue(show_answer_button.is_displayed())
self.place_item(0, TOP_ZONE_ID, Keys.RETURN)
for _ in xrange(self.MAX_ATTEMPTS-1):
self.assertEqual(show_answer_button.get_attribute('disabled'), 'true')
self.click_submit()
# Place an incorrect item on the final attempt.
self.place_item(1, TOP_ZONE_ID, Keys.RETURN)
self.click_submit()
# A feedback popup should open upon final submission.
popup = self._get_popup()
self.assertTrue(popup.is_displayed())
self.assertIsNone(show_answer_button.get_attribute('disabled'))
self.click_show_answer()
# The popup should be closed upon clicking Show Answer.
self.assertFalse(popup.is_displayed())
self.assertEqual(show_answer_button.get_attribute('disabled'), 'true')
self._assert_show_answer_item_placement()
def test_do_attempt_feedback_is_updated(self): def test_do_attempt_feedback_is_updated(self):
""" """
Test updating overall feedback after submitting solution in assessment mode Test updating overall feedback after submitting solution in assessment mode
......
...@@ -190,6 +190,14 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture): ...@@ -190,6 +190,14 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
self.assertEqual(res.status_code, 400) self.assertEqual(res.status_code, 400)
def test_show_answer_not_available(self):
"""
Tests that do_attempt handler returns 400 error for standard mode DnDv2
"""
res = self.call_handler(self.SHOW_ANSWER_HANDLER, expect_json=False)
self.assertEqual(res.status_code, 400)
@ddt.ddt @ddt.ddt
class AssessmentModeFixture(BaseDragAndDropAjaxFixture): class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
...@@ -205,6 +213,12 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture): ...@@ -205,6 +213,12 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
data = self._make_submission(item_id, zone_id) data = self._make_submission(item_id, zone_id)
self.call_handler(self.DROP_ITEM_HANDLER, data) self.call_handler(self.DROP_ITEM_HANDLER, data)
def _get_all_solutions(self): # pylint: disable=no-self-use
raise NotImplementedError()
def _get_all_decoys(self): # pylint: disable=no-self-use
raise NotImplementedError()
def _submit_complete_solution(self): # pylint: disable=no-self-use def _submit_complete_solution(self): # pylint: disable=no-self-use
raise NotImplementedError() raise NotImplementedError()
...@@ -402,6 +416,51 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture): ...@@ -402,6 +416,51 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
self.assertEqual(self.block.item_state, original_item_state) self.assertEqual(self.block.item_state, original_item_state)
@ddt.data(
(None, 10, True),
(0, 12, True),
(3, 3, False),
)
@ddt.unpack
def test_show_answer_validation(self, max_attempts, attempts, expect_validation_error):
"""
Test that show_answer returns a 409 when max_attempts = None, or when
there are still attempts remaining.
"""
self.block.max_attempts = max_attempts
self.block.attempts = attempts
res = self.call_handler(self.SHOW_ANSWER_HANDLER, data={}, expect_json=False)
if expect_validation_error:
self.assertEqual(res.status_code, 409)
else:
self.assertEqual(res.status_code, 200)
def test_get_correct_state(self):
"""
Test that _get_correct_state returns one of the possible correct
solutions for the configuration.
"""
self._set_final_attempt()
self._submit_incorrect_solution()
self.call_handler(self.DO_ATTEMPT_HANDLER, data={})
self.assertFalse(self.block.attempts_remain) # precondition check
res = self.call_handler(self.SHOW_ANSWER_HANDLER, data={})
self.assertIn('items', res)
decoys = self._get_all_decoys()
solution = {}
for item_id, item_state in res['items'].iteritems():
self.assertIn('correct', item_state)
self.assertIn('zone', item_state)
self.assertNotIn(int(item_id), decoys)
solution[int(item_id)] = item_state['zone']
self.assertIn(solution, self._get_all_solutions())
class TestDragAndDropHtmlData(StandardModeFixture, unittest.TestCase): class TestDragAndDropHtmlData(StandardModeFixture, unittest.TestCase):
FOLDER = "html" FOLDER = "html"
...@@ -468,6 +527,12 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase): ...@@ -468,6 +527,12 @@ class TestDragAndDropAssessmentData(AssessmentModeFixture, unittest.TestCase):
self.assertEqual(res[self.FEEDBACK_KEY], expected_item_feedback) self.assertEqual(res[self.FEEDBACK_KEY], expected_item_feedback)
self.assertEqual(res[self.OVERALL_FEEDBACK_KEY], expected_overall_feedback) self.assertEqual(res[self.OVERALL_FEEDBACK_KEY], expected_overall_feedback)
def _get_all_solutions(self):
return [{0: self.ZONE_1, 1: self.ZONE_2, 2: self.ZONE_2}]
def _get_all_decoys(self):
return [3, 4]
def _submit_complete_solution(self): def _submit_complete_solution(self):
self._submit_solution({0: self.ZONE_1, 1: self.ZONE_2, 2: self.ZONE_2}) self._submit_solution({0: self.ZONE_1, 1: self.ZONE_2, 2: self.ZONE_2})
......
...@@ -47,6 +47,7 @@ class TestCaseMixin(object): ...@@ -47,6 +47,7 @@ class TestCaseMixin(object):
DROP_ITEM_HANDLER = 'drop_item' DROP_ITEM_HANDLER = 'drop_item'
DO_ATTEMPT_HANDLER = 'do_attempt' DO_ATTEMPT_HANDLER = 'do_attempt'
RESET_HANDLER = 'reset' RESET_HANDLER = 'reset'
SHOW_ANSWER_HANDLER = 'show_answer'
USER_STATE_HANDLER = 'get_user_state' USER_STATE_HANDLER = 'get_user_state'
def patch_workbench(self): def patch_workbench(self):
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment