Commit 8be8937b by Tim Krones

Merge pull request #44 from open-craft/keyboard-a11y

Make DnDv2 keyboard-accessible
parents 9a0f8652 dc0a76ba
......@@ -14,8 +14,9 @@ TOP_ZONE_DESCRIPTION = _("Use this zone to associate an item with the top layer
MIDDLE_ZONE_DESCRIPTION = _("Use this zone to associate an item with the middle layer of the triangle.")
BOTTOM_ZONE_DESCRIPTION = _("Use this zone to associate an item with the bottom layer of the triangle.")
ITEM_INCORRECT_FEEDBACK = _("No, this item does not belong here. Try again.")
ITEM_CORRECT_FEEDBACK = _("Correct! This one belongs to {zone}.")
ITEM_INCORRECT_FEEDBACK = _("No, this item does not belong here. Try again.")
ITEM_NO_ZONE_FEEDBACK = _("You silly, there are no zones for this one.")
START_FEEDBACK = _("Drag the items onto the image above.")
FINISH_FEEDBACK = _("Good work! You have completed this drag and drop exercise.")
......@@ -88,7 +89,7 @@ DEFAULT_DATA = {
{
"displayName": _("I don't belong anywhere"),
"feedback": {
"incorrect": _("You silly, there are no zones for this one."),
"incorrect": ITEM_NO_ZONE_FEEDBACK,
"correct": ""
},
"zone": "none",
......
......@@ -220,7 +220,7 @@ class DragAndDropBlock(XBlock):
@XBlock.json_handler
def do_attempt(self, attempt, suffix=''):
item = next(i for i in self.data['items'] if i['id'] == attempt['val'])
item = self._get_item_definition(attempt['val'])
state = None
feedback = item['feedback']['incorrect']
......@@ -228,7 +228,7 @@ class DragAndDropBlock(XBlock):
is_correct = False
is_correct_location = False
if 'input' in attempt:
if 'input' in attempt: # Student submitted numerical value for item
state = self._get_item_state().get(str(item['id']))
if state:
state['input'] = attempt['input']
......@@ -238,7 +238,7 @@ class DragAndDropBlock(XBlock):
feedback = item['feedback']['correct']
else:
is_correct = False
elif item['zone'] == attempt['zone']:
elif item['zone'] == attempt['zone']: # Student placed item in zone
is_correct_location = True
if 'inputOptions' in item:
# Input value will have to be provided for the item.
......@@ -250,6 +250,7 @@ class DragAndDropBlock(XBlock):
is_correct = True
feedback = item['feedback']['correct']
state = {
'zone': attempt['zone'],
'x_percent': attempt['x_percent'],
'y_percent': attempt['y_percent'],
}
......@@ -349,8 +350,13 @@ class DragAndDropBlock(XBlock):
""" Get all user-specific data, and any applicable feedback """
item_state = self._get_item_state()
for item_id, item in item_state.iteritems():
definition = next(i for i in self.data['items'] if str(i['id']) == item_id)
definition = self._get_item_definition(int(item_id))
item['correct_input'] = self._is_correct_input(definition, item.get('input'))
# If information about zone is missing
# (because exercise was completed before a11y enhancements were implemented),
# deduce zone in which item is placed from definition:
if item.get('zone') is None:
item['zone'] = definition.get('zone', 'unknown')
is_finished = self._is_finished()
return {
......@@ -374,6 +380,12 @@ class DragAndDropBlock(XBlock):
return state
def _get_item_definition(self, item_id):
"""
Returns definition (settings) for item identified by `item_id`.
"""
return next(i for i in self.data['items'] if i['id'] == item_id)
def _get_grade(self):
"""
Returns the student's grade for this block.
......
......@@ -116,7 +116,8 @@
/* Focused option */
.xblock--drag-and-drop .drag-container .item-bank .option:focus,
.xblock--drag-and-drop .drag-container .item-bank .option:hover {
.xblock--drag-and-drop .drag-container .item-bank .option:hover,
.xblock--drag-and-drop .drag-container .item-bank .option[aria-grabbed='true'] {
outline-width: 2px;
outline-style: solid;
outline-offset: -4px;
......@@ -181,7 +182,6 @@
opacity: 0.5;
}
/*** Drop Target ***/
.xblock--drag-and-drop .target {
display: table;
......@@ -231,7 +231,6 @@
/* W3C */
box-pack:center;
box-align:center;
}
/* Focused zone */
......@@ -263,7 +262,6 @@
/*** FEEDBACK ***/
.xblock--drag-and-drop .feedback {
margin-top: 20px;
border-top: solid 1px #bdbdbd;
}
......@@ -301,10 +299,68 @@
font-size: 18pt;
}
.xblock--drag-and-drop .keyboard-help {
margin-top: 3px;
margin-bottom: 6px;
}
.xblock--drag-and-drop .keyboard-help-dialog {
position: fixed;
left: 50%;
top: 50%;
width: 1px;
height: 1px;
z-index: 1500;
}
.xblock--drag-and-drop .modal-window-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #000;
opacity: 0.5;
z-index: 1500;
}
.xblock--drag-and-drop .modal-window {
display: none;
position: absolute;
width: 600px;
max-width: 90vw;
height: auto;
transform: translate(-50%, -50%);
box-sizing: border-box;
box-shadow: 0 0 7px rgba(0, 0, 0, 0.4);
border-radius: 4px;
padding: 7px;
background-color: #e5e5e5;
text-align: left;
direction: ltr;
z-index: 1500;
}
.xblock--drag-and-drop .modal-content {
border-radius: 5px;
background: white;
margin-bottom: 5px;
padding: 5px;
}
.xblock--drag-and-drop .modal-content li {
margin-left: 2%;
}
.xblock--drag-and-drop .keyboard-help-button,
.xblock--drag-and-drop .reset-button {
cursor: pointer;
float: right;
color: #2d74b3;
}
.xblock--drag-and-drop .reset-button {
float: right;
margin-top: 3px;
}
......
......@@ -12,6 +12,18 @@ function DragAndDropBlock(runtime, element, configuration) {
var state = undefined;
var __vdom = virtualDom.h(); // blank virtual DOM
// Keyboard accessibility
var ESC = 27;
var RET = 13;
var SPC = 32;
var TAB = 9;
var M = 77;
var QUESTION_MARK = 63;
var placementMode = false;
var $selectedItem;
var $focusedElement;
var init = function() {
// Load the current user state, and load the image, then render the block.
// We load the user state via AJAX rather than passing it in statically (like we do with
......@@ -23,24 +35,96 @@ function DragAndDropBlock(runtime, element, configuration) {
$.ajax(runtime.handlerUrl(element, 'get_user_state'), {dataType: 'json'}),
loadBackgroundImage()
).done(function(stateResult, bgImg){
// Render exercise
configuration.zones.forEach(function (zone) {
computeZoneDimension(zone, bgImg.width, bgImg.height);
});
state = stateResult[0]; // stateResult is an array of [data, statusText, jqXHR]
migrateState(bgImg.width, bgImg.height);
applyState();
// Set up event handlers
initDroppable();
$(document).on('mousedown touchstart', closePopup);
$(document).on('keydown mousedown touchstart', closePopup);
$(document).on('keypress', function(evt) {
runOnKey(evt, QUESTION_MARK, showKeyboardHelp);
});
$element.on('click', '.keyboard-help-button', showKeyboardHelp);
$element.on('keydown', '.keyboard-help-button', function(evt) {
runOnKey(evt, RET, showKeyboardHelp);
});
$element.on('click', '.reset-button', resetExercise);
$element.on('keydown', '.reset-button', function(evt) {
runOnKey(evt, RET, resetExercise);
});
$element.on('click', '.submit-input', submitInput);
// Indicate that exercise is done loading
publishEvent({event_type: 'xblock.drag-and-drop-v2.loaded'});
}).fail(function() {
$root.text(gettext("An error occurred. Unable to load drag and drop exercise."));
});
};
var runOnKey = function(evt, key, handler) {
if (evt.which === key) {
handler(evt);
}
};
var keyboardEventDispatcher = function(evt) {
if (evt.which === TAB) {
trapFocus(evt);
} else if (evt.which === ESC) {
hideKeyboardHelp(evt);
}
};
var trapFocus = function(evt) {
if (evt.which === TAB) {
evt.preventDefault();
focusModalButton();
}
};
var focusModalButton = function() {
$root.find('.keyboard-help-dialog .modal-dismiss-button ').focus();
};
var showKeyboardHelp = function(evt) {
evt.preventDefault();
// Show dialog
var $keyboardHelpDialog = $root.find('.keyboard-help-dialog');
$keyboardHelpDialog.find('.modal-window-overlay').show();
$keyboardHelpDialog.find('.modal-window').show();
// Handle focus
$focusedElement = $(':focus');
focusModalButton();
// Set up event handlers
$(document).on('keydown', keyboardEventDispatcher);
$keyboardHelpDialog.find('.modal-dismiss-button').on('click', hideKeyboardHelp);
};
var hideKeyboardHelp = function(evt) {
evt.preventDefault();
// Hide dialog
var $keyboardHelpDialog = $root.find('.keyboard-help-dialog');
$keyboardHelpDialog.find('.modal-window-overlay').hide();
$keyboardHelpDialog.find('.modal-window').hide();
// Handle focus
$focusedElement.focus();
// Remove event handlers
$(document).off('keydown', keyboardEventDispatcher);
$keyboardHelpDialog.find('.modal-dismiss-button').off();
};
/** Asynchronously load the main background image used for this block. */
var loadBackgroundImage = function() {
var promise = $.Deferred();
......@@ -127,58 +211,141 @@ function DragAndDropBlock(runtime, element, configuration) {
});
};
var isCycleKey = function(evt) {
return !evt.ctrlKey && !evt.metaKey && evt.which === TAB;
};
var isCancelKey = function(evt) {
return !evt.ctrlKey && !evt.metaKey && evt.which === ESC;
};
var isActionKey = function(evt) {
var key = evt.which;
if (evt.ctrlKey || evt.metaKey) {
return key === M;
}
return key === RET || key === SPC;
};
var focusNextZone = function(evt, $currentZone) {
if (evt.shiftKey) { // Going backward
var isFirstZone = $currentZone.prev('.zone').length === 0;
if (isFirstZone) {
evt.preventDefault();
$root.find('.target .zone').last().focus();
}
} else { // Going forward
var isLastZone = $currentZone.next('.zone').length === 0;
if (isLastZone) {
evt.preventDefault();
$root.find('.target .zone').first().focus();
}
}
};
var placeItem = function($zone, $item) {
var item_id;
var $anchor;
if ($item !== undefined) {
item_id = $item.data('value');
// Element was placed using the mouse,
// so use relevant properties of *item* when calculating new position below.
$anchor = $item;
} else {
item_id = $selectedItem.data('value');
// Element was placed using the keyboard,
// so use relevant properties of *zone* when calculating new position below.
$anchor = $zone;
}
var zone = $zone.data('zone');
var $target_img = $root.find('.target-img');
// Calculate the position of the item to place relative to the image.
var x_pos = $anchor.offset().left + ($anchor.outerWidth()/2) - $target_img.offset().left;
var y_pos = $anchor.offset().top + ($anchor.outerHeight()/2) - $target_img.offset().top;
var x_pos_percent = x_pos / $target_img.width() * 100;
var y_pos_percent = y_pos / $target_img.height() * 100;
state.items[item_id] = {
zone: zone,
x_percent: x_pos_percent,
y_percent: y_pos_percent,
submitting_location: true,
};
// Wrap in setTimeout to let the droppable event finish.
setTimeout(function() {
applyState();
submitLocation(item_id, zone, x_pos_percent, y_pos_percent);
}, 0);
};
var initDroppable = function() {
// Set up zones for keyboard interaction
$root.find('.zone').each(function() {
var $zone = $(this);
$zone.on('keydown', function(evt) {
if (placementMode) {
if (isCycleKey(evt)) {
focusNextZone(evt, $zone);
} else if (isCancelKey(evt)) {
evt.preventDefault();
placementMode = false;
releaseItem($selectedItem);
} else if (isActionKey(evt)) {
evt.preventDefault();
placementMode = false;
placeItem($zone);
releaseItem($selectedItem);
}
}
});
});
// Make zone accept items that are dropped using the mouse
$root.find('.zone').droppable({
accept: '.xblock--drag-and-drop .item-bank .option',
tolerance: 'pointer',
drop: function(evt, ui) {
var item_id = ui.draggable.data('value');
var zone = $(this).data('zone');
var $target_img = $root.find('.target-img');
// Calculate the position of the center of the dropped element relative to
// the image.
var x_pos = (ui.helper.offset().left + (ui.helper.outerWidth()/2) - $target_img.offset().left);
var x_pos_percent = x_pos / $target_img.width() * 100;
var y_pos = (ui.helper.offset().top + (ui.helper.outerHeight()/2) - $target_img.offset().top);
var y_pos_percent = y_pos / $target_img.height() * 100;
state.items[item_id] = {
x_percent: x_pos_percent,
y_percent: y_pos_percent,
submitting_location: true,
};
// Wrap in setTimeout to let the droppable event finish.
setTimeout(function() {
applyState();
submitLocation(item_id, zone, x_pos_percent, y_pos_percent);
}, 0);
var $zone = $(this);
var $item = ui.helper;
placeItem($zone, $item);
}
});
};
var initDraggable = function() {
$root.find('.item-bank .option').not('[data-drag-disabled=true]').each(function() {
var $item = $(this);
// Allow item to be "picked up" using the keyboard
$item.on('keydown', function(evt) {
if (isActionKey(evt)) {
evt.preventDefault();
placementMode = true;
grabItem($item);
$selectedItem = $item;
$root.find('.target .zone').first().focus();
}
});
// Make item draggable using the mouse
try {
$(this).draggable({
$item.draggable({
containment: $root.find('.xblock--drag-and-drop .drag-container'),
cursor: 'move',
stack: $root.find('.xblock--drag-and-drop .item-bank .option'),
revert: 'invalid',
revertDuration: 150,
start: function(evt, ui) {
var item_id = $(this).data('value');
setGrabbedState(item_id, true);
updateDOM();
var $item = $(this);
grabItem($item);
publishEvent({
event_type: 'xblock.drag-and-drop-v2.item.picked-up',
item_id: item_id
item_id: $item.data('value'),
});
},
stop: function(evt, ui) {
var item_id = $(this).data('value');
setGrabbedState(item_id, false);
updateDOM();
releaseItem($(this));
}
});
} catch (e) {
......@@ -188,6 +355,18 @@ function DragAndDropBlock(runtime, element, configuration) {
});
};
var grabItem = function($item) {
var item_id = $item.data('value');
setGrabbedState(item_id, true);
updateDOM();
};
var releaseItem = function($item) {
var item_id = $item.data('value');
setGrabbedState(item_id, false);
updateDOM();
};
var setGrabbedState = function(item_id, grabbed) {
for (var i = 0; i < configuration.items.length; i++) {
if (configuration.items[i].id === item_id) {
......@@ -198,8 +377,12 @@ function DragAndDropBlock(runtime, element, configuration) {
var destroyDraggable = function() {
$root.find('.item-bank .option[data-drag-disabled=true]').each(function() {
var $item = $(this);
$item.off();
try {
$(this).draggable('destroy');
$item.draggable('destroy');
} catch (e) {
// Destroying the draggable will fail if draggable was
// not initialized in the first place. Ignore the exception.
......@@ -296,7 +479,8 @@ function DragAndDropBlock(runtime, element, configuration) {
applyState();
};
var resetExercise = function() {
var resetExercise = function(evt) {
evt.preventDefault();
$.ajax({
type: 'POST',
url: runtime.handlerUrl(element, 'reset'),
......@@ -331,10 +515,11 @@ function DragAndDropBlock(runtime, element, configuration) {
if (item.grabbed !== undefined) {
grabbed = item.grabbed;
}
var placed = item_user_state && ('input' in item_user_state || item_user_state.correct_input);
var itemProperties = {
value: item.id,
drag_disabled: Boolean(item_user_state || state.finished),
class_name: item_user_state && ('input' in item_user_state || item_user_state.correct_input) ? 'fade': undefined,
class_name: placed || state.finished ? 'fade' : undefined,
xhr_active: (item_user_state && item_user_state.submitting_location),
input: input,
displayName: item.displayName,
......@@ -345,6 +530,7 @@ function DragAndDropBlock(runtime, element, configuration) {
};
if (item_user_state) {
itemProperties.is_placed = true;
itemProperties.zone = item_user_state.zone;
itemProperties.x_percent = item_user_state.x_percent;
itemProperties.y_percent = item_user_state.y_percent;
}
......
......@@ -53,9 +53,18 @@
};
var itemTemplate = function(item) {
var style = {};
// Define properties
var className = (item.class_name) ? item.class_name : "";
var tabindex = 0;
if (item.has_image) {
className += " " + "option-with-image";
}
var attributes = {
'draggable': !item.drag_disabled,
'aria-grabbed': item.grabbed,
'data-value': item.value,
'data-drag-disabled': item.drag_disabled
};
var style = {};
if (item.background_color) {
style['background-color'] = item.background_color;
}
......@@ -68,34 +77,42 @@
if (item.is_placed) {
style.left = item.x_percent + "%";
style.top = item.y_percent + "%";
tabindex = -1; // If an item has been placed it can no longer be interacted with,
// so remove the ability to move focus to it using the keyboard
}
if (item.has_image) {
className += " " + "option-with-image";
} else {
// If an item has not been placed it must be possible to move focus to it using the keyboard:
attributes.tabindex = 0;
}
var content_html = item.displayName;
// Define children
var children = [
itemSpinnerTemplate(item.xhr_active),
itemInputTemplate(item.input)
];
var item_content_html = item.displayName;
if (item.imageURL) {
content_html = '<img src="' + item.imageURL + '" alt="' + item.imageDescription + '" />';
item_content_html = '<img src="' + item.imageURL + '" alt="' + item.imageDescription + '" />';
}
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 = 'item-' + item.value + '-description';
item_content.properties.attributes = { 'aria-describedby': item_description_id };
var item_description = h(
'div',
{ id: item_description_id, className: 'sr' },
gettext('Correctly placed in: ') + item.zone
);
children.splice(1, 0, item_description);
}
children.splice(1, 0, item_content);
return (
h('div.option',
h(
'div.option',
{
key: item.value,
className: className,
attributes: {
'tabindex': tabindex,
'draggable': !item.drag_disabled,
'aria-grabbed': item.grabbed,
'data-value': item.value,
'data-drag-disabled': item.drag_disabled
},
attributes: attributes,
style: style
}, [
itemSpinnerTemplate(item.xhr_active),
h('div', {innerHTML: content_html, className: "item-content"}),
itemInputTemplate(item.input)
]
},
children
)
);
};
......@@ -132,10 +149,44 @@
var properties = { attributes: { 'aria-live': 'polite' } };
return (
h('section.feedback', properties, [
h('div.reset-button', {style: {display: reset_button_display}}, gettext('Reset exercise')),
h('h3.title1', {style: {display: feedback_display}}, gettext('Feedback')),
h('p.message', {style: {display: feedback_display},
innerHTML: ctx.feedback_html})
h(
'a.reset-button',
{ style: { display: reset_button_display }, attributes: { tabindex: 0 }},
gettext('Reset exercise')
),
h('h3.title1', { style: { display: feedback_display } }, gettext('Feedback')),
h('p.message', { style: { display: feedback_display }, innerHTML: ctx.feedback_html })
])
);
};
var keyboardHelpTemplate = function(ctx) {
var dialog_attributes = { role: 'dialog', 'aria-labelledby': 'modal-window-title' };
var dialog_style = {};
return (
h('section.keyboard-help', [
h('a.keyboard-help-button', { attributes: { tabindex: 0 } }, gettext('Keyboard Help')),
h('div.keyboard-help-dialog', [
h('div.modal-window-overlay'),
h('div.modal-window', { attributes: dialog_attributes, style: dialog_style }, [
h('div.modal-header', [
h('h2.modal-window-title', gettext('Keyboard Help'))
]),
h('div.modal-content', [
h('p', gettext('You can complete this exercise using only your keyboard.')),
h('ul', [
h('li', gettext('Use "Tab" and "Shift-Tab" to navigate between items and zones.')),
h('li', gettext('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.')),
h('li', gettext('Press "Enter", "Space", "Ctrl-m", or "⌘-m" to drop the item on the current zone.')),
h('li', gettext('Press "Escape" if you want to cancel the drop operation (e.g. because you would like to select a different item).')),
h('li', gettext('Press "?" at any time to bring up this dialog.')),
])
]),
h('div.modal-actions', [
h('button.modal-dismiss-button', gettext("OK"))
])
])
])
])
);
};
......@@ -156,10 +207,17 @@
h('section.drag-container', [
h('div.item-bank', renderCollection(itemTemplate, items_in_bank, ctx)),
h('div.target', [
h('div.popup', {style: {display: ctx.popup_html ? 'block' : 'none'}}, [
h('div.close.icon-remove-sign.fa-times-circle'),
h('p.popup-content', {innerHTML: ctx.popup_html}),
]),
h(
'div.popup',
{
style: {display: ctx.popup_html ? 'block' : 'none'},
attributes: {'aria-live': 'polite'}
},
[
h('div.close.icon-remove-sign.fa-times-circle'),
h('p.popup-content', {innerHTML: ctx.popup_html}),
]
),
h('div.target-img-wrapper', [
h('img.target-img', {src: ctx.target_img_src, alt: ctx.target_img_description}),
]),
......@@ -167,6 +225,7 @@
renderCollection(itemTemplate, items_placed, ctx),
]),
]),
keyboardHelpTemplate(ctx),
feedbackTemplate(ctx),
])
);
......
......@@ -57,6 +57,24 @@ class BaseIntegrationTest(SeleniumBaseTest):
def _get_zones(self):
return self._page.find_elements_by_css_selector(".drag-container .zone")
def _get_popup(self):
return self._page.find_element_by_css_selector(".popup")
def _get_popup_content(self):
return self._page.find_element_by_css_selector(".popup .popup-content")
def _get_keyboard_help(self):
return self._page.find_element_by_css_selector(".keyboard-help")
def _get_keyboard_help_button(self):
return self._page.find_element_by_css_selector(".keyboard-help .keyboard-help-button")
def _get_keyboard_help_dialog(self):
return self._page.find_element_by_css_selector(".keyboard-help .keyboard-help-dialog")
def _get_reset_button(self):
return self._page.find_element_by_css_selector('.reset-button')
def _get_feedback(self):
return self._page.find_element_by_css_selector(".feedback")
......
from ddt import ddt, data
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver import ActionChains
from selenium.webdriver.common.keys import Keys
from drag_and_drop_v2.default_data import START_FEEDBACK, FINISH_FEEDBACK
from drag_and_drop_v2.default_data import (
TOP_ZONE_TITLE, MIDDLE_ZONE_TITLE, BOTTOM_ZONE_TITLE,
ITEM_CORRECT_FEEDBACK, ITEM_INCORRECT_FEEDBACK, ITEM_NO_ZONE_FEEDBACK,
START_FEEDBACK, FINISH_FEEDBACK
)
from .test_base import BaseIntegrationTest
from ..utils import load_resource
ZONES_MAP = {
0: TOP_ZONE_TITLE,
1: MIDDLE_ZONE_TITLE,
2: BOTTOM_ZONE_TITLE,
}
class ItemDefinition(object):
def __init__(self, item_id, zone_id, feedback_positive, feedback_negative, input_value=None):
self.feedback_negative = feedback_negative
......@@ -16,12 +31,19 @@ class ItemDefinition(object):
class InteractionTestBase(object):
@classmethod
def _get_correct_item_for_zone(cls, items_map):
def _get_items_with_zone(cls, items_map):
return {
item_key: definition for item_key, definition in items_map.items()
if definition.zone_id is not None
}
@classmethod
def _get_items_without_zone(cls, items_map):
return {
item_key: definition for item_key, definition in items_map.items()
if definition.zone_id is None
}
def setUp(self):
super(InteractionTestBase, self).setUp()
......@@ -37,6 +59,10 @@ class InteractionTestBase(object):
items_container = self._page.find_element_by_css_selector('.item-bank')
return items_container.find_elements_by_xpath("//div[@data-value='{item_id}']".format(item_id=item_value))[0]
def _get_placed_item_by_value(self, item_value):
items_container = self._page.find_element_by_css_selector('.target')
return items_container.find_elements_by_xpath("//div[@data-value='{item_id}']".format(item_id=item_value))[0]
def _get_zone_by_id(self, zone_id):
zones_container = self._page.find_element_by_css_selector('.target')
return zones_container.find_elements_by_xpath("//div[@data-zone='{zone_id}']".format(zone_id=zone_id))[0]
......@@ -45,17 +71,19 @@ class InteractionTestBase(object):
element = self._get_item_by_value(item_value)
return element.find_element_by_class_name('numerical-input')
def _send_input(self, item_value, value):
element = self._get_item_by_value(item_value)
self.wait_until_visible(element)
element.find_element_by_class_name('input').send_keys(value)
element.find_element_by_class_name('submit-input').click()
def _get_zone_position(self, zone_id):
return self.browser.execute_script(
'return $("div[data-zone=\'{zone_id}\']").prevAll(".zone").length'.format(zone_id=zone_id)
)
def get_feedback_popup(self):
return self._page.find_element_by_css_selector(".popup-content")
def _focus_item(self, item_position):
self.browser.execute_script("$('.option:nth-child({n})').focus()".format(n=item_position+1))
def get_reset_button(self):
return self._page.find_element_by_css_selector('.reset-button')
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)
else:
self.move_item_to_zone(item_value, zone_id, action_key)
def drag_item_to_zone(self, item_value, zone_id):
element = self._get_item_by_value(item_value)
......@@ -63,29 +91,99 @@ class InteractionTestBase(object):
action_chains = ActionChains(self.browser)
action_chains.drag_and_drop(element, target).perform()
def parameterized_item_positive_feedback_on_good_move(self, items_map, scroll_down=100):
def move_item_to_zone(self, item_value, zone_id, action_key):
# Get item position
item_position = item_value
# Get zone position
zone_position = self._get_zone_position(zone_id)
self._focus_item(0)
focused_item = self._get_item_by_value(0)
for i in range(item_position):
focused_item.send_keys(Keys.TAB)
focused_item = self._get_item_by_value(i+1)
focused_item.send_keys(action_key) # Focus is on first *zone* now
self.assert_grabbed_item(focused_item)
focused_zone = self._get_zone_by_id(ZONES_MAP[0])
for i in range(zone_position):
focused_zone.send_keys(Keys.TAB)
focused_zone = self._get_zone_by_id(ZONES_MAP[i+1])
focused_zone.send_keys(action_key)
def send_input(self, item_value, value):
element = self._get_item_by_value(item_value)
self.wait_until_visible(element)
element.find_element_by_class_name('input').send_keys(value)
element.find_element_by_class_name('submit-input').click()
def assert_grabbed_item(self, item):
self.assertEqual(item.get_attribute('aria-grabbed'), 'true')
def assert_placed_item(self, item_value, zone_id):
item = self._get_placed_item_by_value(item_value)
item_content = item.find_element_by_css_selector('.item-content')
item_description = item.find_element_by_css_selector('.sr')
item_description_id = 'item-{}-description'.format(item_value)
self.assertIsNone(item.get_attribute('tabindex'))
self.assertEqual(item.get_attribute('aria-grabbed'), 'false')
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_id))
def assert_reverted_item(self, item_value):
item = self._get_item_by_value(item_value)
item_content = item.find_element_by_css_selector('.item-content')
self.assertEqual(item.get_attribute('class'), 'option ui-draggable')
self.assertEqual(item.get_attribute('tabindex'), '0')
self.assertEqual(item.get_attribute('draggable'), 'true')
self.assertEqual(item.get_attribute('aria-grabbed'), 'false')
self.assertEqual(item.get_attribute('data-drag-disabled'), 'false')
self.assertIsNone(item_content.get_attribute('aria-describedby'))
try:
item.find_element_by_css_selector('.sr')
except NoSuchElementException:
pass
else:
self.fail('Reverted item should not have .sr description.')
def assert_decoy_items(self, items_map):
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):
# Scroll drop zones into view to make sure Selenium can successfully drop items
self.scroll_down(pixels=scroll_down)
for definition in self._get_correct_item_for_zone(items_map).values():
for definition in self._get_items_with_zone(items_map).values():
if not definition.input:
self.drag_item_to_zone(definition.item_id, definition.zone_id)
feedback_popup = self.get_feedback_popup()
self.wait_until_html_in(definition.feedback_positive, feedback_popup)
self.place_item(definition.item_id, definition.zone_id, action_key)
feedback_popup_content = self._get_popup_content()
self.wait_until_html_in(definition.feedback_positive, feedback_popup_content)
self.assert_placed_item(definition.item_id, definition.zone_id)
def parameterized_item_positive_feedback_on_good_input(self, items_map, scroll_down=100):
def parameterized_item_positive_feedback_on_good_input(self, items_map, scroll_down=100, action_key=None):
self.scroll_down(pixels=scroll_down)
feedback_popup = self.get_feedback_popup()
for definition in self._get_correct_item_for_zone(items_map).values():
feedback_popup_content = self._get_popup_content()
for definition in self._get_items_with_zone(items_map).values():
if definition.input:
self.drag_item_to_zone(definition.item_id, definition.zone_id)
self._send_input(definition.item_id, definition.input)
self.place_item(definition.item_id, definition.zone_id, action_key)
self.send_input(definition.item_id, definition.input)
input_div = self._get_input_div_by_value(definition.item_id)
self.wait_until_has_class('correct', input_div)
self.wait_until_html_in(definition.feedback_positive, feedback_popup)
self.wait_until_html_in(definition.feedback_positive, feedback_popup_content)
self.assert_placed_item(definition.item_id, definition.zone_id)
def parameterized_item_negative_feedback_on_bad_move(self, items_map, all_zones, scroll_down=100):
feedback_popup = self.get_feedback_popup()
def parameterized_item_negative_feedback_on_bad_move(self, items_map, all_zones, scroll_down=100, action_key=None):
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)
......@@ -94,28 +192,30 @@ class InteractionTestBase(object):
for zone in all_zones:
if zone == definition.zone_id:
continue
self.drag_item_to_zone(definition.item_id, zone)
self.wait_until_html_in(definition.feedback_negative, feedback_popup)
self.place_item(definition.item_id, zone, action_key)
self.wait_until_html_in(definition.feedback_negative, feedback_popup_content)
self.assert_reverted_item(definition.item_id)
def parameterized_item_positive_feedback_on_bad_input(self, items_map, scroll_down=100):
feedback_popup = self.get_feedback_popup()
def parameterized_item_negative_feedback_on_bad_input(self, items_map, scroll_down=100, action_key=None):
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_correct_item_for_zone(items_map).values():
for definition in self._get_items_with_zone(items_map).values():
if definition.input:
self.drag_item_to_zone(definition.item_id, definition.zone_id)
self._send_input(definition.item_id, '1999999')
self.place_item(definition.item_id, definition.zone_id, action_key)
self.send_input(definition.item_id, '1999999')
input_div = self._get_input_div_by_value(definition.item_id)
self.wait_until_has_class('incorrect', input_div)
self.wait_until_html_in(definition.feedback_negative, feedback_popup)
self.wait_until_html_in(definition.feedback_negative, feedback_popup_content)
self.assert_placed_item(definition.item_id, definition.zone_id)
def parameterized_final_feedback_and_reset(self, items_map, feedback, scroll_down=100):
def parameterized_final_feedback_and_reset(self, items_map, feedback, scroll_down=100, action_key=None):
feedback_message = self._get_feedback_message()
self.assertEqual(self.get_element_html(feedback_message), feedback['intro']) # precondition check
items = self._get_correct_item_for_zone(items_map)
items = self._get_items_with_zone(items_map)
def get_locations():
return {item_id: self._get_item_by_value(item_id).location for item_id in items.keys()}
......@@ -126,41 +226,88 @@ class InteractionTestBase(object):
self.scroll_down(pixels=scroll_down)
for item_key, definition in items.items():
self.drag_item_to_zone(item_key, definition.zone_id)
self.place_item(definition.item_id, definition.zone_id, action_key)
if definition.input:
self._send_input(item_key, definition.input)
self.send_input(item_key, definition.input)
input_div = self._get_input_div_by_value(item_key)
self.wait_until_has_class('correct', input_div)
self.assert_placed_item(definition.item_id, definition.zone_id)
self.wait_until_html_in(feedback['final'], self._get_feedback_message())
# Check decoy items
self.assert_decoy_items(items_map)
# Scroll "Reset exercise" button into view to make sure Selenium can successfully click it
self.scroll_down(pixels=scroll_down+150)
reset = self.get_reset_button()
reset.click()
reset = self._get_reset_button()
if action_key is not None: # Using keyboard to interact with block
reset.send_keys(Keys.RETURN)
else:
reset.click()
self.wait_until_html_in(feedback['intro'], self._get_feedback_message())
locations_after_reset = get_locations()
for item_key in items.keys():
self.assertDictEqual(locations_after_reset[item_key], initial_locations[item_key])
self.assert_reverted_item(item_key)
def interact_with_keyboard_help(self, scroll_down=250, use_keyboard=False):
keyboard_help_button = self._get_keyboard_help_button()
keyboard_help_dialog = self._get_keyboard_help_dialog()
dialog_modal_overlay = keyboard_help_dialog.find_element_by_css_selector('.modal-window-overlay')
dialog_modal = keyboard_help_dialog.find_element_by_css_selector('.modal-window')
dialog_dismiss_button = dialog_modal.find_element_by_css_selector('.modal-dismiss-button')
# Scroll "Keyboard help" button into view to make sure Selenium can successfully click it
self.scroll_down(pixels=scroll_down)
if use_keyboard:
keyboard_help_button.send_keys(Keys.RETURN)
else:
keyboard_help_button.click()
self.assertTrue(dialog_modal_overlay.is_displayed())
self.assertTrue(dialog_modal.is_displayed())
if use_keyboard:
dialog_dismiss_button.send_keys(Keys.RETURN)
else:
dialog_dismiss_button.click()
self.assertFalse(dialog_modal_overlay.is_displayed())
self.assertFalse(dialog_modal.is_displayed())
if use_keyboard: # Try again with "?" key
self._page.send_keys("?")
self.assertTrue(dialog_modal_overlay.is_displayed())
self.assertTrue(dialog_modal.is_displayed())
class BasicInteractionTest(InteractionTestBase):
"""
Verifying Drag and Drop XBlock rendering against default data - if default data changes this will probably break.
Testing interactions with Drag and Drop XBlock against default data. If default data changes this will break.
"""
PAGE_TITLE = 'Drag and Drop v2'
PAGE_ID = 'drag_and_drop_v2'
items_map = {
0: ItemDefinition(0, 'Zone 1', "Yes, it's a 1", "No, 1 does not belong here"),
1: ItemDefinition(1, 'Zone 2', "Yes, it's a 2", "No, 2 does not belong here"),
2: ItemDefinition(2, None, "", "You silly, there are no zones for X")
0: ItemDefinition(
0, TOP_ZONE_TITLE, ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK
),
1: ItemDefinition(
1, MIDDLE_ZONE_TITLE, ITEM_CORRECT_FEEDBACK.format(zone=MIDDLE_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK
),
2: ItemDefinition(
2, BOTTOM_ZONE_TITLE, ITEM_CORRECT_FEEDBACK.format(zone=BOTTOM_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK
),
3: ItemDefinition(3, None, "", ITEM_NO_ZONE_FEEDBACK),
}
all_zones = ['Zone 1', 'Zone 2']
all_zones = [TOP_ZONE_TITLE, MIDDLE_ZONE_TITLE, BOTTOM_ZONE_TITLE]
feedback = {
"intro": START_FEEDBACK,
......@@ -179,12 +326,41 @@ class BasicInteractionTest(InteractionTestBase):
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_item_positive_feedback_on_bad_input(self):
self.parameterized_item_positive_feedback_on_bad_input(self.items_map)
def test_item_negative_feedback_on_bad_input(self):
self.parameterized_item_negative_feedback_on_bad_input(self.items_map)
def test_final_feedback_and_reset(self):
self.parameterized_final_feedback_and_reset(self.items_map, self.feedback)
def test_keyboard_help(self):
self.interact_with_keyboard_help()
@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_positive_feedback_on_good_input_with_keyboard(self, action_key):
self.parameterized_item_positive_feedback_on_good_input(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_item_negative_feedback_on_bad_input_with_keyboard(self, action_key):
self.parameterized_item_negative_feedback_on_bad_input(self.items_map, 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):
items_map = {
......@@ -284,11 +460,11 @@ class MultipleBlocksDataInteraction(InteractionTestBase, BaseIntegrationTest):
self.item_maps['block2'], self.all_zones['block2'], scroll_down=900
)
def test_item_positive_feedback_on_bad_input(self):
def test_item_negative_feedback_on_bad_input(self):
self._switch_to_block(0)
self.parameterized_item_positive_feedback_on_bad_input(self.item_maps['block1'])
self.parameterized_item_negative_feedback_on_bad_input(self.item_maps['block1'])
self._switch_to_block(1)
self.parameterized_item_positive_feedback_on_bad_input(self.item_maps['block2'], scroll_down=900)
self.parameterized_item_negative_feedback_on_bad_input(self.item_maps['block2'], scroll_down=900)
def test_final_feedback_and_reset(self):
self._switch_to_block(0)
......
......@@ -179,6 +179,30 @@ class TestDragAndDropRender(BaseIntegrationTest):
# Zone description should only be visible to screen readers:
self.assertEqual(zone_description.get_attribute('class'), 'zone-description sr')
def test_popup(self):
self.load_scenario()
popup = self._get_popup()
popup_content = self._get_popup_content()
self.assertFalse(popup.is_displayed())
self.assertEqual(popup.get_attribute('aria-live'), 'polite')
self.assertEqual(popup_content.text, "")
def test_keyboard_help(self):
self.load_scenario()
self._get_keyboard_help()
keyboard_help_button = self._get_keyboard_help_button()
keyboard_help_dialog = self._get_keyboard_help_dialog()
dialog_modal_overlay = keyboard_help_dialog.find_element_by_css_selector('.modal-window-overlay')
dialog_modal = keyboard_help_dialog.find_element_by_css_selector('.modal-window')
self.assertEqual(keyboard_help_button.get_attribute('tabindex'), '0')
self.assertFalse(dialog_modal_overlay.is_displayed())
self.assertFalse(dialog_modal.is_displayed())
self.assertEqual(dialog_modal.get_attribute('role'), 'dialog')
self.assertEqual(dialog_modal.get_attribute('aria-labelledby'), 'modal-window-title')
def test_feedback(self):
self.load_scenario()
......
......@@ -87,6 +87,7 @@ class BaseDragAndDropAjaxFixture(TestCaseMixin):
})
def test_do_attempt_with_input(self):
# Drop item that requires numerical input
data = {"val": 1, "zone": self.ZONE_2, "x_percent": "0%", "y_percent": "85%"}
res = self.call_handler('do_attempt', data)
self.assertEqual(res, {
......@@ -99,13 +100,16 @@ class BaseDragAndDropAjaxFixture(TestCaseMixin):
expected_state = {
'items': {
"1": {"x_percent": "0%", "y_percent": "85%", "correct_input": False},
"1": {
"x_percent": "0%", "y_percent": "85%", "correct_input": False, "zone": self.ZONE_2,
},
},
'finished': False,
'overall_feedback': self.initial_feedback(),
}
self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET"))
# Submit incorrect value
data = {"val": 1, "input": "250"}
res = self.call_handler('do_attempt', data)
self.assertEqual(res, {
......@@ -118,13 +122,17 @@ class BaseDragAndDropAjaxFixture(TestCaseMixin):
expected_state = {
'items': {
"1": {"x_percent": "0%", "y_percent": "85%", "input": "250", "correct_input": False},
"1": {
"x_percent": "0%", "y_percent": "85%", "correct_input": False, "zone": self.ZONE_2,
"input": "250",
},
},
'finished': False,
'overall_feedback': self.initial_feedback(),
}
self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET"))
# Submit correct value
data = {"val": 1, "input": "103"}
res = self.call_handler('do_attempt', data)
self.assertEqual(res, {
......@@ -137,7 +145,10 @@ class BaseDragAndDropAjaxFixture(TestCaseMixin):
expected_state = {
'items': {
"1": {"x_percent": "0%", "y_percent": "85%", "input": "103", "correct_input": True},
"1": {
"x_percent": "0%", "y_percent": "85%", "correct_input": True, "zone": self.ZONE_2,
"input": "103",
},
},
'finished': False,
'overall_feedback': self.initial_feedback(),
......@@ -177,7 +188,7 @@ class BaseDragAndDropAjaxFixture(TestCaseMixin):
expected_state = {
"items": {
"0": {"x_percent": "33%", "y_percent": "11%", "correct_input": True}
"0": {"x_percent": "33%", "y_percent": "11%", "correct_input": True, "zone": self.ZONE_1}
},
"finished": False,
'overall_feedback': self.initial_feedback(),
......@@ -198,8 +209,13 @@ class BaseDragAndDropAjaxFixture(TestCaseMixin):
expected_state = {
"items": {
"0": {"x_percent": "33%", "y_percent": "11%", "correct_input": True},
"1": {"x_percent": "22%", "y_percent": "22%", "input": "99", "correct_input": True}
"0": {
"x_percent": "33%", "y_percent": "11%", "correct_input": True, "zone": self.ZONE_1,
},
"1": {
"x_percent": "22%", "y_percent": "22%", "correct_input": True, "zone": self.ZONE_2,
"input": "99",
}
},
"finished": True,
'overall_feedback': self.FINAL_FEEDBACK,
......
import unittest
from drag_and_drop_v2.default_data import (
TARGET_IMG_DESCRIPTION, START_FEEDBACK, FINISH_FEEDBACK, DEFAULT_DATA
TARGET_IMG_DESCRIPTION, TOP_ZONE_TITLE, MIDDLE_ZONE_TITLE, BOTTOM_ZONE_TITLE,
START_FEEDBACK, FINISH_FEEDBACK, DEFAULT_DATA
)
from ..utils import make_block, TestCaseMixin
......@@ -62,25 +63,25 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
assert_user_state_empty()
# Drag three items into the correct spot:
data = {"val": 0, "zone": "The Top Zone", "x_percent": "33%", "y_percent": "11%"}
data = {"val": 0, "zone": TOP_ZONE_TITLE, "x_percent": "33%", "y_percent": "11%"}
self.call_handler('do_attempt', data)
data = {"val": 1, "zone": "The Middle Zone", "x_percent": "67%", "y_percent": "80%"}
data = {"val": 1, "zone": MIDDLE_ZONE_TITLE, "x_percent": "67%", "y_percent": "80%"}
self.call_handler('do_attempt', data)
data = {"val": 2, "zone": "The Bottom Zone", "x_percent": "99%", "y_percent": "95%"}
data = {"val": 2, "zone": BOTTOM_ZONE_TITLE, "x_percent": "99%", "y_percent": "95%"}
self.call_handler('do_attempt', data)
# Check the result:
self.assertTrue(self.block.completed)
self.assertEqual(self.block.item_state, {
'0': {'x_percent': '33%', 'y_percent': '11%'},
'1': {'x_percent': '67%', 'y_percent': '80%'},
'2': {'x_percent': '99%', 'y_percent': '95%'},
'0': {'x_percent': '33%', 'y_percent': '11%', 'zone': TOP_ZONE_TITLE},
'1': {'x_percent': '67%', 'y_percent': '80%', 'zone': MIDDLE_ZONE_TITLE},
'2': {'x_percent': '99%', 'y_percent': '95%', 'zone': BOTTOM_ZONE_TITLE},
})
self.assertEqual(self.call_handler('get_user_state'), {
'items': {
'0': {'x_percent': '33%', 'y_percent': '11%', 'correct_input': True},
'1': {'x_percent': '67%', 'y_percent': '80%', 'correct_input': True},
'2': {'x_percent': '99%', 'y_percent': '95%', 'correct_input': True},
'0': {'x_percent': '33%', 'y_percent': '11%', 'correct_input': True, 'zone': TOP_ZONE_TITLE},
'1': {'x_percent': '67%', 'y_percent': '80%', 'correct_input': True, 'zone': MIDDLE_ZONE_TITLE},
'2': {'x_percent': '99%', 'y_percent': '95%', 'correct_input': True, 'zone': BOTTOM_ZONE_TITLE},
},
'finished': True,
'overall_feedback': FINISH_FEEDBACK,
......
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