Commit 69ee413d by Matjaz Gregoric Committed by GitHub

Merge pull request #112 from open-craft/mtyaka/a11y-interaction-issues

a11y Interaction Improvements
parents c773d9fa 85262c32
......@@ -12,7 +12,7 @@ install:
- "pip install selenium==2.53.0"
- "pip uninstall -y xblock-drag-and-drop-v2"
- "python setup.py sdist"
- "pip install dist/xblock-drag-and-drop-v2-2.0.13.tar.gz"
- "pip install dist/xblock-drag-and-drop-v2-2.0.14.tar.gz"
script:
- pep8 drag_and_drop_v2 tests --max-line-length=120
- pylint drag_and_drop_v2
......
Version 2.0.14 (2017-01-17)
---------------------------
* Various accessibility improvements (PRs #110, #111, #112)
Version 2.0.13 (2017-01-02)
---------------------------
* i18n improvements (PR #113)
Version 2.0.12 (2016-11-08)
---------------------------
......
......@@ -8,7 +8,8 @@ function DragAndDropTemplates(configuration) {
}
return (
h("div.spinner-wrapper", {key: item.value + '-spinner'}, [
h("i.fa.fa-spin.fa-spinner")
h("span.fa.fa-spin.fa-spinner", {attributes: {'aria-hidden': true}}),
h("span.sr", gettext('Submitting'))
])
);
};
......@@ -53,7 +54,6 @@ function DragAndDropTemplates(configuration) {
var itemTemplate = function(item, ctx) {
// Define properties
var descriptionClassName = "sr description";
var className = (item.class_name) ? item.class_name : "";
var zone = getZone(item.zone, ctx) || {};
if (item.has_image) {
......@@ -119,8 +119,8 @@ function DragAndDropTemplates(configuration) {
var item_description_id = configuration.url_name + '-item-' + item.value + '-description';
item_content.properties.attributes = { 'aria-describedby': item_description_id };
item_description = h(
'div',
{ key: item.value + '-description', id: item_description_id, className: descriptionClassName },
'div.sr.description',
{ key: item_description_id, id: item_description_id},
description_content
);
}
......@@ -185,7 +185,7 @@ function DragAndDropTemplates(configuration) {
var item_wrapper = 'div.item-wrapper.item-align.item-align-' + zone.align;
var is_item_in_zone = function(i) { return i.is_placed && (i.zone === zone.uid); };
var items_in_zone = $.grep(ctx.items, is_item_in_zone);
var zone_description_id = configuration.url_name + '-zone-' + zone.uid + '-description';
var zone_description_id = zone.prefixed_uid + '-description';
if (items_in_zone.length == 0) {
var zone_description = h(
'div',
......@@ -221,8 +221,15 @@ function DragAndDropTemplates(configuration) {
}
},
[
h('p', { className: className }, zone.title),
h('p', { className: 'zone-description sr' }, zone.description || gettext("droppable")),
h(
'p',
{ className: className },
[
zone.title,
h('span.sr', gettext(', dropzone'))
]
),
h('p', { className: 'zone-description sr' }, zone.description || gettext('droppable')),
h(item_wrapper, renderCollection(itemTemplate, items_in_zone, ctx)),
zone_description
]
......@@ -242,10 +249,10 @@ function DragAndDropTemplates(configuration) {
});
return (
h('section.feedback', {}, [
h('div.feedback', {attributes: {'role': 'group', 'aria-label': gettext('Feedback')}}, [
h(
"div.feedback-content",
{ attributes: { 'aria-live': 'polite' } },
{},
[
h('h3.title1', { style: { display: feedback_display } }, gettext('Feedback')),
h('div.messages', { style: { display: feedback_display } }, feedback_messages),
......@@ -286,26 +293,41 @@ function DragAndDropTemplates(configuration) {
};
var submitAnswerTemplate = function(ctx) {
var attemptsUsedId = "attempts-used-"+configuration.url_name;
var attemptsUsedDisplay = (ctx.max_attempts && ctx.max_attempts > 0) ? 'inline': 'none';
var submitButtonProperties = {
disabled: ctx.disable_submit_button || ctx.submit_spinner,
attributes: {}
};
var attemptsUsedInfo = null;
if (ctx.max_attempts && ctx.max_attempts > 0) {
var attemptsUsedId = "attempts-used-" + configuration.url_name;
submitButtonProperties.attributes["aria-describedby"] = attemptsUsedId;
var attemptsUsedTemplate = gettext("You have used {used} of {total} attempts.");
var attemptsUsedText = attemptsUsedTemplate.
replace("{used}", ctx.attempts).replace("{total}", ctx.max_attempts);
attemptsUsedInfo = h("span.attempts-used", {id: attemptsUsedId}, attemptsUsedText);
}
var submitSpinner = null;
if (ctx.submit_spinner) {
submitSpinner = h('span', [
h('span.fa.fa-spin.fa-spinner', {attributes: {'aria-hidden': true}}),
h('span.sr', gettext('Submitting'))
]);
}
return (
h("section.action-toolbar-item.submit-answer", {}, [
h("div.action-toolbar-item.submit-answer", {}, [
h(
"button.btn-brand.submit-answer-button",
{
disabled: ctx.disable_submit_button || ctx.submit_spinner,
attributes: {"aria-describedby": attemptsUsedId}},
submitButtonProperties,
[
(ctx.submit_spinner ? h("span.fa.fa-spin.fa-spinner") : null),
submitSpinner,
' ', // whitespace between spinner icon and text
gettext("Submit")
]
),
h(
"span.attempts-used#"+attemptsUsedId, {style: {display: attemptsUsedDisplay}},
gettext("You have used {used} of {total} attempts.")
.replace("{used}", ctx.attempts).replace("{total}", ctx.max_attempts)
)
attemptsUsedInfo
])
);
};
......@@ -351,7 +373,7 @@ function DragAndDropTemplates(configuration) {
go_to_beginning_button_class += ' sr';
}
return(
h("section.action-toolbar-item.sidebar-buttons", {}, [
h("div.action-toolbar-item.sidebar-buttons", {}, [
sidebarButtonTemplate(
go_to_beginning_button_class,
"fa-arrow-up",
......@@ -397,13 +419,7 @@ function DragAndDropTemplates(configuration) {
return h(
popupSelector,
{
style: {display: have_messages ? 'block' : 'none'},
attributes: {
"tabindex": "-1",
'aria-live': 'polite',
'aria-atomic': 'true',
'aria-relevant': 'additions',
}
style: {display: have_messages ? 'block' : 'none'}
},
[
h(
......@@ -491,18 +507,24 @@ function DragAndDropTemplates(configuration) {
return h('div.problem-progress', {
id: configuration.url_name + '-problem-progress',
attributes: {'role': 'status', 'aria-live': 'polite'}
attributes: {role: 'status'}
}, progress_text);
};
var mainTemplate = function(ctx) {
var main_element_properties = {attributes: {role: 'group'}};
var problemProgress = progressTemplate(ctx);
var problemTitle = null;
if (ctx.show_title) {
var problem_title_id = configuration.url_name + '-problem-title';
problemTitle = h('h3.problem-title', {
id: problem_title_id,
innerHTML: ctx.title_html,
attributes: {'aria-describedby': problemProgress.properties.id}
});
main_element_properties.attributes['arial-labelledby'] = problem_title_id;
} else {
main_element_properties.attributes['aria-label'] = gettext('Drag and Drop Problem');
}
var problemHeader = ctx.show_problem_header ? h('h4.title1', gettext('Problem')) : null;
// Render only items in the bank here, including placeholders. Placed
......@@ -523,36 +545,37 @@ function DragAndDropTemplates(configuration) {
item_bank_properties.attributes['role'] = 'button';
}
return (
h('section.themed-xblock.xblock--drag-and-drop', [
h('div.themed-xblock.xblock--drag-and-drop', main_element_properties, [
problemTitle,
problemProgress,
h('div', [forwardKeyboardHelpButtonTemplate(ctx)]),
h('section.problem', [
h('div.problem', [
problemHeader,
h('p', {innerHTML: ctx.problem_html}),
]),
h('section.drag-container', {}, [
h('div.drag-container', {}, [
h('div.item-bank', item_bank_properties, [
renderCollection(itemTemplate, items_in_bank, ctx),
renderCollection(itemPlaceholderTemplate, items_placed, ctx)
]),
h('div.target',
{},
[
h('div.target', {attributes: {'role': 'group', 'arial-label': gettext('Drop Targets')}}, [
itemFeedbackPopupTemplate(ctx),
h('div.target-img-wrapper', [
h('img.target-img', {src: ctx.target_img_src, alt: ctx.target_img_description}),
]
),
]),
renderCollection(zoneTemplate, ctx.zones, ctx)
]),
]),
h("section.actions-toolbar", {}, [
h("div.actions-toolbar", {attributes: {'role': 'group', 'aria-label': gettext('Actions')}}, [
sidebarTemplate(ctx),
(ctx.show_submit_answer ? submitAnswerTemplate(ctx) : null),
]),
keyboardHelpPopupTemplate(ctx),
feedbackTemplate(ctx),
h('div.sr.reader-feedback-area', {
attributes: {'aria-live': 'polite', 'aria-atomic': true},
innerHTML: ctx.screen_reader_messages
}),
])
);
};
......@@ -664,6 +687,9 @@ function DragAndDropBlock(runtime, element, configuration) {
// to watch for load events on any child element, since load events do not bubble.
element.addEventListener('load', webkitFix, true);
// Remove the spinner and create a blank slate for virtualDom to take over.
$root.empty();
applyState();
initDroppable();
......@@ -904,7 +930,7 @@ function DragAndDropBlock(runtime, element, configuration) {
return feedback_msgs_list.map(function(message) { return message.message; }).join('\n');
};
var updateDOM = function(state) {
var updateDOM = function() {
var new_vdom = render(state);
var patches = virtualDom.diff(__vdom, new_vdom);
root = virtualDom.patch(root, patches);
......@@ -912,6 +938,51 @@ function DragAndDropBlock(runtime, element, configuration) {
__vdom = new_vdom;
};
var sr_clear_timeout = null;
var setScreenReaderMessages = function() {
clearTimeout(sr_clear_timeout);
var pluckMessages = function(feedback_items) {
return feedback_items.map(function(item) {
return item.message;
});
};
var messages = [];
// In standard mode, it makes more sense to read the per-item feedback before overall feedback.
if (state.feedback && configuration.mode === DragAndDropBlock.STANDARD_MODE) {
messages = messages.concat(pluckMessages(state.feedback));
}
if (state.overall_feedback) {
messages = messages.concat(pluckMessages(state.overall_feedback));
}
// In assessment mode overall feedback comes first then multiple per-item feedbacks.
if (state.feedback && configuration.mode === DragAndDropBlock.ASSESSMENT_MODE) {
if (state.feedback.length > 0) {
if (!state.last_action_correct) {
messages.push(gettext("Some of your answers were not correct."));
}
messages = messages.concat(
gettext("Hints:"),
pluckMessages(state.feedback)
);
}
}
var paragraphs = messages.map(function(msg) {
return '<p>' + msg + '</p>';
});
state.screen_reader_messages = paragraphs.join('');
// Remove the text on next redraw. This will make screen readers read the message again,
// next time the user performs an action, even if next feedback message did not change from
// last attempt (for example: if user drops the same item on two wrong zones one after another,
// the negative feedback should be read out twice, not only on first drop).
sr_clear_timeout = setTimeout(function() {
state.screen_reader_messages = '';
}, 0);
};
var publishEvent = function(data) {
$.ajax({
type: 'POST',
......@@ -981,16 +1052,18 @@ function DragAndDropBlock(runtime, element, configuration) {
return false;
};
var placeItem = function($zone, $item) {
var item_id;
if ($item !== undefined) {
item_id = $item.data('value');
} else {
item_id = $selectedItem.data('value');
}
var placeGrabbedItem = function($zone) {
var zone = String($zone.data('uid'));
var zone_align = $zone.data('zone_align');
var items = configuration.items;
var item_id;
for (var i = 0; i < items.length; i++) {
if (items[i].grabbed) {
item_id = items[i].id;
break;
}
}
var items_in_zone_count = countItemsInZone(zone, [item_id.toString()]);
if (configuration.max_items_per_zone && configuration.max_items_per_zone <= items_in_zone_count) {
......@@ -1036,18 +1109,17 @@ function DragAndDropBlock(runtime, element, configuration) {
} else if (isCancelKey(evt)) {
evt.preventDefault();
state.keyboard_placement_mode = false;
releaseItem($selectedItem);
releaseGrabbedItems();
} else if (isActionKey(evt)) {
evt.preventDefault();
evt.stopPropagation();
state.keyboard_placement_mode = false;
releaseItem($selectedItem);
if ($zone.is('.item-bank')) {
delete state.items[$selectedItem.data('value')];
applyState();
} else {
placeItem($zone);
placeGrabbedItem($zone);
}
releaseGrabbedItems();
}
} else if (isTabKey(evt) && !evt.shiftKey) {
// If the user just dropped an item to this zone, next TAB keypress
......@@ -1074,8 +1146,7 @@ function DragAndDropBlock(runtime, element, configuration) {
tolerance: 'pointer',
drop: function(evt, ui) {
var $zone = $(this);
var $item = ui.helper;
placeItem($zone, $item);
placeGrabbedItem($zone);
}
});
......@@ -1087,7 +1158,7 @@ function DragAndDropBlock(runtime, element, configuration) {
drop: function(evt, ui) {
var $item = ui.helper;
var item_id = $item.data('value');
releaseItem($item);
releaseGrabbedItems();
delete state.items[item_id];
applyState();
}
......@@ -1140,7 +1211,7 @@ function DragAndDropBlock(runtime, element, configuration) {
stop: function(evt, ui) {
// Revert to original position.
$item.css($item.data('initial-position'));
releaseItem($(this));
releaseGrabbedItems();
}
});
} catch (e) {
......@@ -1152,31 +1223,27 @@ function DragAndDropBlock(runtime, element, configuration) {
var grabItem = function($item, interaction_type) {
var item_id = $item.data('value');
setGrabbedState(item_id, true, interaction_type);
closePopup(false);
// applyState(true) skips destroying and initializing draggable
applyState(true);
};
var releaseItem = function($item) {
var item_id = $item.data('value');
setGrabbedState(item_id, false);
// applyState(true) skips destroying and initializing draggable
applyState(true);
};
var setGrabbedState = function(item_id, grabbed, interaction_type) {
configuration.items.forEach(function(item) {
if (item.id === item_id) {
if (grabbed) {
item.grabbed = true;
item.grabbed_with = interaction_type;
} else {
item.grabbed = false;
delete item.grabbed_with;
}
}
});
closePopup(false);
// applyState(true) skips destroying and initializing draggable
applyState(true);
};
var releaseGrabbedItems = function() {
configuration.items.forEach(function(item) {
item.grabbed = false;
delete item.grabbed_with;
});
// applyState(true) skips destroying and initializing draggable
applyState(true);
};
var destroyDraggable = function() {
......@@ -1220,6 +1287,7 @@ function DragAndDropBlock(runtime, element, configuration) {
state.finished = true;
state.overall_feedback = data.overall_feedback;
}
setScreenReaderMessages();
}
applyState();
if (state.feedback && state.feedback.length > 0) {
......@@ -1324,6 +1392,7 @@ function DragAndDropBlock(runtime, element, configuration) {
} else {
state.finished = true;
}
setScreenReaderMessages();
}).always(function() {
state.submit_spinner = false;
applyState();
......@@ -1451,7 +1520,8 @@ function DragAndDropBlock(runtime, element, configuration) {
showing_answer: state.showing_answer,
show_answer_spinner: state.show_answer_spinner,
disable_go_to_beginning_button: !canGoToBeginning(),
show_go_to_beginning_button: state.go_to_beginning_button_visible
show_go_to_beginning_button: state.go_to_beginning_button_visible,
screen_reader_messages: (state.screen_reader_messages || '')
};
return renderView(context);
......
{% load i18n %}
<section class="themed-xblock xblock--drag-and-drop">
<i class="fa fa-spin fa-spinner initial-load-spinner"></i>{% trans "Loading drag and drop problem." %}
</section>
<div class="themed-xblock xblock--drag-and-drop">
<span class="fa fa-spin fa-spinner initial-load-spinner" aria-hidden="true"></span>
{% trans "Loading drag and drop problem." %}
</div>
......@@ -4,10 +4,12 @@
<div class="xblock--drag-and-drop--editor editor-with-buttons">
{{ js_templates|safe }}
<section class="drag-builder">
<div class="tab feedback-tab">
<section class="tab-content">
<div class="drag-builder">
<section class="tab feedback-tab">
<header class="tab-header">
<h3>{% trans "Basic Settings" %}</h3>
</header>
<div class="tab-content">
<form class="feedback-form">
<label class="h4">
<span>{% trans fields.display_name.display_name %}</span>
......@@ -80,14 +82,14 @@
<textarea class="final-feedback">{{ self.data.feedback.finish }}</textarea>
</label>
</form>
</section>
</div>
</section>
<div class="tab zones-tab hidden">
<section class="tab zones-tab hidden">
<header class="tab-header">
<h3>{% trans "Zones" %}</h3>
</header>
<section class="tab-content">
<div class="tab-content">
<form class="target-image-form">
<label class="h4" for="background-url-{{id_suffix}}">
<span>{% trans "Background URL" %}</span>
......@@ -114,8 +116,8 @@
{% endblocktrans %}
</div>
</form>
</section>
<section class="tab-content">
</div>
<div class="tab-content">
<form class="display-labels-form">
<h4>{% trans "Zone labels" %}</h4>
<label class="checkbox-label">
......@@ -130,8 +132,8 @@
<span>{% trans "Display zone borders on the image" %}</span>
</label>
</form>
</section>
<section class="tab-content">
</div>
<div class="tab-content">
<h4>{% trans "Zone definitions" %}</h4>
<div class="zone-editor">
<div class="controls">
......@@ -147,14 +149,14 @@
</div>
</div>
</div>
</section>
</div>
</section>
<div class="tab items-tab hidden">
<section class="tab items-tab hidden">
<header class="tab-header">
<h3>{% trans "Items" %}</h3>
</header>
<section class="tab-content">
<div class="tab-content">
<form class="item-styles-form">
<label class="h4">
<span>{% trans fields.item_background_color.display_name %}</span>
......@@ -184,19 +186,19 @@
{% trans fields.max_items_per_zone.help %}
</div>
</form>
</section>
<section class="tab-content">
</div>
<div class="tab-content">
<h4>{% trans "Item definitions" %}</h4>
<form class="items-form"></form>
<button class="btn add-item add-element">
<span class="icon add" aria-hidden="true"></span>
{% trans "Add an item" %}
</button>
</section>
</div>
</section>
</div>
<div class="xblock-actions">
<ul class="action-buttons">
<li class="action-item">
......
......@@ -595,6 +595,31 @@ msgid_plural "{possible} points possible (ungraded)"
msgstr[0] ""
msgstr[1] ""
#: drag_and_drop_v2/public/js/drag_and_drop.js:374
#: drag_and_drop_v2/public/js/drag_and_drop.js:839
msgid "Hints:"
msgstr ""
#: drag_and_drop_v2/public/js/drag_and_drop.js:224
msgid ", dropzone"
msgstr ""
#: drag_and_drop_v2/public/js/drag_and_drop.js:479
msgid "Drag and Drop Problem"
msgstr ""
#: drag_and_drop_v2/public/js/drag_and_drop.js:509
msgid "Drop Targets"
msgstr ""
#: drag_and_drop_v2/public/js/drag_and_drop.js:517
msgid "Actions"
msgstr ""
#: drag_and_drop_v2/public/js/drag_and_drop.js:12
msgid "Submitting"
msgstr ""
#: utils.py:44
msgid "Your highest score is {score}"
msgstr ""
......
......@@ -694,6 +694,31 @@ msgstr[1] "{possible} pöïnts pössïßlé (üngrädéd) Ⱡ'σяєм ιρѕυ
msgid "Close"
msgstr "Çlösé Ⱡ'σяєм ιρѕ#"
#: drag_and_drop_v2/public/js/drag_and_drop.js:374
#: drag_and_drop_v2/public/js/drag_and_drop.js:839
msgid "Hints:"
msgstr "Hïnts: Ⱡ'σяєм ιρѕυ#"
#: drag_and_drop_v2/public/js/drag_and_drop.js:224
msgid ", dropzone"
msgstr ", dröpzöné Ⱡ'σяєм ιρѕυм ∂σłσ#"
#: drag_and_drop_v2/public/js/drag_and_drop.js:479
msgid "Drag and Drop Problem"
msgstr "Dräg änd Dröp Prößlém Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #"
#: drag_and_drop_v2/public/js/drag_and_drop.js:509
msgid "Drop Targets"
msgstr "Dröp Tärgéts Ⱡ'σяєм ιρѕυм ∂σłσя ѕ#"
#: drag_and_drop_v2/public/js/drag_and_drop.js:517
msgid "Actions"
msgstr "Àçtïöns Ⱡ'σяєм ιρѕυм #"
#: drag_and_drop_v2/public/js/drag_and_drop.js:12
msgid "Submitting"
msgstr "Süßmïttïng Ⱡ'σяєм ιρѕυм ∂σłσ#"
#: utils.py:44
msgid "Your highest score is {score}"
msgstr "Ýöür hïghést sçöré ïs {score} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#"
......
......@@ -23,7 +23,7 @@ def package_data(pkg, root_list):
setup(
name='xblock-drag-and-drop-v2',
version='2.0.13',
version='2.0.14',
description='XBlock - Drag-and-Drop v2',
packages=['drag_and_drop_v2'],
install_requires=[
......
......@@ -48,7 +48,7 @@ ItemDefinition = namedtuple( # pylint: disable=invalid-name
class BaseIntegrationTest(SeleniumBaseTest):
default_css_selector = 'section.themed-xblock.xblock--drag-and-drop'
default_css_selector = '.themed-xblock.xblock--drag-and-drop'
module_name = __name__
_additional_escapes = {
......@@ -239,22 +239,32 @@ class DefaultDataTestMixin(object):
class InteractionTestBase(object):
POPUP_ERROR_CLASS = "popup-incorrect"
@classmethod
def _get_items_with_zone(cls, items_map):
def setUp(self):
super(InteractionTestBase, self).setUp()
scenario_xml = self._get_scenario_xml()
self._add_scenario(self.PAGE_ID, self.PAGE_TITLE, scenario_xml)
self._page = self.go_to_page(self.PAGE_TITLE)
# Resize window so that the entire drag container is visible.
# Selenium has issues when dragging to an area that is off screen.
self.browser.set_window_size(1024, 1024)
@staticmethod
def _get_items_with_zone(items_map):
return {
item_key: definition for item_key, definition in items_map.items()
if definition.zone_ids != []
}
@classmethod
def _get_items_without_zone(cls, items_map):
@staticmethod
def _get_items_without_zone(items_map):
return {
item_key: definition for item_key, definition in items_map.items()
if definition.zone_ids == []
}
@classmethod
def _get_items_by_zone(cls, items_map):
@staticmethod
def _get_items_by_zone(items_map):
zone_ids = set([definition.zone_ids[0] for _, definition in items_map.items() if definition.zone_ids])
return {
zone_id: {item_key: definition for item_key, definition in items_map.items()
......@@ -262,15 +272,17 @@ class InteractionTestBase(object):
for zone_id in zone_ids
}
def setUp(self):
super(InteractionTestBase, self).setUp()
scenario_xml = self._get_scenario_xml()
self._add_scenario(self.PAGE_ID, self.PAGE_TITLE, scenario_xml)
self._page = self.go_to_page(self.PAGE_TITLE)
# Resize window so that the entire drag container is visible.
# Selenium has issues when dragging to an area that is off screen.
self.browser.set_window_size(1024, 800)
@staticmethod
def _get_incorrect_zone_for_item(item, zones):
"""Returns the first zone that is not correct for this item."""
zone_id = None
zone_title = None
for z_id, z_title in zones:
if z_id not in item.zone_ids:
zone_id = z_id
zone_title = z_title
break
return [zone_id, zone_title]
def _get_item_by_value(self, item_value):
return self._page.find_elements_by_xpath(".//div[@data-value='{item_id}']".format(item_id=item_value))[0]
......@@ -312,7 +324,7 @@ class InteractionTestBase(object):
both the HTML attribute and the DOM property are set to false.
We work around that selenium bug by using JavaScript to get the correct value of 'draggable'.
"""
script = "return $('div.option[data-value={}]').prop('draggable')".format(item_value)
script = "return $('.option[data-value={}]').prop('draggable')".format(item_value)
return self.browser.execute_script(script)
def assertDraggable(self, item_value):
......@@ -370,7 +382,7 @@ class InteractionTestBase(object):
item.send_keys("")
item.send_keys(action_key)
# Focus is on first *zone* now
self.assert_grabbed_item(item)
self.assert_item_grabbed(item)
# Get desired zone and figure out how many times we have to press Tab to focus the zone.
if zone_id is None: # moving back to the bank
zone = self._get_item_bank()
......@@ -387,9 +399,12 @@ class InteractionTestBase(object):
ActionChains(self.browser).send_keys(Keys.TAB).perform()
zone.send_keys(action_key)
def assert_grabbed_item(self, item):
def assert_item_grabbed(self, item):
self.assertEqual(item.get_attribute('aria-grabbed'), 'true')
def assert_item_not_grabbed(self, item):
self.assertEqual(item.get_attribute('aria-grabbed'), 'false')
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)
......@@ -474,3 +489,10 @@ class InteractionTestBase(object):
def assert_button_enabled(self, submit_button, enabled=True):
self.assertEqual(submit_button.is_enabled(), enabled)
def assert_reader_feedback_messages(self, expected_message_lines):
expected_paragraphs = ['<p>{}</p>'.format(l) for l in expected_message_lines]
expected_html = ''.join(expected_paragraphs)
feedback_area = self._page.find_element_by_css_selector('.reader-feedback-area')
actual_html = feedback_area.get_attribute('innerHTML')
self.assertEqual(actual_html, expected_html)
......@@ -76,7 +76,7 @@ class EventsFiredTest(DefaultDataTestMixin, ParameterizedTestsMixin, BaseEventsT
@data(*enumerate(scenarios)) # pylint: disable=star-args
@unpack
def test_event(self, index, event):
self.parameterized_item_positive_feedback_on_good_move(self.items_map)
self.parameterized_item_positive_feedback_on_good_move_standard(self.items_map)
dummy, name, published_data = self.publish.call_args_list[index][0]
self.assertEqual(name, event['name'])
self.assertEqual(published_data, event['data'])
......
......@@ -50,35 +50,92 @@ class ParameterizedTestsMixin(object):
ActionChains(self.browser).send_keys(Keys.TAB).perform()
self.assertFocused(go_to_beginning_button)
def parameterized_item_positive_feedback_on_good_move(
self, items_map, scroll_down=100, action_key=None, assessment_mode=False
def parameterized_item_positive_feedback_on_good_move_standard(
self, items_map, scroll_down=100, action_key=None, feedback=None
):
if feedback is None:
feedback = self.feedback
popup = self._get_popup()
feedback_popup_content = self._get_popup_content()
# Scroll drop zones into view to make sure Selenium can successfully drop items
self.scroll_down(pixels=scroll_down)
for definition in self._get_items_with_zone(items_map).values():
items_with_zones = self._get_items_with_zone(items_map).values()
for i, definition in enumerate(items_with_zones):
self.place_item(definition.item_id, definition.zone_ids[0], action_key)
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)
self.assert_placed_item(definition.item_id, definition.zone_title, assessment_mode=False)
feedback_popup_html = feedback_popup_content.get_attribute('innerHTML')
self.assertEqual(feedback_popup_html, "<p>{}</p>".format(definition.feedback_positive))
self.assert_popup_correct(popup)
self.assertTrue(popup.is_displayed())
expected_sr_texts = [definition.feedback_positive]
if i == len(items_with_zones) - 1:
# We just dropped the last item, so the problem is done and we should see the final feedback.
overall_feedback = feedback['final']
else:
overall_feedback = feedback['intro']
expected_sr_texts.append(overall_feedback)
self.assert_reader_feedback_messages(expected_sr_texts)
self._test_popup_focus_and_close(popup, action_key)
def parameterized_item_positive_feedback_on_good_move_assessment(
self, items_map, scroll_down=100, action_key=None, feedback=None
):
if feedback is None:
feedback = self.feedback
popup = self._get_popup()
feedback_popup_content = self._get_popup_content()
# Scroll drop zones into view to make sure Selenium can successfully drop items
self.scroll_down(pixels=scroll_down)
items_with_zones = self._get_items_with_zone(items_map).values()
for definition in items_with_zones:
self.place_item(definition.item_id, definition.zone_ids[0], action_key)
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=True)
feedback_popup_html = feedback_popup_content.get_attribute('innerHTML')
if assessment_mode:
self.assertEqual(feedback_popup_html, '')
self.assertFalse(popup.is_displayed())
self.assert_reader_feedback_messages([])
if action_key:
# Next TAB keypress should move focus to "Go to Beginning button"
self._test_next_tab_goes_to_go_to_beginning_button()
else:
self.assertEqual(feedback_popup_html, "<p>{}</p>".format(definition.feedback_positive))
self.assert_popup_correct(popup)
def parameterized_item_negative_feedback_on_bad_move_standard(
self, items_map, all_zones, scroll_down=100, action_key=None, feedback=None
):
if feedback is None:
feedback = self.feedback
popup = self._get_popup()
feedback_popup_content = self._get_popup_content()
# Scroll drop zones into view to make sure Selenium can successfully drop items
self.scroll_down(pixels=scroll_down)
for definition in items_map.values():
zone_id, _ = self._get_incorrect_zone_for_item(definition, all_zones)
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)
self.wait_until_html_in(definition.feedback_negative, feedback_popup_content)
self.assert_popup_incorrect(popup)
self.assertTrue(popup.is_displayed())
self.assert_reverted_item(definition.item_id)
expected_sr_texts = [definition.feedback_negative, feedback['intro']]
self.assert_reader_feedback_messages(expected_sr_texts)
self._test_popup_focus_and_close(popup, action_key)
def parameterized_item_negative_feedback_on_bad_move(
self, items_map, all_zones, scroll_down=100, action_key=None, assessment_mode=False
def parameterized_item_negative_feedback_on_bad_move_assessment(
self, items_map, all_zones, scroll_down=100, action_key=None, feedback=None
):
if feedback is None:
feedback = self.feedback
popup = self._get_popup()
feedback_popup_content = self._get_popup_content()
......@@ -86,30 +143,17 @@ class ParameterizedTestsMixin(object):
self.scroll_down(pixels=scroll_down)
for definition in items_map.values():
# 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
zone_id, zone_title = self._get_incorrect_zone_for_item(definition, all_zones)
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)
self.assert_reader_feedback_messages([])
if action_key:
self._test_next_tab_goes_to_go_to_beginning_button()
else:
self.wait_until_html_in(definition.feedback_negative, feedback_popup_content)
self.assert_popup_incorrect(popup)
self.assertTrue(popup.is_displayed())
self.assert_reverted_item(definition.item_id)
self._test_popup_focus_and_close(popup, action_key)
def parameterized_move_items_between_zones(self, items_map, all_zones, scroll_down=100, action_key=None):
# Scroll drop zones into view to make sure Selenium can successfully drop items
......@@ -247,11 +291,13 @@ class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, Paramet
"""
@data(*ITEM_DRAG_KEYBOARD_KEYS)
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)
self.parameterized_item_positive_feedback_on_good_move_standard(self.items_map, action_key=action_key)
@data(*ITEM_DRAG_KEYBOARD_KEYS)
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)
self.parameterized_item_negative_feedback_on_bad_move_standard(
self.items_map, self.all_zones, action_key=action_key
)
@data(*ITEM_DRAG_KEYBOARD_KEYS)
def test_cannot_move_items_between_zones(self, action_key):
......@@ -332,6 +378,23 @@ class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, Paramet
# After placing all items, we get the full score.
self.assertEqual(progress.text, '1/1 point (ungraded)')
@data(*ITEM_DRAG_KEYBOARD_KEYS)
def test_cannot_select_multiple_items(self, action_key):
if action_key:
all_item_ids = self.items_map.keys()
# Go through all items and select them all using the keyboard action key.
for item_id in all_item_ids:
item = self._get_item_by_value(item_id)
item.send_keys('')
item.send_keys(action_key)
# Item should be grabbed.
self.assert_item_grabbed(item)
# Other items should NOT be grabbed.
for other_item_id in all_item_ids:
if other_item_id != item_id:
other_item = self._get_item_by_value(other_item_id)
self.assert_item_not_grabbed(other_item)
class MultipleValidOptionsInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest):
......@@ -477,16 +540,22 @@ class MultipleBlocksDataInteraction(ParameterizedTestsMixin, InteractionTestBase
def test_item_positive_feedback_on_good_move(self):
self._switch_to_block(0)
self.parameterized_item_positive_feedback_on_good_move(self.item_maps['block1'])
self.parameterized_item_positive_feedback_on_good_move_standard(
self.item_maps['block1'], feedback=self.feedback['block1']
)
self._switch_to_block(1)
self.parameterized_item_positive_feedback_on_good_move(self.item_maps['block2'], scroll_down=1000)
self.parameterized_item_positive_feedback_on_good_move_standard(
self.item_maps['block2'], feedback=self.feedback['block2'], scroll_down=1000
)
def test_item_negative_feedback_on_bad_move(self):
self._switch_to_block(0)
self.parameterized_item_negative_feedback_on_bad_move(self.item_maps['block1'], self.all_zones['block1'])
self.parameterized_item_negative_feedback_on_bad_move_standard(
self.item_maps['block1'], self.all_zones['block1'], feedback=self.feedback['block1']
)
self._switch_to_block(1)
self.parameterized_item_negative_feedback_on_bad_move(
self.item_maps['block2'], self.all_zones['block2'], scroll_down=1000
self.parameterized_item_negative_feedback_on_bad_move_standard(
self.item_maps['block2'], self.all_zones['block2'], feedback=self.feedback['block2'], scroll_down=1000
)
def test_final_feedback_and_reset(self):
......
......@@ -81,14 +81,12 @@ class AssessmentInteractionTest(
"""
@data(*ITEM_DRAG_KEYBOARD_KEYS)
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
)
self.parameterized_item_positive_feedback_on_good_move_assessment(self.items_map, action_key=action_key)
@data(*ITEM_DRAG_KEYBOARD_KEYS)
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
self.parameterized_item_negative_feedback_on_bad_move_assessment(
self.items_map, self.all_zones, action_key=action_key
)
@data(*ITEM_DRAG_KEYBOARD_KEYS)
......@@ -250,6 +248,18 @@ class AssessmentInteractionTest(
"""
Test updating overall feedback after submitting solution in assessment mode
"""
def check_feedback(overall_feedback_lines, per_item_feedback_lines=None):
# Check that the feedback is correctly displayed in the overall feedback area.
expected_overall_feedback = "\n".join(["FEEDBACK"] + overall_feedback_lines)
self.assertEqual(self._get_feedback().text, expected_overall_feedback)
# Check that the SR.readText function was passed correct feedback messages.
sr_feedback_lines = overall_feedback_lines
if per_item_feedback_lines:
sr_feedback_lines += ["Some of your answers were not correct.", "Hints:"]
sr_feedback_lines += per_item_feedback_lines
self.assert_reader_feedback_messages(sr_feedback_lines)
# used keyboard mode to avoid bug/feature with selenium "selecting" everything instead of dragging an element
self.place_item(0, TOP_ZONE_ID, Keys.RETURN)
......@@ -261,29 +271,25 @@ class AssessmentInteractionTest(
expected_grade = 2.0 / 5.0
feedback_lines = [
"FEEDBACK",
FeedbackMessages.correctly_placed(1),
FeedbackMessages.not_placed(3),
START_FEEDBACK,
FeedbackMessages.GRADE_FEEDBACK_TPL.format(score=expected_grade)
]
expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback)
check_feedback(feedback_lines)
# Place the item into incorrect zone. The score does not change.
self.place_item(1, BOTTOM_ZONE_ID, Keys.RETURN)
self.click_submit()
feedback_lines = [
"FEEDBACK",
FeedbackMessages.correctly_placed(1),
FeedbackMessages.misplaced_returned(1),
FeedbackMessages.not_placed(2),
START_FEEDBACK,
FeedbackMessages.GRADE_FEEDBACK_TPL.format(score=expected_grade)
]
expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback)
check_feedback(feedback_lines, ["No, this item does not belong here. Try again."])
# reach final attempt
for _ in xrange(self.MAX_ATTEMPTS-3):
......@@ -299,13 +305,11 @@ class AssessmentInteractionTest(
expected_grade = 1.0
feedback_lines = [
"FEEDBACK",
FeedbackMessages.correctly_placed(4),
FINISH_FEEDBACK,
FeedbackMessages.FINAL_ATTEMPT_TPL.format(score=expected_grade)
]
expected_feedback = "\n".join(feedback_lines)
self.assertEqual(self._get_feedback().text, expected_feedback)
check_feedback(feedback_lines)
def test_per_item_feedback_multiple_misplaced(self):
self.place_item(0, MIDDLE_ZONE_ID, Keys.RETURN)
......
......@@ -194,7 +194,7 @@ class TestDragAndDropRender(BaseIntegrationTest):
'#-Zone_{}'.format(zone_number), **zone_box_percentages
)
zone_name = zone.find_element_by_css_selector('p.zone-name')
self.assertEqual(zone_name.text, 'Zone {}'.format(zone_number))
self.assertEqual(zone_name.text, 'Zone {}\n, dropzone'.format(zone_number))
zone_description = zone.find_element_by_css_selector('p.zone-description')
self.assertEqual(zone_description.text, 'This describes zone {}'.format(zone_number))
# Zone description should only be visible to screen readers:
......@@ -204,12 +204,10 @@ class TestDragAndDropRender(BaseIntegrationTest):
self.load_scenario()
popup = self._get_popup()
popup_wrapper = self._get_popup_wrapper()
popup_content = self._get_popup_content()
self.assertFalse(popup.is_displayed())
self.assertIn('popup', popup.get_attribute('class'))
self.assertEqual(popup_content.text, "")
self.assertEqual(popup_wrapper.get_attribute('aria-live'), 'polite')
@data(None, Keys.RETURN)
def test_go_to_beginning_button(self, action_key):
......@@ -253,9 +251,7 @@ class TestDragAndDropRender(BaseIntegrationTest):
def test_feedback(self):
self.load_scenario()
feedback = self._get_feedback()
feedback_message = self._get_feedback_message()
self.assertEqual(feedback.get_attribute('aria-live'), 'polite')
self.assertEqual(feedback_message.text, START_FEEDBACK)
def test_background_image(self):
......
......@@ -26,10 +26,10 @@ class TestDragAndDropTitleAndProblem(BaseIntegrationTest):
self.addCleanup(scenarios.remove_scenario, const_page_id)
page = self.go_to_page(const_page_name)
is_problem_header_visible = len(page.find_elements_by_css_selector('section.problem > h4')) > 0
is_problem_header_visible = len(page.find_elements_by_css_selector('.problem > h4')) > 0
self.assertEqual(is_problem_header_visible, show_problem_header)
problem = page.find_element_by_css_selector('section.problem > p')
problem = page.find_element_by_css_selector('.problem > p')
self.assertEqual(self.get_element_html(problem), problem_text)
@unpack
......
......@@ -45,7 +45,7 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
def test_template_contents(self):
context = {}
student_fragment = self.block.runtime.render(self.block, 'student_view', context)
self.assertIn('<section class="themed-xblock xblock--drag-and-drop">', student_fragment.content)
self.assertIn('<div class="themed-xblock xblock--drag-and-drop">', student_fragment.content)
self.assertIn('Loading drag and drop problem.', student_fragment.content)
def test_get_configuration(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