Commit 97c5c4d8 by Matjaz Gregoric

Keep items in dropped position in assessment mode.

Items dropped onto wrong zones in standard mode are immediately removed
from the board and sent back to the bank.

In assessment mode, items should stick in the dropped position until the
entire problem is explicitly submitted by clicking a button.

This commit only implements the "stick" part, but does not implement the
submit button and associated handler.

This commit also fixes an existing bug where the template function
would gett reused between blocks.

The template function (which is slightly different for each block,
depending on block's configuration) was getting assigned to the global
DragAndDropBlock function. Each block would overwrite the previous
value, which caused bugs.
parent cd689891
......@@ -190,6 +190,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
return items
return {
"mode": self.mode,
"zones": self._get_zones(),
# SDK doesn't supply url_name.
"url_name": getattr(self, 'url_name', ''),
......@@ -337,12 +338,18 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
'is_correct': is_correct,
})
return {
'correct': is_correct,
'finished': self._is_finished(),
'overall_feedback': overall_feedback,
'feedback': feedback
}
if self.mode == self.ASSESSMENT_MODE:
# In assessment mode we don't send any feedback on drop.
result = {}
else:
result = {
'correct': is_correct,
'finished': self._is_finished(),
'overall_feedback': overall_feedback,
'feedback': feedback
}
return result
@XBlock.json_handler
def reset(self, data, suffix=''):
......
function DragNDropTemplates(url_name) {
function DragAndDropTemplates(configuration) {
"use strict";
var h = virtualDom.h;
// Set up a mock for gettext if it isn't available in the client runtime:
......@@ -107,13 +107,22 @@ function DragNDropTemplates(url_name) {
var item_content = h('div', { innerHTML: item_content_html, className: "item-content" });
if (item.is_placed) {
// Insert information about zone in which this item has been placed
var item_description_id = url_name + '-item-' + item.value + '-description';
var item_description_id = configuration.url_name + '-item-' + item.value + '-description';
item_content.properties.attributes = { 'aria-describedby': item_description_id };
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) {
// 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 {
// In standard mode item is immediately returned back to the bank if dropped on a wrong zone,
// so all placed items are always in the correct zone.
description_content = gettext('Correctly placed in: {zone_title}').replace('{zone_title}', zone_title);
}
var item_description = h(
'div',
{ id: item_description_id, className: 'sr' },
gettext('Correctly placed in: ') + zone_title
description_content
);
children.splice(1, 0, item_description);
}
......@@ -283,14 +292,17 @@ function DragNDropTemplates(url_name) {
);
};
DragAndDropBlock.renderView = mainTemplate;
return mainTemplate;
}
function DragAndDropBlock(runtime, element, configuration) {
"use strict";
DragNDropTemplates(configuration.url_name);
DragAndDropBlock.STANDARD_MODE = 'standard';
DragAndDropBlock.ASSESSMENT_MODE = 'assessment';
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; }; }
......@@ -747,17 +759,21 @@ function DragAndDropBlock(runtime, element, configuration) {
$.post(url, JSON.stringify(data), 'json')
.done(function(data){
state.last_action_correct = data.correct;
if (data.correct) {
state.items[item_id].correct = true;
state.items[item_id].submitting_location = false;
} else {
delete state.items[item_id];
}
state.feedback = data.feedback;
if (data.finished) {
state.finished = true;
state.overall_feedback = data.overall_feedback;
state.items[item_id].submitting_location = false;
// In standard mode we immediately return item to the bank if dropped on wrong zone.
// In assessment mode we leave it in the chosen zone until explicit answer submission.
if (configuration.mode === DragAndDropBlock.STANDARD_MODE) {
state.last_action_correct = data.correct;
if (data.correct) {
state.items[item_id].correct = true;
} else {
delete state.items[item_id];
}
state.feedback = data.feedback;
if (data.finished) {
state.finished = true;
state.overall_feedback = data.overall_feedback;
}
}
applyState();
})
......@@ -866,7 +882,7 @@ function DragAndDropBlock(runtime, element, configuration) {
display_reset_button: Object.keys(state.items).length > 0,
};
return DragAndDropBlock.renderView(context);
return renderView(context);
};
/**
......
......@@ -409,6 +409,10 @@ msgid "ok"
msgstr ""
#: public/js/drag_and_drop.js
msgid "Placed in: "
msgstr ""
#: public/js/drag_and_drop.js
msgid "Correctly placed in: "
msgstr ""
......
......@@ -494,6 +494,10 @@ msgid "ok"
msgstr "ök Ⱡ'σя#"
#: public/js/drag_and_drop.js
msgid "Placed in: "
msgstr "Pläçéd ïn: Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #"
#: public/js/drag_and_drop.js
msgid "Correctly placed in: "
msgstr "Çörréçtlý pläçéd ïn: Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #"
......
......@@ -6,6 +6,7 @@ from mock import Mock, patch
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver import ActionChains
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import WebDriverWait
from workbench.runtime import WorkbenchRuntime
from xblockutils.resources import ResourceLoader
......@@ -89,6 +90,19 @@ class InteractionTestBase(object):
'return $("div[data-uid=\'{zone_id}\']").prevAll(".zone").length'.format(zone_id=zone_id)
)
@staticmethod
def wait_until_ondrop_xhr_finished(elem):
"""
Waits until the XHR request triggered by dropping the item finishes loading.
"""
wait = WebDriverWait(elem, 2)
# While the XHR is in progress, a spinner icon is shown inside the item.
# When the spinner disappears, we can assume that the XHR request has finished.
wait.until(
lambda e: 'fa-spinner' not in e.get_attribute('innerHTML'),
u"Spinner should not be in {}".format(elem.get_attribute('innerHTML'))
)
def place_item(self, item_value, zone_id, action_key=None):
if action_key is None:
self.drag_item_to_zone(item_value, zone_id)
......@@ -117,7 +131,7 @@ class InteractionTestBase(object):
def assert_grabbed_item(self, item):
self.assertEqual(item.get_attribute('aria-grabbed'), 'true')
def assert_placed_item(self, item_value, zone_title):
def assert_placed_item(self, item_value, zone_title, assessment_mode=False):
item = self._get_placed_item_by_value(item_value)
self.wait_until_visible(item)
item_content = item.find_element_by_css_selector('.item-content')
......@@ -131,7 +145,10 @@ class InteractionTestBase(object):
self.assertEqual(item.get_attribute('data-drag-disabled'), 'true')
self.assertEqual(item_content.get_attribute('aria-describedby'), item_description_id)
self.assertEqual(item_description.get_attribute('id'), item_description_id)
self.assertEqual(item_description.text, 'Correctly placed in: {}'.format(zone_title))
if assessment_mode:
self.assertEqual(item_description.text, 'Placed in: {}'.format(zone_title))
else:
self.assertEqual(item_description.text, 'Correctly placed in: {}'.format(zone_title))
def assert_reverted_item(self, item_value):
item = self._get_item_by_value(item_value)
......@@ -152,16 +169,28 @@ class InteractionTestBase(object):
else:
self.fail('Reverted item should not have .sr description.')
def assert_decoy_items(self, items_map):
def place_decoy_items(self, items_map, action_key):
decoy_items = self._get_items_without_zone(items_map)
# Place decoy items into first available zone.
zone_id, zone_title = self.all_zones[0]
for definition in decoy_items.values():
self.place_item(definition.item_id, zone_id, action_key)
self.assert_placed_item(definition.item_id, zone_title, assessment_mode=True)
def assert_decoy_items(self, items_map, assessment_mode=False):
decoy_items = self._get_items_without_zone(items_map)
for item_key in decoy_items:
item = self._get_item_by_value(item_key)
self.assertEqual(item.get_attribute('class'), 'option fade')
self.assertEqual(item.get_attribute('aria-grabbed'), 'false')
self.assertEqual(item.get_attribute('data-drag-disabled'), 'true')
def parameterized_item_positive_feedback_on_good_move(self, items_map, scroll_down=100, action_key=None):
if assessment_mode:
self.assertEqual(item.get_attribute('class'), 'option')
else:
self.assertEqual(item.get_attribute('class'), 'option fade')
def parameterized_item_positive_feedback_on_good_move(
self, items_map, scroll_down=100, action_key=None, assessment_mode=False
):
popup = self._get_popup()
feedback_popup_content = self._get_popup_content()
......@@ -170,11 +199,20 @@ class InteractionTestBase(object):
for definition in self._get_items_with_zone(items_map).values():
self.place_item(definition.item_id, definition.zone_ids[0], action_key)
self.wait_until_html_in(definition.feedback_positive, feedback_popup_content)
self.assertEqual(popup.get_attribute('class'), 'popup')
self.assert_placed_item(definition.item_id, definition.zone_title)
self.wait_until_ondrop_xhr_finished(self._get_item_by_value(definition.item_id))
self.assert_placed_item(definition.item_id, definition.zone_title, assessment_mode=assessment_mode)
feedback_popup_html = feedback_popup_content.get_attribute('innerHTML')
if assessment_mode:
self.assertEqual(feedback_popup_html, '')
self.assertFalse(popup.is_displayed())
else:
self.assertEqual(feedback_popup_html, definition.feedback_positive)
self.assertEqual(popup.get_attribute('class'), 'popup')
self.assertTrue(popup.is_displayed())
def parameterized_item_negative_feedback_on_bad_move(self, items_map, all_zones, scroll_down=100, action_key=None):
def parameterized_item_negative_feedback_on_bad_move(
self, items_map, all_zones, scroll_down=100, action_key=None, assessment_mode=False
):
popup = self._get_popup()
feedback_popup_content = self._get_popup_content()
......@@ -182,15 +220,31 @@ class InteractionTestBase(object):
self.scroll_down(pixels=scroll_down)
for definition in items_map.values():
for zone in all_zones:
if zone in definition.zone_ids:
continue
self.place_item(definition.item_id, zone, action_key)
self.wait_until_html_in(definition.feedback_negative, feedback_popup_content)
self.assertEqual(popup.get_attribute('class'), 'popup popup-incorrect')
self.assert_reverted_item(definition.item_id)
def parameterized_final_feedback_and_reset(self, items_map, feedback, scroll_down=100, action_key=None):
# Get first zone that is not correct for this item.
zone_id = None
zone_title = None
for z_id, z_title in all_zones:
if z_id not in definition.zone_ids:
zone_id = z_id
zone_title = z_title
break
if zone_id is not None: # Some items may be placed in any zone, ignore those.
self.place_item(definition.item_id, zone_id, action_key)
if assessment_mode:
self.wait_until_ondrop_xhr_finished(self._get_item_by_value(definition.item_id))
feedback_popup_html = feedback_popup_content.get_attribute('innerHTML')
self.assertEqual(feedback_popup_html, '')
self.assertFalse(popup.is_displayed())
self.assert_placed_item(definition.item_id, zone_title, assessment_mode=True)
else:
self.wait_until_html_in(definition.feedback_negative, feedback_popup_content)
self.assertEqual(popup.get_attribute('class'), 'popup popup-incorrect')
self.assertTrue(popup.is_displayed())
self.assert_reverted_item(definition.item_id)
def parameterized_final_feedback_and_reset(
self, items_map, feedback, scroll_down=100, action_key=None, assessment_mode=False
):
feedback_message = self._get_feedback_message()
self.assertEqual(self.get_element_html(feedback_message), feedback['intro']) # precondition check
......@@ -206,12 +260,17 @@ class InteractionTestBase(object):
for item_key, definition in items.items():
self.place_item(definition.item_id, definition.zone_ids[0], action_key)
self.assert_placed_item(definition.item_id, definition.zone_title)
self.assert_placed_item(definition.item_id, definition.zone_title, assessment_mode=assessment_mode)
self.wait_until_html_in(feedback['final'], self._get_feedback_message())
if assessment_mode:
# In assessment mode we also place decoy items onto the board,
# to make sure they are correctly reverted back to the bank on problem reset.
self.place_decoy_items(items_map, action_key)
else:
self.wait_until_html_in(feedback['final'], self._get_feedback_message())
# Check decoy items
self.assert_decoy_items(items_map)
self.assert_decoy_items(items_map, assessment_mode=assessment_mode)
# Scroll "Reset problem" button into view to make sure Selenium can successfully click it
self.scroll_down(pixels=scroll_down+150)
......@@ -298,7 +357,11 @@ class DefaultDataTestMixin(object):
4: ItemDefinition(4, [], None, "", ITEM_NO_ZONE_FEEDBACK),
}
all_zones = [TOP_ZONE_ID, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID]
all_zones = [
(TOP_ZONE_ID, TOP_ZONE_TITLE),
(MIDDLE_ZONE_ID, MIDDLE_ZONE_TITLE),
(BOTTOM_ZONE_ID, BOTTOM_ZONE_TITLE)
]
feedback = {
"intro": START_FEEDBACK,
......@@ -309,21 +372,66 @@ class DefaultDataTestMixin(object):
return "<vertical_demo><drag-and-drop-v2/></vertical_demo>"
class BasicInteractionTest(DefaultDataTestMixin, InteractionTestBase):
class DefaultAssessmentDataTestMixin(DefaultDataTestMixin):
"""
Testing interactions with Drag and Drop XBlock against default data. If default data changes this will break.
Provides a test scenario with default options in assessment mode.
"""
def test_item_positive_feedback_on_good_move(self):
self.parameterized_item_positive_feedback_on_good_move(self.items_map)
def _get_scenario_xml(self): # pylint: disable=no-self-use
return "<vertical_demo><drag-and-drop-v2 mode='assessment'/></vertical_demo>"
def test_item_negative_feedback_on_bad_move(self):
self.parameterized_item_negative_feedback_on_bad_move(self.items_map, self.all_zones)
def test_final_feedback_and_reset(self):
self.parameterized_final_feedback_and_reset(self.items_map, self.feedback)
@ddt
class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest):
"""
Testing interactions with Drag and Drop XBlock against default data.
All interactions are tested using mouse (action_key=None) and four different keyboard action keys.
If default data changes this will break.
"""
@data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_item_positive_feedback_on_good_move(self, action_key):
self.parameterized_item_positive_feedback_on_good_move(self.items_map, action_key=action_key)
def test_keyboard_help(self):
self.interact_with_keyboard_help()
@data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_item_negative_feedback_on_bad_move(self, action_key):
self.parameterized_item_negative_feedback_on_bad_move(self.items_map, self.all_zones, action_key=action_key)
@data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_final_feedback_and_reset(self, action_key):
self.parameterized_final_feedback_and_reset(self.items_map, self.feedback, action_key=action_key)
@data(False, True)
def test_keyboard_help(self, use_keyboard):
self.interact_with_keyboard_help(use_keyboard=use_keyboard)
@ddt
class AssessmentInteractionTest(DefaultAssessmentDataTestMixin, InteractionTestBase, BaseIntegrationTest):
"""
Testing interactions with Drag and Drop XBlock against default data in assessment mode.
All interactions are tested using mouse (action_key=None) and four different keyboard action keys.
If default data changes this will break.
"""
@data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_item_no_feedback_on_good_move(self, action_key):
self.parameterized_item_positive_feedback_on_good_move(
self.items_map, action_key=action_key, assessment_mode=True
)
@data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_item_no_feedback_on_bad_move(self, action_key):
self.parameterized_item_negative_feedback_on_bad_move(
self.items_map, self.all_zones, action_key=action_key, assessment_mode=True
)
@data(None, Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_final_feedback_and_reset(self, action_key):
self.parameterized_final_feedback_and_reset(
self.items_map, self.feedback, action_key=action_key, assessment_mode=True
)
@data(False, True)
def test_keyboard_help(self, use_keyboard):
self.interact_with_keyboard_help(use_keyboard=use_keyboard)
class MultipleValidOptionsInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest):
......@@ -417,32 +525,14 @@ class EventsFiredTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegration
)
@ddt
class KeyboardInteractionTest(BasicInteractionTest, BaseIntegrationTest):
@data(Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_item_positive_feedback_on_good_move_with_keyboard(self, action_key):
self.parameterized_item_positive_feedback_on_good_move(self.items_map, action_key=action_key)
@data(Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_item_negative_feedback_on_bad_move_with_keyboard(self, action_key):
self.parameterized_item_negative_feedback_on_bad_move(self.items_map, self.all_zones, action_key=action_key)
@data(Keys.RETURN, Keys.SPACE, Keys.CONTROL+'m', Keys.COMMAND+'m')
def test_final_feedback_and_reset_with_keyboard(self, action_key):
self.parameterized_final_feedback_and_reset(self.items_map, self.feedback, action_key=action_key)
def test_keyboard_help(self):
self.interact_with_keyboard_help(use_keyboard=True)
class CustomDataInteractionTest(BasicInteractionTest, BaseIntegrationTest):
class CustomDataInteractionTest(StandardInteractionTest):
items_map = {
0: ItemDefinition(0, ['zone-1'], "Zone 1", "Yes 1", "No 1"),
1: ItemDefinition(1, ['zone-2'], "Zone 2", "Yes 2", "No 2"),
2: ItemDefinition(2, [], None, "", "No Zone for this")
}
all_zones = ['zone-1', 'zone-2']
all_zones = [('zone-1', 'Zone 1'), ('zone-2', 'Zone 2')]
feedback = {
"intro": "Some Intro Feed",
......@@ -453,14 +543,14 @@ class CustomDataInteractionTest(BasicInteractionTest, BaseIntegrationTest):
return self._get_custom_scenario_xml("data/test_data.json")
class CustomHtmlDataInteractionTest(BasicInteractionTest, BaseIntegrationTest):
class CustomHtmlDataInteractionTest(StandardInteractionTest):
items_map = {
0: ItemDefinition(0, ['zone-1'], 'Zone <i>1</i>', "Yes <b>1</b>", "No <b>1</b>"),
1: ItemDefinition(1, ['zone-2'], 'Zone <b>2</b>', "Yes <i>2</i>", "No <i>2</i>"),
2: ItemDefinition(2, [], None, "", "No Zone for <i>X</i>")
}
all_zones = ['zone-1', 'zone-2']
all_zones = [('zone-1', 'Zone 1'), ('zone-2', 'Zone 2')]
feedback = {
"intro": "Intro <i>Feed</i>",
......@@ -492,8 +582,8 @@ class MultipleBlocksDataInteraction(InteractionTestBase, BaseIntegrationTest):
}
all_zones = {
'block1': ['zone-1', 'zone-2'],
'block2': ['zone-51', 'zone-52']
'block1': [('zone-1', 'Zone 1'), ('zone-2', 'Zone 2')],
'block2': [('zone-51', 'Zone 51'), ('zone-52', 'Zone 52')]
}
feedback = {
......
{
"title": "DnDv2 XBlock with HTML instructions",
"mode": "standard",
"show_title": false,
"problem_text": "Solve this <strong>drag-and-drop</strong> problem.",
"show_problem_header": false,
......
{
"title": "Drag and Drop",
"mode": "standard",
"show_title": true,
"problem_text": "",
"show_problem_header": true,
......
{
"title": "DnDv2 XBlock with plain text instructions",
"mode": "standard",
"show_title": true,
"problem_text": "Can you solve this drag-and-drop problem?",
"show_problem_header": true,
......
......@@ -5,6 +5,8 @@ import unittest
from xblockutils.resources import ResourceLoader
from drag_and_drop_v2.drag_and_drop_v2 import DragAndDropBlock
from ..utils import make_block, TestCaseMixin
......@@ -90,6 +92,14 @@ class BaseDragAndDropAjaxFixture(TestCaseMixin):
"feedback": self.FEEDBACK[item_id]["correct"]
})
def test_do_attempt_in_assessment_mode(self):
self.block.mode = DragAndDropBlock.ASSESSMENT_MODE
item_id, zone_id = 0, self.ZONE_1
data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"}
res = self.call_handler('do_attempt', data)
# In assessment mode, the do_attempt doesn't return any data.
self.assertEqual(res, {})
def test_grading(self):
published_grades = []
......
......@@ -30,6 +30,7 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
zones = config.pop("zones")
items = config.pop("items")
self.assertEqual(config, {
"mode": DragAndDropBlock.STANDARD_MODE,
"display_zone_borders": False,
"display_zone_labels": False,
"title": "Drag and Drop",
......
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