Commit 30173c47 by Matjaz Gregoric Committed by GitHub

Merge pull request #82 from open-craft/mtyaka/assessment-item-placement

Keep items in dropped position in assessment mode.
parents cd689891 97c5c4d8
......@@ -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,13 +338,19 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
'is_correct': is_correct,
})
return {
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=''):
self.item_state = {}
......
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,10 +759,13 @@ function DragAndDropBlock(runtime, element, configuration) {
$.post(url, JSON.stringify(data), 'json')
.done(function(data){
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;
state.items[item_id].submitting_location = false;
} else {
delete state.items[item_id];
}
......@@ -759,6 +774,7 @@ function DragAndDropBlock(runtime, element, configuration) {
state.finished = true;
state.overall_feedback = data.overall_feedback;
}
}
applyState();
})
.fail(function (data) {
......@@ -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,6 +145,9 @@ 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)
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):
......@@ -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')
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):
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.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.assert_placed_item(definition.item_id, definition.zone_title)
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)
# 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):
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)
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