Commit 4d0d5001 by Eugeny Kolpakov Committed by GitHub

Merge pull request #92 from open-craft/ekolpakov/non-overlapping-placement

Prevent overlapping drop placements
parents 6b211933 de5ac0c4
......@@ -11,7 +11,7 @@ The editor is fully guided. Features include:
* custom zone labels
* ability to show or hide zone borders
* custom text and background colors for items
* optional auto-alignment for items (left, right, center)
* auto-alignment for items: left, right, center
* image items
* decoy items that don't have a zone
* feedback popups for both correct and incorrect attempts
......@@ -122,15 +122,13 @@ whether or not to display borders outlining the zones. It is possible
to define an arbitrary number of drop zones as long as their labels
are unique.
Additionally, you can specify the alignment for items once they are dropped in
the zone. No alignment is the default, and causes items to stay where the
learner drops them. Left alignment causes dropped items to be placed from left
You can specify the alignment for items once they are dropped in
the zone. Centered alignment is the default, and places items from top to bottom
along the center of the zone. Left alignment causes dropped items to be placed from left
to right across the zone. Right alignment causes the items to be placed from
right to left across the zone. Center alignment places items from top to bottom
along the center of the zone. If left, right, or center alignment is chosen,
items dropped in a zone will not overlap, but if the zone is not made large
enough for all its items, they will overflow the bottom of the zone, and
potentially, overlap the zones below.
right to left across the zone. Items dropped in a zone will not overlap,
but if the zone is not made large enough for all its items, they will overflow the bottom
of the zone, and potentially overlap the zones below.
![Drag item edit](/doc/img/edit-view-items.png)
......@@ -151,6 +149,9 @@ You can leave all of the checkboxes unchecked in order to create a
You can define an arbitrary number of drag items, each of which may
be attached to any number of zones.
"Maximum items per Zone" setting controls how many items can be dropped into a
single zone, allowing some degree of control over items overlapping zones below.
Scoring
-------
......
......@@ -38,6 +38,7 @@ DEFAULT_DATA = {
"y": 30,
"width": 196,
"height": 178,
"align": "center"
},
{
"uid": MIDDLE_ZONE_ID,
......@@ -47,6 +48,7 @@ DEFAULT_DATA = {
"y": 210,
"width": 340,
"height": 138,
"align": "center"
},
{
"uid": BOTTOM_ZONE_ID,
......@@ -56,6 +58,7 @@ DEFAULT_DATA = {
"y": 350,
"width": 485,
"height": 135,
"align": "center"
}
],
"items": [
......@@ -65,7 +68,9 @@ DEFAULT_DATA = {
"incorrect": ITEM_INCORRECT_FEEDBACK,
"correct": ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE)
},
"zones": [TOP_ZONE_ID],
"zones": [
TOP_ZONE_ID
],
"imageURL": "",
"id": 0,
},
......@@ -75,7 +80,9 @@ DEFAULT_DATA = {
"incorrect": ITEM_INCORRECT_FEEDBACK,
"correct": ITEM_CORRECT_FEEDBACK.format(zone=MIDDLE_ZONE_TITLE)
},
"zones": [MIDDLE_ZONE_ID],
"zones": [
MIDDLE_ZONE_ID
],
"imageURL": "",
"id": 1,
},
......@@ -85,7 +92,9 @@ DEFAULT_DATA = {
"incorrect": ITEM_INCORRECT_FEEDBACK,
"correct": ITEM_CORRECT_FEEDBACK.format(zone=BOTTOM_ZONE_TITLE)
},
"zones": [BOTTOM_ZONE_ID],
"zones": [
BOTTOM_ZONE_ID
],
"imageURL": "",
"id": 2,
},
......@@ -95,7 +104,11 @@ DEFAULT_DATA = {
"incorrect": "",
"correct": ITEM_ANY_ZONE_FEEDBACK
},
"zones": [TOP_ZONE_ID, BOTTOM_ZONE_ID, MIDDLE_ZONE_ID],
"zones": [
TOP_ZONE_ID,
BOTTOM_ZONE_ID,
MIDDLE_ZONE_ID
],
"imageURL": "",
"id": 3
},
......
......@@ -6,6 +6,7 @@
import copy
import json
import logging
import urllib
import webob
......@@ -16,25 +17,24 @@ from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader
from xblockutils.settings import XBlockWithSettingsMixin, ThemableXBlockMixin
from .utils import _, DummyTranslationService, FeedbackMessage, FeedbackMessages, ItemStats
from .utils import _, DummyTranslationService, FeedbackMessage, FeedbackMessages, ItemStats, StateMigration, Constants
from .default_data import DEFAULT_DATA
# Globals ###########################################################
loader = ResourceLoader(__name__)
logger = logging.getLogger(__name__)
# Classes ###########################################################
@XBlock.wants('settings')
@XBlock.needs('i18n')
class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
"""
XBlock that implements a friendly Drag-and-Drop problem
"""
STANDARD_MODE = "standard"
ASSESSMENT_MODE = "assessment"
SOLUTION_CORRECT = "correct"
SOLUTION_PARTIAL = "partial"
......@@ -69,10 +69,10 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
),
scope=Scope.settings,
values=[
{"display_name": _("Standard"), "value": STANDARD_MODE},
{"display_name": _("Assessment"), "value": ASSESSMENT_MODE},
{"display_name": _("Standard"), "value": Constants.STANDARD_MODE},
{"display_name": _("Assessment"), "value": Constants.ASSESSMENT_MODE},
],
default=STANDARD_MODE
default=Constants.STANDARD_MODE
)
max_attempts = Integer(
......@@ -127,6 +127,13 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
default="",
)
max_items_per_zone = Integer(
display_name=_("Maximum items per zone"),
help=_("This setting limits the number of items that can be dropped into a single zone."),
scope=Scope.settings,
default=None
)
data = Dict(
display_name=_("Problem data"),
help=_(
......@@ -157,7 +164,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
)
grade = Float(
help=_("Keeps maximum achieved score by student"),
help=_("Keeps maximum score achieved by student"),
scope=Scope.user_state,
default=0
)
......@@ -223,8 +230,9 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
return {
"mode": self.mode,
"zones": self.zones,
"max_attempts": self.max_attempts,
"zones": self._get_zones(),
"max_items_per_zone": self.max_items_per_zone,
# SDK doesn't supply url_name.
"url_name": getattr(self, 'url_name', ''),
"display_zone_labels": self.data.get('displayLabels', False),
......@@ -286,7 +294,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
items = self.data.get('items', [])
for item in items:
zones = self._get_item_zones(item['id'])
zones = self.get_item_zones(item['id'])
# Note that we appear to be mutating the state of the XBlock here, but because
# the change won't be committed, we're actually just affecting the data that
# we're going to send to the client, not what's saved in the backing store.
......@@ -315,12 +323,46 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
self.weight = float(submissions['weight'])
self.item_background_color = submissions['item_background_color']
self.item_text_color = submissions['item_text_color']
self.max_items_per_zone = self._get_max_items_per_zone(submissions)
self.data = submissions['data']
return {
'result': 'success',
}
@staticmethod
def _get_max_items_per_zone(submissions):
"""
Parses Max items per zone value coming from editor.
Returns:
* None if invalid value is passed (i.e. not an integer)
* None if value is parsed into zero or negative integer
* Positive integer otherwise.
Examples:
* _get_max_items_per_zone(None) -> None
* _get_max_items_per_zone('string') -> None
* _get_max_items_per_zone('-1') -> None
* _get_max_items_per_zone(-1) -> None
* _get_max_items_per_zone('0') -> None
* _get_max_items_per_zone('') -> None
* _get_max_items_per_zone('42') -> 42
* _get_max_items_per_zone(42) -> 42
"""
raw_max_items_per_zone = submissions.get('max_items_per_zone', None)
# Entries that aren't numbers should be treated as null. We assume that if we can
# turn it into an int, a number was submitted.
try:
max_attempts = int(raw_max_items_per_zone)
if max_attempts > 0:
return max_attempts
else:
return None
except (ValueError, TypeError):
return None
@XBlock.json_handler
def drop_item(self, item_attempt, suffix=''):
"""
......@@ -328,9 +370,9 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
"""
self._validate_drop_item(item_attempt)
if self.mode == self.ASSESSMENT_MODE:
if self.mode == Constants.ASSESSMENT_MODE:
return self._drop_item_assessment(item_attempt)
elif self.mode == self.STANDARD_MODE:
elif self.mode == Constants.STANDARD_MODE:
return self._drop_item_standard(item_attempt)
else:
raise JsonHandlerError(
......@@ -434,7 +476,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
"""
Validates if `do_attempt` handler should be executed
"""
if self.mode != self.ASSESSMENT_MODE:
if self.mode != Constants.ASSESSMENT_MODE:
raise JsonHandlerError(
400,
self.i18n_service.gettext("do_attempt handler should only be called for assessment mode")
......@@ -452,7 +494,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
answer_correctness = self._answer_correctness()
is_correct = answer_correctness == self.SOLUTION_CORRECT
if self.mode == self.STANDARD_MODE or not self.attempts:
if self.mode == Constants.STANDARD_MODE or not self.attempts:
feedback_key = 'finish' if is_correct else 'start'
return [FeedbackMessage(self.data['feedback'][feedback_key], None)], set()
......@@ -563,9 +605,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
"""
return {
'zone': attempt['zone'],
'correct': correct,
'x_percent': attempt['x_percent'],
'y_percent': attempt['y_percent'],
'correct': correct
}
def _mark_complete_and_publish_grade(self):
......@@ -613,7 +653,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
"""
Check if the item was placed correctly.
"""
correct_zones = self._get_item_zones(attempt['val'])
correct_zones = self.get_item_zones(attempt['val'])
return attempt['zone'] in correct_zones
def _expand_static_url(self, url):
......@@ -640,12 +680,12 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
item_state = self._get_item_state()
# In assessment mode, we do not want to leak the correctness info for individual items to the frontend,
# so we remove "correct" from all items when in assessment mode.
if self.mode == self.ASSESSMENT_MODE:
if self.mode == Constants.ASSESSMENT_MODE:
for item in item_state.values():
del item["correct"]
overall_feedback_msgs, __ = self._get_feedback()
if self.mode == self.STANDARD_MODE:
if self.mode == Constants.STANDARD_MODE:
is_finished = self._is_answer_correct()
else:
is_finished = not self.attempts_remain
......@@ -666,33 +706,10 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
# IMPORTANT: this method should always return a COPY of self.item_state - it is called from get_user_state
# handler and the data it returns is manipulated there to hide correctness of items placed.
state = {}
migrator = StateMigration(self)
for item_id, raw_item in self.item_state.iteritems():
if isinstance(raw_item, dict):
# Items are manipulated in _get_user_state, so we protect actual data.
item = copy.deepcopy(raw_item)
else:
item = {'top': raw_item[0], 'left': raw_item[1]}
# If information about zone is missing
# (because problem was completed before a11y enhancements were implemented),
# deduce zone in which item is placed from definition:
if item.get('zone') is None:
valid_zones = self._get_item_zones(int(item_id))
if valid_zones:
# If we get to this point, then the item was placed prior to support for
# multiple correct zones being added. As a result, it can only be correct
# on a single zone, and so we can trust that the item was placed on the
# zone with index 0.
item['zone'] = valid_zones[0]
else:
item['zone'] = 'unknown'
# If correctness information is missing
# (because problem was completed before assessment mode was implemented),
# assume the item is in correct zone (in standard mode, only items placed
# into correct zone are stored in item state).
if item.get('correct') is None:
item['correct'] = True
state[item_id] = item
for item_id, item in self.item_state.iteritems():
state[item_id] = migrator.apply_item_state_migrations(item_id, item)
return state
......@@ -702,7 +719,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
"""
return next(i for i in self.data['items'] if i['id'] == item_id)
def _get_item_zones(self, item_id):
def get_item_zones(self, item_id):
"""
Returns a list of the zones that are valid options for the item.
......@@ -720,27 +737,20 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
else:
return []
def _get_zones(self):
@property
def zones(self):
"""
Get drop zone data, defined by the author.
"""
# Convert zone data from old to new format if necessary
zones = []
for zone in self.data.get('zones', []):
zone = zone.copy()
if "uid" not in zone:
zone["uid"] = zone.get("title") # Older versions used title as the zone UID
# Remove old, now-unused zone attributes, if present:
zone.pop("id", None)
zone.pop("index", None)
zones.append(zone)
return zones
migrator = StateMigration(self)
return [migrator.apply_zone_migrations(zone) for zone in self.data.get('zones', [])]
def _get_zone_by_uid(self, uid):
"""
Given a zone UID, return that zone, or None.
"""
for zone in self._get_zones():
for zone in self.zones:
if zone["uid"] == uid:
return zone
......@@ -772,7 +782,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin):
item_state = self._get_item_state()
all_items = set(str(item['id']) for item in self.data['items'])
required = set(item_id for item_id in all_items if self._get_item_zones(int(item_id)) != [])
required = set(item_id for item_id in all_items if self.get_item_zones(int(item_id)) != [])
placed = set(item_id for item_id in all_items if item_id in item_state)
correctly_placed = set(item_id for item_id in placed if item_state[item_id]['correct'])
decoy = all_items - required
......
......@@ -171,9 +171,9 @@
text-align: center;
}
.xblock--drag-and-drop .zone .item-align-center .option {
display: block;
margin-left: auto;
margin-right: auto;
display: inline-block;
margin-left: 1px;
margin-right: 1px;
}
/* Focused option */
......
......@@ -84,23 +84,11 @@ function DragAndDropTemplates(configuration) {
style['outline-color'] = item.color;
}
if (item.is_placed) {
if (item.zone_align === 'none') {
// This is not an "aligned" zone, so the item gets positioned where the learner dropped it.
style.left = item.x_percent + "%";
style.top = item.y_percent + "%";
if (item.widthPercent) { // This item has an author-defined explicit width
style.width = item.widthPercent + "%";
style.maxWidth = item.widthPercent + "%"; // default maxWidth is ~30%
}
} else {
// This is an "aligned" zone, so the item position within the zone is calculated by the browser.
// Make up for the fact we're in a wrapper container by calculating percentage differences.
var maxWidth = (item.widthPercent || 30) / 100;
var widthPercent = zone.width_percent / 100;
style.maxWidth = ((1 / (widthPercent / maxWidth)) * 100) + '%';
if (item.widthPercent) {
style.width = style.maxWidth;
}
var maxWidth = (item.widthPercent || 30) / 100;
var widthPercent = zone.width_percent / 100;
style.maxWidth = ((1 / (widthPercent / maxWidth)) * 100) + '%';
if (item.widthPercent) {
style.width = style.maxWidth;
}
// Finally, if the item is using automatic sizing and contains an image, we
// always prefer the natural width of the image (subject to the max-width):
......@@ -192,7 +180,6 @@ function DragAndDropTemplates(configuration) {
var zoneTemplate = function(zone, ctx) {
var className = ctx.display_zone_labels ? 'zone-name' : 'zone-name sr';
var selector = ctx.display_zone_borders ? 'div.zone.zone-with-borders' : 'div.zone';
// If zone is aligned, mark its item alignment
// and render its placed items as children
var item_wrapper = 'div.item-wrapper';
......@@ -389,8 +376,6 @@ function DragAndDropTemplates(configuration) {
var is_item_placed = function(i) { return i.is_placed; };
var items_placed = $.grep(ctx.items, is_item_placed);
var items_in_bank = $.grep(ctx.items, is_item_placed, true);
var is_item_placed_unaligned = function(i) { return i.zone_align === 'none'; };
var items_placed_unaligned = $.grep(items_placed, is_item_placed_unaligned);
var item_bank_properties = {};
if (ctx.item_bank_focusable) {
item_bank_properties.attributes = {
......@@ -421,7 +406,6 @@ function DragAndDropTemplates(configuration) {
h('img.target-img', {src: ctx.target_img_src, alt: ctx.target_img_description}),
]
),
renderCollection(itemTemplate, items_placed_unaligned, ctx),
renderCollection(zoneTemplate, ctx.zones, ctx)
]),
]),
......@@ -463,6 +447,8 @@ function DragAndDropBlock(runtime, element, configuration) {
// Event string size limit.
var MAX_LENGTH = 255;
var DEFAULT_ZONE_ALIGN = 'center';
// Keyboard accessibility
var ESC = 27;
var RET = 13;
......@@ -490,7 +476,7 @@ function DragAndDropBlock(runtime, element, configuration) {
});
state = stateResult[0]; // stateResult is an array of [data, statusText, jqXHR]
migrateConfiguration(bgImg.width);
migrateState(bgImg.width, bgImg.height);
migrateState();
markItemZoneAlign();
bgImgNaturalWidth = bgImg.width;
......@@ -755,42 +741,42 @@ function DragAndDropBlock(runtime, element, configuration) {
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 = String($zone.data('uid'));
var zone_align = $zone.data('zone_align');
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;
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) {
state.last_action_correct = false;
state.feedback = gettext("You cannot add any more items to this zone.");
applyState();
return;
}
state.items[item_id] = {
zone: zone,
zone_align: zone_align,
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);
submitLocation(item_id, zone);
}, 0);
};
var countItemsInZone = function(zone, exclude_ids) {
var ids_to_exclude = exclude_ids ? exclude_ids : [];
return Object.keys(state.items).filter(function(item_id) {
return state.items[item_id].zone === zone && $.inArray(item_id, ids_to_exclude) === -1;
}).length;
};
var initDroppable = function() {
// Set up zones for keyboard interaction
$root.find('.zone, .item-bank').each(function() {
......@@ -805,6 +791,7 @@ function DragAndDropBlock(runtime, element, configuration) {
releaseItem($selectedItem);
} else if (isActionKey(evt)) {
evt.preventDefault();
evt.stopPropagation();
state.keyboard_placement_mode = false;
releaseItem($selectedItem);
if ($zone.is('.item-bank')) {
......@@ -946,16 +933,14 @@ function DragAndDropBlock(runtime, element, configuration) {
});
};
var submitLocation = function(item_id, zone, x_percent, y_percent) {
var submitLocation = function(item_id, zone) {
if (!zone) {
return;
}
var url = runtime.handlerUrl(element, 'drop_item');
var data = {
val: item_id,
zone: zone,
x_percent: x_percent,
y_percent: y_percent,
zone: zone
};
$.post(url, JSON.stringify(data), 'json')
......@@ -1107,8 +1092,6 @@ function DragAndDropBlock(runtime, element, configuration) {
if (item_user_state) {
itemProperties.zone = item_user_state.zone;
itemProperties.zone_align = item_user_state.zone_align;
itemProperties.x_percent = item_user_state.x_percent;
itemProperties.y_percent = item_user_state.y_percent;
}
if (configuration.item_background_color) {
itemProperties.background_color = configuration.item_background_color;
......@@ -1172,35 +1155,12 @@ function DragAndDropBlock(runtime, element, configuration) {
/**
* migrateState: Apply any changes necessary to support the 'state' format used by older
* versions of this XBlock.
* We have to do this in JS, not python, since some migrations depend on the image size,
* Most migrations are applied in python, but migrations may depend on the image size,
* which is not known in Python-land.
*/
var migrateState = function(bg_image_width, bg_image_height) {
Object.keys(state.items).forEach(function(item_id) {
var item = state.items[item_id];
if (item.x_percent === undefined) {
// Find the matching item in the configuration
var width = 190;
var height = 44;
for (var i in configuration.items) {
if (configuration.items[i].id === +item_id) {
var size = configuration.items[i].size;
// size is an object like '{width: "50px", height: "auto"}'
if (parseInt(size.width ) > 0) {width = parseInt(size.width);}
if (parseInt(size.height) > 0) {height = parseInt(size.height);}
break;
}
}
// Update the user's item state to use centered relative coordinates
var left_px = parseFloat(item.left) - 220; // 220 px for the items container that used to be on the left
var top_px = parseFloat(item.top);
item.x_percent = (left_px + width/2) / bg_image_width * 100;
item.y_percent = (top_px + height/2) / bg_image_height * 100;
delete item.left;
delete item.top;
delete item.absolute;
}
});
var migrateState = function() {
// JS migrations were squashed down to "do nothing", but decided to keep the method
// to give a hint to future developers that migrations can be applied in JS
};
/**
......@@ -1211,12 +1171,12 @@ function DragAndDropBlock(runtime, element, configuration) {
var markItemZoneAlign = function() {
var zone_alignments = {};
configuration.zones.forEach(function(zone) {
if (!zone.align) zone.align = 'none';
if (!zone.align) zone.align = DEFAULT_ZONE_ALIGN;
zone_alignments[zone.uid] = zone.align;
});
Object.keys(state.items).forEach(function(item_id) {
var item = state.items[item_id];
item.zone_align = zone_alignments[item.zone] || 'none';
item.zone_align = zone_alignments[item.zone] || DEFAULT_ZONE_ALIGN;
});
};
......
......@@ -166,14 +166,10 @@ function DragAndDropEditBlock(runtime, element, params) {
$(this).addClass('hidden');
$('.save-button', element).parent()
.removeClass('hidden')
.one('click', function submitForm(e) {
.on('click', function submitForm(e) {
// $itemTab -> submit
e.preventDefault();
if (!self.validate()) {
$(e.target).one('click', submitForm);
return
}
_fn.build.form.submit();
});
});
......@@ -190,7 +186,7 @@ function DragAndDropEditBlock(runtime, element, params) {
})
.on('click', '.remove-zone', _fn.build.form.zone.remove)
.on('input', '.zone-row input', _fn.build.form.zone.changedInputHandler)
.on('change', '.align-select', _fn.build.form.zone.changedInputHandler)
.on('change', '.zone-align-select', _fn.build.form.zone.changedInputHandler)
.on('click', '.target-image-form button', function(e) {
var new_img_url = $.trim($('.target-image-form .background-url', element).val());
if (new_img_url) {
......@@ -516,19 +512,21 @@ function DragAndDropEditBlock(runtime, element, params) {
'show_problem_header': $element.find('.show-problem-header').is(':checked'),
'item_background_color': $element.find('.item-background-color').val(),
'item_text_color': $element.find('.item-text-color').val(),
'max_items_per_zone': $element.find('.max-items-per-zone').val(),
'data': _fn.data,
};
$('.xblock-editor-error-message', element).html();
$('.xblock-editor-error-message', element).css('display', 'none');
var handlerUrl = runtime.handlerUrl(element, 'studio_submit');
runtime.notify('save', {state: 'start', message: gettext("Saving")});
$.post(handlerUrl, JSON.stringify(data), 'json').done(function(response) {
if (response.result === 'success') {
window.location.reload(false);
runtime.notify('save', {state: 'end'});
} else {
$('.xblock-editor-error-message', element)
.html(gettext('Error: ') + response.message);
$('.xblock-editor-error-message', element).css('display', 'block');
var message = response.messages.join(", ");
runtime.notify('error', {
'title': window.gettext("There was an error with your form."),
'message': message
});
}
});
}
......
......@@ -174,6 +174,15 @@
<div id="item-text-color-description-{{id_suffix}}" class="form-help">
{% trans fields.item_text_color.help %}
</div>
<label class="h4">
<span>{% trans fields.max_items_per_zone.display_name %}</span>
<input type="number" min="1" step="1" class="max-items-per-zone"
value="{{ self.max_items_per_zone|unlocalize }}"
aria-describedby="max-items-per-zone-description-{{id_suffix}}">
</label>
<div id="max-items-per-zone-description-{{id_suffix}}" class="form-help">
{% trans fields.max_items_per_zone.help %}
</div>
</form>
</section>
<section class="tab-content">
......@@ -189,8 +198,7 @@
</section>
<div class="xblock-actions">
<span class="xblock-editor-error-message"></span>
<ul>
<ul class="action-buttons">
<li class="action-item">
<a href="#" class="button action-primary continue-button">{% trans "Continue" %}</a>
</li>
......
......@@ -64,10 +64,6 @@
<span>{{i18n "Alignment"}}</span>
<select class="zone-align-select"
aria-describedby="zone-align-description-{{zone.uid}}-{{id_suffix}}">
<option value=""
{{#ifeq zone.align ""}}selected{{/ifeq}}>
{{i18n "none"}}
</option>
<option value="left"
{{#ifeq zone.align "left"}}selected{{/ifeq}}>
{{i18n "left"}}
......@@ -83,7 +79,7 @@
</select>
</label>
<div id="zone-align-description-{{zone.uid}}-{{id_suffix}}" class="form-help">
{{i18n "Align dropped items to the left, center, or right. Default is no alignment (items stay exactly where the user drops them)."}}
{{i18n "Align dropped items to the left, center, or right."}}
</div>
</div>
</fieldset>
......
......@@ -90,6 +90,10 @@ msgid "I don't belong anywhere"
msgstr ""
#: drag_and_drop_v2.py
#: msgid "This setting limits the number of items that can be dropped into a single zone."
#: msgstr ""
#: drag_and_drop_v2.py
#: templates/html/js_templates.html
msgid "Title"
msgstr ""
......@@ -205,10 +209,6 @@ msgid "Indicates whether a learner has completed the problem at least once"
msgstr ""
#: drag_and_drop_v2.py
msgid "Keeps maximum achieved score by student"
msgstr ""
#: drag_and_drop_v2.py
msgid "do_attempt handler should only be called for assessment mode"
msgstr ""
......@@ -224,6 +224,19 @@ msgstr ""
msgid "Remove zone"
msgstr ""
#: drag_and_drop_v2.py
msgid "Keeps maximum score achieved by student"
msgstr ""
#: drag_and_drop_v2.py
msgid "Failed to parse \"Maximum items per zone\""
msgstr ""
#: drag_and_drop_v2.py
msgid ""
"\"Maximum items per zone\" should be positive integer, got {max_items_per_zone}"
msgstr ""
#: templates/html/js_templates.html
msgid "Text"
msgstr ""
......@@ -265,9 +278,7 @@ msgid "right"
msgstr ""
#: templates/html/js_templates.html
msgid ""
"Align dropped items to the left, center, or right. Default is no alignment "
"(items stay exactly where the user drops them)."
msgid "Align dropped items to the left, center, or right."
msgstr ""
#: templates/html/js_templates.html
......@@ -500,10 +511,6 @@ msgstr ""
msgid "None"
msgstr ""
#: public/js/drag_and_drop_edit.js
msgid "Error: "
msgstr ""
#: utils.py:18
msgid "Final attempt was used, highest score is {score}"
msgstr ""
......
......@@ -118,6 +118,10 @@ msgid "Title"
msgstr "Tïtlé Ⱡ'σяєм ιρѕ#"
#: drag_and_drop_v2.py
#: msgid "This setting limits the number of items that can be dropped into a single zone."
#: msgstr "Thïs séttïng lïmïts thé nümßér öf ïtéms thät çän ßé dröppéd ïntö ä sïnglé zöné."
#: drag_and_drop_v2.py
msgid ""
"The title of the drag and drop problem. The title is displayed to learners."
msgstr ""
......@@ -194,7 +198,7 @@ msgstr ""
#: drag_and_drop_v2.py
msgid "Maximum score"
msgstr Mäxïmüm sçöré Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#
msgstr "Mäxïmüm sçöré Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
#: drag_and_drop_v2.py
msgid "The maximum score the learner can receive for the problem."
......@@ -260,10 +264,6 @@ msgstr ""
"ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #"
#: drag_and_drop_v2.py
msgid "Keeps maximum achieved score by student"
msgstr "Kééps mäxïmüm äçhïévéd sçöré ßý stüdént Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя#"
#: drag_and_drop_v2.py
msgid "do_attempt handler should only be called for assessment mode"
msgstr "dö_ättémpt händlér shöüld önlý ßé çälléd för ässéssmént mödé Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#"
......@@ -279,6 +279,19 @@ msgstr "Ûnknöwn DnDv2 mödé {mode} - çöürsé ïs mïsçönfïgüréd Ⱡ'
msgid "Remove zone"
msgstr "Rémövé zöné Ⱡ'σяєм ιρѕυм ∂σłσя #"
#: drag_and_drop_v2.py
msgid "Keeps maximum score achieved by student"
msgstr "Kééps mäxïmüm sçöré äçhïévéd ßý stüdént Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя#"
#: drag_and_drop_v2.py
msgid "Failed to parse \"Maximum items per zone\""
msgstr "Fäïléd tö pärsé \"Mäxïmüm ïtéms pér zöné\" Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυ#"
#: drag_and_drop_v2.py
msgid ""
"\"Maximum items per zone\" should be positive integer, got {max_items_per_zone}"
msgstr "\"Mäxïmüm ïtéms pér zöné\" shöüld ßé pösïtïvé ïntégér, göt {max_items_per_zone} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#"
#: templates/html/js_templates.html
msgid "Text"
msgstr "Téxt Ⱡ'σяєм ι#"
......@@ -322,12 +335,8 @@ msgid "right"
msgstr "rïght Ⱡ'σяєм ιρѕ#"
#: templates/html/js_templates.html
msgid ""
"Align dropped items to the left, center, or right. Default is no alignment "
"(items stay exactly where the user drops them)."
msgstr ""
"Àlïgn dröppéd ïtéms tö thé léft, çéntér, ör rïght. Défäült ïs nö älïgnmént "
"(ïtéms stäý éxäçtlý whéré thé üsér dröps thém). Ⱡ'σяєм ιρ#"
msgid "Align dropped items to the left, center, or right."
msgstr "Àlïgn dröppéd ïtéms tö thé léft, çéntér, ör rïght. Ⱡ'σяєм ιρ#"
#: templates/html/js_templates.html
msgid "Remove item"
......@@ -590,11 +599,6 @@ msgstr ""
msgid "None"
msgstr "Nöné Ⱡ'σяєм ι#"
#: public/js/drag_and_drop_edit.js
msgid "Error: "
msgstr "Érrör: Ⱡ'σяєм ιρѕυм #"
#: utils.py:18
msgid "Fïnäl ättémpt wäs üséd, hïghést sçöré ïs {score} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #"
msgstr ""
......
# -*- coding: utf-8 -*-
""" Drag and Drop v2 XBlock - Utils """
import copy
from collections import namedtuple
......@@ -82,3 +83,200 @@ ItemStats = namedtuple( # pylint: disable=invalid-name
'ItemStats',
["required", "placed", "correctly_placed", "decoy", "decoy_in_bank"]
)
class Constants(object):
"""
Namespace class for various constants
"""
ALLOWED_ZONE_ALIGNMENTS = ['left', 'right', 'center']
DEFAULT_ZONE_ALIGNMENT = 'center'
STANDARD_MODE = "standard"
ASSESSMENT_MODE = "assessment"
class StateMigration(object):
"""
Helper class to apply zone data and item state migrations
"""
def __init__(self, block):
self._block = block
@staticmethod
def _apply_migration(obj_id, obj, migrations):
"""
Applies migrations sequentially to a copy of an `obj`, to avoid updating actual data
"""
tmp = copy.deepcopy(obj)
for method in migrations:
tmp = method(obj_id, tmp)
return tmp
def apply_zone_migrations(self, zone):
"""
Applies zone migrations
"""
migrations = (self._zone_v1_to_v2, self._zone_v2_to_v2p1)
zone_id = zone.get('uid', zone.get('id'))
return self._apply_migration(zone_id, zone, migrations)
def apply_item_state_migrations(self, item_id, item_state):
"""
Applies item_state migrations
"""
migrations = (self._item_state_v1_to_v1p5, self._item_state_v1p5_to_v2, self._item_state_v2_to_v2p1)
return self._apply_migration(item_id, item_state, migrations)
@classmethod
def _zone_v1_to_v2(cls, unused_zone_id, zone):
"""
Migrates zone data from v1.0 format to v2.0 format.
Changes:
* v1 used zone "title" as UID, while v2 zone has dedicated "uid" property
* "id" and "index" properties are no longer used
In: {'id': 1, 'index': 2, 'title': "Zone", ...}
Out: {'uid': "Zone", ...}
"""
if "uid" not in zone:
zone["uid"] = zone.get("title")
zone.pop("id", None)
zone.pop("index", None)
return zone
@classmethod
def _zone_v2_to_v2p1(cls, unused_zone_id, zone):
"""
Migrates zone data from v2.0 to v2.1
Changes:
* Removed "none" zone alignment; default align is "center"
In: {
'uid': "Zone", "align": "none",
"x_percent": "10%", "y_percent": "10%", "width_percent": "10%", "height_percent": "10%"
}
Out: {
'uid': "Zone", "align": "center",
"x_percent": "10%", "y_percent": "10%", "width_percent": "10%", "height_percent": "10%"
}
"""
if zone.get('align', None) not in Constants.ALLOWED_ZONE_ALIGNMENTS:
zone['align'] = Constants.DEFAULT_ZONE_ALIGNMENT
return zone
@classmethod
def _item_state_v1_to_v1p5(cls, unused_item_id, item):
"""
Migrates item_state from v1.0 to v1.5
Changes:
* Item state is now a dict instead of tuple
In: ('100px', '120px')
Out: {'top': '100px', 'left': '120px'}
"""
if isinstance(item, dict):
return item
else:
return {'top': item[0], 'left': item[1]}
@classmethod
def _item_state_v1p5_to_v2(cls, unused_item_id, item):
"""
Migrates item_state from v1.5 to v2.0
Changes:
* Item placement attributes switched from absolute (left-top) to relative (x_percent-y_percent) units
In: {'zone': 'Zone", 'correct': True, 'top': '100px', 'left': '120px'}
Out: {'zone': 'Zone", 'correct': True, 'top': '100px', 'left': '120px'}
"""
# Conversion can't be made as parent dimensions are unknown to python - converted in JS
# Since 2.1 JS this conversion became unnecesary, so it was removed from JS code
return item
def _item_state_v2_to_v2p1(self, item_id, item):
"""
Migrates item_state from v2.0 to v2.1
* Single item can correspond to multiple zones - "zone" key is added to each item
* Assessment mode - "correct" key is added to each item
* Removed "no zone align" option; only automatic alignment is now allowed - removes attributes related to
"absolute" placement of an item (relative to background image, as opposed to the zone)
"""
self._multiple_zones_migration(item_id, item)
self._assessment_mode_migration(item)
self._automatic_alignment_migration(item)
return item
def _multiple_zones_migration(self, item_id, item):
"""
Changes:
* Adds "zone" attribute
In: {'item_id': 0}
Out: {'zone': 'Zone", 'item_id": 0}
In: {'item_id': 1}
Out: {'zone': 'unknown", 'item_id": 1}
"""
if item.get('zone') is None:
valid_zones = self._block.get_item_zones(int(item_id))
if valid_zones:
# If we get to this point, then the item was placed prior to support for
# multiple correct zones being added. As a result, it can only be correct
# on a single zone, and so we can trust that the item was placed on the
# zone with index 0.
item['zone'] = valid_zones[0]
else:
item['zone'] = 'unknown'
@classmethod
def _assessment_mode_migration(cls, item):
"""
Changes:
* Adds "correct" attribute if missing
In: {'item_id': 0}
Out: {'item_id': 'correct': True}
In: {'item_id': 0, 'correct': True}
Out: {'item_id': 'correct': True}
In: {'item_id': 0, 'correct': False}
Out: {'item_id': 'correct': False}
"""
# If correctness information is missing
# (because problem was completed before assessment mode was implemented),
# assume the item is in correct zone (in standard mode, only items placed
# into correct zone are stored in item state).
if item.get('correct') is None:
item['correct'] = True
@classmethod
def _automatic_alignment_migration(cls, item):
"""
Changes:
* Removed old "absolute" placement attributes
* Removed "none" zone alignment, making "x_percent" and "y_percent" attributes obsolete
In: {'zone': 'Zone", 'correct': True, 'top': '100px', 'left': '120px', 'absolute': true}
Out: {'zone': 'Zone", 'correct': True}
In: {'zone': 'Zone", 'correct': True, 'x_percent': '90%', 'y_percent': '20%'}
Out: {'zone': 'Zone", 'correct': True}
"""
attributes_to_remove = ['x_percent', 'y_percent', 'left', 'top', 'absolute']
for attribute in attributes_to_remove:
item.pop(attribute, None)
return item
......@@ -17,7 +17,7 @@ disable=
min-similarity-lines=4
[OPTIONS]
good-names=_,__,log,loader
good-names=_,__,logger,loader
method-rgx=_?[a-z_][a-z0-9_]{2,40}$
function-rgx=_?[a-z_][a-z0-9_]{2,40}$
method-name-hint=_?[a-z_][a-z0-9_]{2,40}$
......
# -*- coding: utf-8 -*-
#
# Imports ###########################################################
import json
from xml.sax.saxutils import escape
from selenium.webdriver import ActionChains
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import WebDriverWait
from bok_choy.promise import EmptyPromise
......@@ -9,6 +14,13 @@ from xblockutils.resources import ResourceLoader
from xblockutils.base_test import SeleniumBaseTest
from drag_and_drop_v2.utils import Constants
from drag_and_drop_v2.default_data import (
DEFAULT_DATA, START_FEEDBACK, FINISH_FEEDBACK,
TOP_ZONE_ID, TOP_ZONE_TITLE, MIDDLE_ZONE_ID, MIDDLE_ZONE_TITLE, BOTTOM_ZONE_ID, BOTTOM_ZONE_TITLE,
ITEM_CORRECT_FEEDBACK, ITEM_INCORRECT_FEEDBACK, ITEM_ANY_ZONE_FEEDBACK, ITEM_NO_ZONE_FEEDBACK,
)
# Globals ###########################################################
......@@ -17,6 +29,14 @@ loader = ResourceLoader(__name__)
# Classes ###########################################################
class ItemDefinition(object):
def __init__(self, item_id, zone_ids, zone_title, feedback_positive, feedback_negative):
self.feedback_negative = feedback_negative
self.feedback_positive = feedback_positive
self.zone_ids = zone_ids
self.zone_title = zone_title
self.item_id = item_id
class BaseIntegrationTest(SeleniumBaseTest):
default_css_selector = 'section.themed-xblock.xblock--drag-and-drop'
......@@ -27,8 +47,14 @@ class BaseIntegrationTest(SeleniumBaseTest):
"'": "&apos;"
}
@staticmethod
def _make_scenario_xml(display_name, show_title, problem_text, completed=False, show_problem_header=True):
# pylint: disable=too-many-arguments
@classmethod
def _make_scenario_xml(
cls, display_name="Test DnDv2", show_title=True, problem_text="Question", completed=False,
show_problem_header=True, max_items_per_zone=0, data=None, mode=Constants.STANDARD_MODE
):
if not data:
data = json.dumps(DEFAULT_DATA)
return """
<vertical_demo>
<drag-and-drop-v2
......@@ -38,6 +64,9 @@ class BaseIntegrationTest(SeleniumBaseTest):
show_question_header='{show_problem_header}'
weight='1'
completed='{completed}'
max_items_per_zone='{max_items_per_zone}'
mode='{mode}'
data='{data}'
/>
</vertical_demo>
""".format(
......@@ -46,6 +75,9 @@ class BaseIntegrationTest(SeleniumBaseTest):
problem_text=escape(problem_text),
show_problem_header=show_problem_header,
completed=completed,
max_items_per_zone=max_items_per_zone,
mode=mode,
data=escape(data, cls._additional_escapes)
)
def _get_custom_scenario_xml(self, filename):
......@@ -137,3 +169,263 @@ class BaseIntegrationTest(SeleniumBaseTest):
return self.browser.execute_script("return typeof(jQuery)!='undefined' && jQuery.active==0")
EmptyPromise(is_ajax_finished, "Finished waiting for ajax requests.", timeout=timeout).fulfill()
class DefaultDataTestMixin(object):
"""
Provides a test scenario with default options.
"""
PAGE_TITLE = 'Drag and Drop v2'
PAGE_ID = 'drag_and_drop_v2'
items_map = {
0: ItemDefinition(
0, [TOP_ZONE_ID], TOP_ZONE_TITLE,
ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK
),
1: ItemDefinition(
1, [MIDDLE_ZONE_ID], MIDDLE_ZONE_TITLE,
ITEM_CORRECT_FEEDBACK.format(zone=MIDDLE_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK
),
2: ItemDefinition(
2, [BOTTOM_ZONE_ID], BOTTOM_ZONE_TITLE,
ITEM_CORRECT_FEEDBACK.format(zone=BOTTOM_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK
),
3: ItemDefinition(
3, [MIDDLE_ZONE_ID, TOP_ZONE_ID, BOTTOM_ZONE_ID], MIDDLE_ZONE_TITLE,
ITEM_ANY_ZONE_FEEDBACK, ITEM_INCORRECT_FEEDBACK
),
4: ItemDefinition(4, [], None, "", ITEM_NO_ZONE_FEEDBACK),
}
all_zones = [
(TOP_ZONE_ID, TOP_ZONE_TITLE),
(MIDDLE_ZONE_ID, MIDDLE_ZONE_TITLE),
(BOTTOM_ZONE_ID, BOTTOM_ZONE_TITLE)
]
feedback = {
"intro": START_FEEDBACK,
"final": FINISH_FEEDBACK,
}
def _get_scenario_xml(self): # pylint: disable=no-self-use
return "<vertical_demo><drag-and-drop-v2/></vertical_demo>"
class InteractionTestBase(object):
@classmethod
def _get_items_with_zone(cls, 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):
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):
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()
if definition.zone_ids and definition.zone_ids[0] is zone_id}
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)
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]
def _get_unplaced_item_by_value(self, item_value):
items_container = self._get_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-uid='{zone_id}']".format(zone_id=zone_id))[0]
def _get_dialog_components(self, dialog): # pylint: disable=no-self-use
dialog_modal_overlay = dialog.find_element_by_css_selector('.modal-window-overlay')
dialog_modal = dialog.find_element_by_css_selector('.modal-window')
return dialog_modal_overlay, dialog_modal
def _get_dialog_dismiss_button(self, dialog_modal): # pylint: disable=no-self-use
return dialog_modal.find_element_by_css_selector('.modal-dismiss-button')
def _get_item_bank(self):
return self._page.find_element_by_css_selector('.item-bank')
def _get_zone_position(self, zone_id):
return self.browser.execute_script(
'return $("div[data-uid=\'{zone_id}\']").prevAll(".zone").length'.format(zone_id=zone_id)
)
def _get_draggable_property(self, item_value):
"""
Returns the value of the 'draggable' property of item.
Selenium has the element.get_attribute method that looks up properties and attributes,
but for some reason it *always* returns "true" for the 'draggable' property, event though
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)
return self.browser.execute_script(script)
def assertDraggable(self, item_value):
self.assertTrue(self._get_draggable_property(item_value))
def assertNotDraggable(self, item_value):
self.assertFalse(self._get_draggable_property(item_value))
@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):
"""
Place item with ID of item_value into zone with ID of zone_id.
zone_id=None means place item back to the item bank.
action_key=None means simulate mouse drag/drop instead of placing the item with keyboard.
"""
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)
self.wait_for_ajax()
def drag_item_to_zone(self, item_value, zone_id):
"""
Drag item to desired zone using mouse interaction.
zone_id=None means drag item back to the item bank.
"""
element = self._get_item_by_value(item_value)
if zone_id is None:
target = self._get_item_bank()
else:
target = self._get_zone_by_id(zone_id)
action_chains = ActionChains(self.browser)
action_chains.drag_and_drop(element, target).perform()
def move_item_to_zone(self, item_value, zone_id, action_key):
"""
Place item to descired zone using keybard interaction.
zone_id=None means place item back into the item bank.
"""
# Focus on the item, then press the action key:
item = self._get_item_by_value(item_value)
item.send_keys("")
item.send_keys(action_key)
# Focus is on first *zone* now
self.assert_grabbed_item(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()
# When switching focus between zones in keyboard placement mode,
# the item bank always gets focused last (after all regular zones),
# so we have to press Tab once for every regular zone to move focus to the item bank.
tab_press_count = len(self.all_zones)
else:
zone = self._get_zone_by_id(zone_id)
# The number of times we have to press Tab to focus the desired zone equals the zero-based
# position of the zone (zero presses for first zone, one press for second zone, etc).
tab_press_count = self._get_zone_position(zone_id)
for _ in range(tab_press_count):
ActionChains(self.browser).send_keys(Keys.TAB).perform()
zone.send_keys(action_key)
def assert_grabbed_item(self, item):
self.assertEqual(item.get_attribute('aria-grabbed'), 'true')
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)
self.wait_until_ondrop_xhr_finished(item)
item_content = item.find_element_by_css_selector('.item-content')
self.wait_until_visible(item_content)
item_description = item.find_element_by_css_selector('.sr')
self.wait_until_visible(item_description)
item_description_id = '-item-{}-description'.format(item_value)
self.assertEqual(item.get_attribute('aria-grabbed'), 'false')
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.assertDraggable(item_value)
self.assertEqual(item.get_attribute('class'), 'option')
self.assertEqual(item.get_attribute('tabindex'), '0')
self.assertEqual(item_description.text, 'Placed in: {}'.format(zone_title))
else:
self.assertNotDraggable(item_value)
self.assertEqual(item.get_attribute('class'), 'option fade')
self.assertIsNone(item.get_attribute('tabindex'))
self.assertEqual(item_description.text, 'Correctly placed in: {}'.format(zone_title))
def assert_reverted_item(self, item_value):
item = self._get_item_by_value(item_value)
self.wait_until_visible(item)
self.wait_until_ondrop_xhr_finished(item)
item_content = item.find_element_by_css_selector('.item-content')
self.assertDraggable(item_value)
self.assertEqual(item.get_attribute('class'), 'option')
self.assertEqual(item.get_attribute('tabindex'), '0')
self.assertEqual(item.get_attribute('aria-grabbed'), 'false')
item_description_id = '-item-{}-description'.format(item_value)
self.assertEqual(item_content.get_attribute('aria-describedby'), item_description_id)
describedby_text = (u'Press "Enter", "Space", "Ctrl-m", or "⌘-m" on an item to select it for dropping, '
'then navigate to the zone you want to drop it on.')
self.assertEqual(item.find_element_by_css_selector('.sr').text, describedby_text)
def 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('aria-grabbed'), 'false')
if assessment_mode:
self.assertDraggable(item_key)
self.assertEqual(item.get_attribute('class'), 'option')
else:
self.assertNotDraggable(item_key)
self.assertEqual(item.get_attribute('class'), 'option fade')
def _switch_to_block(self, idx):
""" Only needed if there are multiple blocks on the page. """
self._page = self.browser.find_elements_by_css_selector(self.default_css_selector)[idx]
self.scroll_down(0)
from ddt import ddt, data, unpack
from mock import Mock, patch
from workbench.runtime import WorkbenchRuntime
from drag_and_drop_v2.default_data import TOP_ZONE_TITLE, TOP_ZONE_ID, ITEM_CORRECT_FEEDBACK
from .test_base import BaseIntegrationTest, DefaultDataTestMixin
from .test_interaction import ParameterizedTestsMixin
from tests.integration.test_base import InteractionTestBase
@ddt
class EventsFiredTest(DefaultDataTestMixin, ParameterizedTestsMixin, InteractionTestBase, BaseIntegrationTest):
"""
Tests that the analytics events are fired and in the proper order.
"""
# These events must be fired in this order.
scenarios = (
{
'name': 'edx.drag_and_drop_v2.loaded',
'data': {},
},
{
'name': 'edx.drag_and_drop_v2.item.picked_up',
'data': {'item_id': 0},
},
{
'name': 'grade',
'data': {'max_value': 1, 'value': (2.0 / 5)},
},
{
'name': 'edx.drag_and_drop_v2.item.dropped',
'data': {
'is_correct': True,
'item_id': 0,
'location': TOP_ZONE_TITLE,
'location_id': TOP_ZONE_ID,
},
},
{
'name': 'edx.drag_and_drop_v2.feedback.opened',
'data': {
'content': ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE),
'truncated': False,
},
},
{
'name': 'edx.drag_and_drop_v2.feedback.closed',
'data': {
'manually': False,
'content': ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE),
'truncated': False,
},
},
)
def setUp(self):
mock = Mock()
context = patch.object(WorkbenchRuntime, 'publish', mock)
context.start()
self.addCleanup(context.stop)
self.publish = mock
super(EventsFiredTest, self).setUp()
def _get_scenario_xml(self): # pylint: disable=no-self-use
return "<vertical_demo><drag-and-drop-v2/></vertical_demo>"
@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)
dummy, name, published_data = self.publish.call_args_list[index][0]
self.assertEqual(name, event['name'])
self.assertEqual(
published_data, event['data']
)
......@@ -4,25 +4,18 @@
# Imports ###########################################################
from ddt import ddt, data, unpack
from mock import Mock, patch
from selenium.common.exceptions import WebDriverException
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
from drag_and_drop_v2.default_data import (
TOP_ZONE_ID, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID,
TOP_ZONE_TITLE, MIDDLE_ZONE_TITLE, BOTTOM_ZONE_TITLE,
ITEM_CORRECT_FEEDBACK, ITEM_INCORRECT_FEEDBACK, ITEM_NO_ZONE_FEEDBACK,
ITEM_ANY_ZONE_FEEDBACK, START_FEEDBACK, FINISH_FEEDBACK
from tests.integration.test_base import (
DefaultDataTestMixin, InteractionTestBase, ItemDefinition
)
from .test_base import BaseIntegrationTest
# Globals ###########################################################
loader = ResourceLoader(__name__)
......@@ -30,227 +23,7 @@ loader = ResourceLoader(__name__)
# Classes ###########################################################
class ItemDefinition(object):
def __init__(self, item_id, zone_ids, zone_title, feedback_positive, feedback_negative):
self.feedback_negative = feedback_negative
self.feedback_positive = feedback_positive
self.zone_ids = zone_ids
self.zone_title = zone_title
self.item_id = item_id
class InteractionTestBase(object):
@classmethod
def _get_items_with_zone(cls, 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):
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):
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()
if definition.zone_ids and definition.zone_ids[0] is zone_id}
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)
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]
def _get_unplaced_item_by_value(self, item_value):
items_container = self._get_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-uid='{zone_id}']".format(zone_id=zone_id))[0]
def _get_dialog_components(self, dialog): # pylint: disable=no-self-use
dialog_modal_overlay = dialog.find_element_by_css_selector('.modal-window-overlay')
dialog_modal = dialog.find_element_by_css_selector('.modal-window')
return dialog_modal_overlay, dialog_modal
def _get_dialog_dismiss_button(self, dialog_modal): # pylint: disable=no-self-use
return dialog_modal.find_element_by_css_selector('.modal-dismiss-button')
def _get_item_bank(self):
return self._page.find_element_by_css_selector('.item-bank')
def _get_zone_position(self, zone_id):
return self.browser.execute_script(
'return $("div[data-uid=\'{zone_id}\']").prevAll(".zone").length'.format(zone_id=zone_id)
)
def _get_draggable_property(self, item_value):
"""
Returns the value of the 'draggable' property of item.
Selenium has the element.get_attribute method that looks up properties and attributes,
but for some reason it *always* returns "true" for the 'draggable' property, event though
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)
return self.browser.execute_script(script)
def assertDraggable(self, item_value):
self.assertTrue(self._get_draggable_property(item_value))
def assertNotDraggable(self, item_value):
self.assertFalse(self._get_draggable_property(item_value))
@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):
"""
Place item with ID of item_value into zone with ID of zone_id.
zone_id=None means place item back to the item bank.
action_key=None means simulate mouse drag/drop instead of placing the item with keyboard.
"""
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)
self.wait_for_ajax()
def drag_item_to_zone(self, item_value, zone_id):
"""
Drag item to desired zone using mouse interaction.
zone_id=None means drag item back to the item bank.
"""
element = self._get_item_by_value(item_value)
if zone_id is None:
target = self._get_item_bank()
else:
target = self._get_zone_by_id(zone_id)
action_chains = ActionChains(self.browser)
action_chains.drag_and_drop(element, target).perform()
def move_item_to_zone(self, item_value, zone_id, action_key):
"""
Place item to descired zone using keybard interaction.
zone_id=None means place item back into the item bank.
"""
# Focus on the item, then press the action key:
item = self._get_item_by_value(item_value)
item.send_keys("")
item.send_keys(action_key)
# Focus is on first *zone* now
self.assert_grabbed_item(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()
# When switching focus between zones in keyboard placement mode,
# the item bank always gets focused last (after all regular zones),
# so we have to press Tab once for every regular zone to move focus to the item bank.
tab_press_count = len(self.all_zones)
else:
zone = self._get_zone_by_id(zone_id)
# The number of times we have to press Tab to focus the desired zone equals the zero-based
# position of the zone (zero presses for first zone, one press for second zone, etc).
tab_press_count = self._get_zone_position(zone_id)
for _ in range(tab_press_count):
ActionChains(self.browser).send_keys(Keys.TAB).perform()
zone.send_keys(action_key)
def assert_grabbed_item(self, item):
self.assertEqual(item.get_attribute('aria-grabbed'), 'true')
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)
self.wait_until_ondrop_xhr_finished(item)
item_content = item.find_element_by_css_selector('.item-content')
self.wait_until_visible(item_content)
item_description = item.find_element_by_css_selector('.sr')
self.wait_until_visible(item_description)
item_description_id = '-item-{}-description'.format(item_value)
self.assertEqual(item.get_attribute('aria-grabbed'), 'false')
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.assertDraggable(item_value)
self.assertEqual(item.get_attribute('class'), 'option')
self.assertEqual(item.get_attribute('tabindex'), '0')
self.assertEqual(item_description.text, 'Placed in: {}'.format(zone_title))
else:
self.assertNotDraggable(item_value)
self.assertEqual(item.get_attribute('class'), 'option fade')
self.assertIsNone(item.get_attribute('tabindex'))
self.assertEqual(item_description.text, 'Correctly placed in: {}'.format(zone_title))
def assert_reverted_item(self, item_value):
item = self._get_item_by_value(item_value)
self.wait_until_visible(item)
self.wait_until_ondrop_xhr_finished(item)
item_content = item.find_element_by_css_selector('.item-content')
self.assertDraggable(item_value)
self.assertEqual(item.get_attribute('class'), 'option')
self.assertEqual(item.get_attribute('tabindex'), '0')
self.assertEqual(item.get_attribute('aria-grabbed'), 'false')
item_description_id = '-item-{}-description'.format(item_value)
self.assertEqual(item_content.get_attribute('aria-describedby'), item_description_id)
describedby_text = (u'Press "Enter", "Space", "Ctrl-m", or "⌘-m" on an item to select it for dropping, '
'then navigate to the zone you want to drop it on.')
self.assertEqual(item.find_element_by_css_selector('.sr').text, describedby_text)
def 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('aria-grabbed'), 'false')
if assessment_mode:
self.assertDraggable(item_key)
self.assertEqual(item.get_attribute('class'), 'option')
else:
self.assertNotDraggable(item_key)
self.assertEqual(item.get_attribute('class'), 'option fade')
class ParameterizedTestsMixin(object):
def parameterized_item_positive_feedback_on_good_move(
self, items_map, scroll_down=100, action_key=None, assessment_mode=False
):
......@@ -429,56 +202,9 @@ class InteractionTestBase(object):
self.assertFalse(dialog_modal_overlay.is_displayed())
self.assertFalse(dialog_modal.is_displayed())
def _switch_to_block(self, idx):
""" Only needed if ther eare multiple blocks on the page. """
self._page = self.browser.find_elements_by_css_selector(self.default_css_selector)[idx]
self.scroll_down(0)
class DefaultDataTestMixin(object):
"""
Provides a test scenario with default options.
"""
PAGE_TITLE = 'Drag and Drop v2'
PAGE_ID = 'drag_and_drop_v2'
items_map = {
0: ItemDefinition(
0, [TOP_ZONE_ID], TOP_ZONE_TITLE,
ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK
),
1: ItemDefinition(
1, [MIDDLE_ZONE_ID], MIDDLE_ZONE_TITLE,
ITEM_CORRECT_FEEDBACK.format(zone=MIDDLE_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK
),
2: ItemDefinition(
2, [BOTTOM_ZONE_ID], BOTTOM_ZONE_TITLE,
ITEM_CORRECT_FEEDBACK.format(zone=BOTTOM_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK
),
3: ItemDefinition(
3, [MIDDLE_ZONE_ID, TOP_ZONE_ID, BOTTOM_ZONE_ID], MIDDLE_ZONE_TITLE,
ITEM_ANY_ZONE_FEEDBACK, ITEM_INCORRECT_FEEDBACK
),
4: ItemDefinition(4, [], None, "", ITEM_NO_ZONE_FEEDBACK),
}
all_zones = [
(TOP_ZONE_ID, TOP_ZONE_TITLE),
(MIDDLE_ZONE_ID, MIDDLE_ZONE_TITLE),
(BOTTOM_ZONE_ID, BOTTOM_ZONE_TITLE)
]
feedback = {
"intro": START_FEEDBACK,
"final": FINISH_FEEDBACK,
}
def _get_scenario_xml(self): # pylint: disable=no-self-use
return "<vertical_demo><drag-and-drop-v2/></vertical_demo>"
@ddt
class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest):
class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, ParameterizedTestsMixin, 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.
......@@ -571,73 +297,6 @@ class MultipleValidOptionsInteractionTest(DefaultDataTestMixin, InteractionTestB
return self._get_custom_scenario_xml("data/test_multiple_options_data.json")
@ddt
class EventsFiredTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest):
"""
Tests that the analytics events are fired and in the proper order.
"""
# These events must be fired in this order.
scenarios = (
{
'name': 'edx.drag_and_drop_v2.loaded',
'data': {},
},
{
'name': 'edx.drag_and_drop_v2.item.picked_up',
'data': {'item_id': 0},
},
{
'name': 'grade',
'data': {'max_value': 1, 'value': (2.0 / 5)},
},
{
'name': 'edx.drag_and_drop_v2.item.dropped',
'data': {
'is_correct': True,
'item_id': 0,
'location': TOP_ZONE_TITLE,
'location_id': TOP_ZONE_ID,
},
},
{
'name': 'edx.drag_and_drop_v2.feedback.opened',
'data': {
'content': ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE),
'truncated': False,
},
},
{
'name': 'edx.drag_and_drop_v2.feedback.closed',
'data': {
'manually': False,
'content': ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE),
'truncated': False,
},
},
)
def setUp(self):
mock = Mock()
context = patch.object(WorkbenchRuntime, 'publish', mock)
context.start()
self.addCleanup(context.stop)
self.publish = mock
super(EventsFiredTest, self).setUp()
def _get_scenario_xml(self): # pylint: disable=no-self-use
return "<vertical_demo><drag-and-drop-v2/></vertical_demo>"
@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)
dummy, name, published_data = self.publish.call_args_list[index][0]
self.assertEqual(name, event['name'])
self.assertEqual(
published_data, event['data']
)
class PreventSpaceBarScrollTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest):
""""
Test that browser default page down action is prevented when pressing the space bar while
......@@ -709,7 +368,7 @@ class CustomHtmlDataInteractionTest(StandardInteractionTest):
return self._get_custom_scenario_xml("data/test_html_data.json")
class MultipleBlocksDataInteraction(InteractionTestBase, BaseIntegrationTest):
class MultipleBlocksDataInteraction(ParameterizedTestsMixin, InteractionTestBase, BaseIntegrationTest):
PAGE_TITLE = 'Drag and Drop v2 Multiple Blocks'
PAGE_ID = 'drag_and_drop_v2_multi'
......@@ -821,35 +480,11 @@ class ZoneAlignInteractionTest(InteractionTestBase, BaseIntegrationTest):
self.assertEquals(self._get_style(zone_item_selector, 'left'), '0px')
self.assertEquals(self._get_style(zone_item_selector, 'top'), '0px')
# Center-aligned items are display block
if align == 'center':
self.assertEquals(self._get_style(zone_item_selector, 'display'), 'block')
# but other aligned items are just inline-block
else:
self.assertEquals(self._get_style(zone_item_selector, 'display'), 'inline-block')
def test_no_zone_align(self):
"""
Test items placed in a zone with no align setting.
Ensure that they are children of div.target, not the zone.
"""
zone_id = "Zone No Align"
self.place_item(0, zone_id)
zone_item_selector = "div[data-uid='{zone_id}'] .item-wrapper .option".format(zone_id=zone_id)
self.assertEquals(len(self._page.find_elements_by_css_selector(zone_item_selector)), 0)
target_item_selector = '.target > .option'
placed_items = self._page.find_elements_by_css_selector(target_item_selector)
self.assertEquals(len(placed_items), 1)
self.assertEquals(placed_items[0].get_attribute('data-value'), '0')
# Non-aligned items are absolute positioned, with top/bottom set to px
self.assertEquals(self._get_style(target_item_selector, 'position'), 'absolute')
self.assertRegexpMatches(self._get_style(target_item_selector, 'left'), r'^\d+(\.\d+)?px$')
self.assertRegexpMatches(self._get_style(target_item_selector, 'top'), r'^\d+(\.\d+)?px$')
self.assertEquals(self._get_style(zone_item_selector, 'display'), 'inline-block')
@data(
([3, 4, 5], "Zone Invalid Align", "start"),
([0, 1, 2], "Zone No Align", "center"),
([3, 4, 5], "Zone Invalid Align", "center"),
([6, 7, 8], "Zone Left Align", "left"),
([9, 10, 11], "Zone Right Align", "right"),
([12, 13, 14], "Zone Center Align", "center"),
......@@ -865,3 +500,60 @@ class ZoneAlignInteractionTest(InteractionTestBase, BaseIntegrationTest):
reset.click()
self.scroll_down(pixels=0)
self.wait_until_disabled(reset)
class TestMaxItemsPerZone(InteractionTestBase, BaseIntegrationTest):
"""
Tests for max items per dropzone feature
"""
PAGE_TITLE = 'Drag and Drop v2'
PAGE_ID = 'drag_and_drop_v2'
assessment_mode = False
def _get_scenario_xml(self):
scenario_data = loader.load_unicode("data/test_zone_align.json")
return self._make_scenario_xml(data=scenario_data, max_items_per_zone=2)
def test_item_returned_to_bank(self):
"""
Tests that an item is returned to bank if max items per zone reached
"""
zone_id = "Zone No Align"
self.place_item(0, zone_id)
self.place_item(1, zone_id)
# precondition check - max items placed into zone
self.assert_placed_item(0, zone_id, assessment_mode=self.assessment_mode)
self.assert_placed_item(1, zone_id, assessment_mode=self.assessment_mode)
self.place_item(2, zone_id)
self.assert_reverted_item(2)
feedback_popup = self._get_popup()
self.assertTrue(feedback_popup.is_displayed())
feedback_popup_content = self._get_popup_content()
self.assertEqual(
feedback_popup_content.get_attribute('innerHTML'),
"You cannot add any more items to this zone."
)
def test_item_returned_to_bank_after_refresh(self):
zone_id = "Zone Left Align"
self.place_item(6, zone_id)
self.place_item(7, zone_id)
# precondition check - max items placed into zone
self.assert_placed_item(6, zone_id, assessment_mode=self.assessment_mode)
self.assert_placed_item(7, zone_id, assessment_mode=self.assessment_mode)
self.place_item(8, zone_id)
self.assert_reverted_item(8)
self._page = self.go_to_page(self.PAGE_TITLE) # refresh the page
self.assert_placed_item(6, zone_id, assessment_mode=self.assessment_mode)
self.assert_placed_item(7, zone_id, assessment_mode=self.assessment_mode)
self.assert_reverted_item(8)
......@@ -13,9 +13,9 @@ from drag_and_drop_v2.default_data import (
TOP_ZONE_ID, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID,
TOP_ZONE_TITLE, START_FEEDBACK, FINISH_FEEDBACK
)
from drag_and_drop_v2.utils import FeedbackMessages
from drag_and_drop_v2.utils import FeedbackMessages, Constants
from .test_base import BaseIntegrationTest
from .test_interaction import InteractionTestBase, DefaultDataTestMixin
from .test_interaction import InteractionTestBase, DefaultDataTestMixin, ParameterizedTestsMixin, TestMaxItemsPerZone
# Globals ###########################################################
......@@ -33,8 +33,8 @@ class DefaultAssessmentDataTestMixin(DefaultDataTestMixin):
def _get_scenario_xml(self): # pylint: disable=no-self-use
return """
<vertical_demo><drag-and-drop-v2 mode='assessment' max_attempts='{max_attempts}'/></vertical_demo>
""".format(max_attempts=self.MAX_ATTEMPTS)
<vertical_demo><drag-and-drop-v2 mode='{mode}' max_attempts='{max_attempts}'/></vertical_demo>
""".format(mode=Constants.ASSESSMENT_MODE, max_attempts=self.MAX_ATTEMPTS)
class AssessmentTestMixin(object):
......@@ -57,7 +57,8 @@ class AssessmentTestMixin(object):
@ddt
class AssessmentInteractionTest(
DefaultAssessmentDataTestMixin, AssessmentTestMixin, InteractionTestBase, BaseIntegrationTest
DefaultAssessmentDataTestMixin, AssessmentTestMixin, ParameterizedTestsMixin,
InteractionTestBase, BaseIntegrationTest
):
"""
Testing interactions with Drag and Drop XBlock against default data in assessment mode.
......@@ -217,3 +218,28 @@ class AssessmentInteractionTest(
published_grade = next((event[0][2] for event in events if event[0][1] == 'grade'))
expected_grade = {'max_value': 1, 'value': (1.0 / 5.0)}
self.assertEqual(published_grade, expected_grade)
class TestMaxItemsPerZoneAssessment(TestMaxItemsPerZone):
assessment_mode = True
def _get_scenario_xml(self):
scenario_data = loader.load_unicode("data/test_zone_align.json")
return self._make_scenario_xml(data=scenario_data, max_items_per_zone=2, mode=Constants.ASSESSMENT_MODE)
def test_drop_item_to_same_zone_does_not_show_popup(self):
zone_id = "Zone Left Align"
self.place_item(6, zone_id)
self.place_item(7, zone_id)
popup = self._get_popup()
# precondition check - max items placed into zone
self.assert_placed_item(6, zone_id, assessment_mode=self.assessment_mode)
self.assert_placed_item(7, zone_id, assessment_mode=self.assessment_mode)
self.place_item(6, zone_id, Keys.RETURN)
self.assertFalse(popup.is_displayed())
self.place_item(7, zone_id, Keys.RETURN)
self.assertFalse(popup.is_displayed())
......@@ -189,7 +189,7 @@ class TestDragAndDropRender(BaseIntegrationTest):
self.assertEqual(zone.get_attribute('dropzone'), 'move')
self.assertEqual(zone.get_attribute('aria-dropeffect'), 'move')
self.assertEqual(zone.get_attribute('data-uid'), 'Zone {}'.format(zone_number))
self.assertEqual(zone.get_attribute('data-zone_align'), 'none')
self.assertEqual(zone.get_attribute('data-zone_align'), 'center')
self.assertIn('ui-droppable', self.get_element_classes(zone))
zone_box_percentages = box_percentages[index]
self._assert_box_percentages( # pylint: disable=star-args
......@@ -293,8 +293,8 @@ class TestDragAndDropRenderZoneAlign(BaseIntegrationTest):
def test_zone_align(self):
expected_alignments = {
"#-Zone_No_Align": "start",
"#-Zone_Invalid_Align": "start",
"#-Zone_No_Align": "center",
"#-Zone_Invalid_Align": "center",
"#-Zone_Left_Align": "left",
"#-Zone_Right_Align": "right",
"#-Zone_Center_Align": "center"
......
......@@ -8,7 +8,7 @@ from selenium.webdriver.support.ui import WebDriverWait
from xblockutils.resources import ResourceLoader
from .test_base import BaseIntegrationTest
from .test_interaction import InteractionTestBase
from tests.integration.test_base import InteractionTestBase
loader = ResourceLoader(__name__)
......@@ -82,7 +82,7 @@ class SizingTests(InteractionTestBase, BaseIntegrationTest):
EXPECTATIONS = [
# The text 'Auto' with no fixed size specified should be 5-20% wide
Expectation(item_id=0, zone_id=ZONE_33, width_percent=[5, 20]),
Expectation(item_id=0, zone_id=ZONE_33, width_percent=[5, AUTO_MAX_WIDTH]),
# The long text with no fixed size specified should be wrapped at the maximum width
Expectation(item_id=1, zone_id=ZONE_33, width_percent=AUTO_MAX_WIDTH),
# The text items that specify specific widths as a percentage of the background image:
......
......@@ -13,6 +13,7 @@
"display_zone_borders": false,
"display_zone_labels": false,
"url_name": "test",
"max_items_per_zone": null,
"zones": [
{
......@@ -21,7 +22,8 @@
"x": 234,
"width": 345,
"height": 456,
"uid": "zone-1"
"uid": "zone-1",
"align": "right"
},
{
"title": "Zone 2",
......@@ -29,7 +31,8 @@
"x": 10,
"width": 30,
"height": 40,
"uid": "zone-2"
"uid": "zone-2",
"align": "center"
}
],
......
......@@ -6,7 +6,8 @@
"x": 234,
"width": 345,
"height": 456,
"uid": "zone-1"
"uid": "zone-1",
"align": "right"
},
{
"title": "Zone 2",
......
......@@ -13,6 +13,7 @@
"display_zone_borders": false,
"display_zone_labels": false,
"url_name": "unique_name",
"max_items_per_zone": null,
"zones": [
{
......@@ -21,7 +22,8 @@
"y": 200,
"width": 200,
"height": 100,
"uid": "Zone <i>1</i>"
"uid": "Zone <i>1</i>",
"align": "right"
},
{
"title": "Zone <b>2</b>",
......@@ -29,7 +31,8 @@
"y": 0,
"width": 200,
"height": 100,
"uid": "Zone <b>2</b>"
"uid": "Zone <b>2</b>",
"align": "center"
}
],
......
......@@ -7,7 +7,8 @@
"height": 100,
"y": 200,
"x": 100,
"id": "zone-1"
"id": "zone-1",
"align": "right"
},
{
"index": 2,
......@@ -16,7 +17,8 @@
"height": 100,
"y": 0,
"x": 0,
"id": "zone-2"
"id": "zone-2",
"align": "center"
}
],
......
......@@ -13,6 +13,7 @@
"display_zone_borders": false,
"display_zone_labels": false,
"url_name": "",
"max_items_per_zone": null,
"zones": [
{
......@@ -21,7 +22,8 @@
"y": "200",
"width": 200,
"height": 100,
"uid": "Zone 1"
"uid": "Zone 1",
"align": "center"
},
{
"title": "Zone 2",
......@@ -29,7 +31,8 @@
"y": 0,
"width": 200,
"height": 100,
"uid": "Zone 2"
"uid": "Zone 2",
"align": "center"
}
],
......
......@@ -13,6 +13,7 @@
"display_zone_borders": false,
"display_zone_labels": false,
"url_name": "test",
"max_items_per_zone": 4,
"zones": [
{
......@@ -21,7 +22,8 @@
"x": 234,
"width": 345,
"height": 456,
"uid": "zone-1"
"uid": "zone-1",
"align": "left"
},
{
"title": "Zone 2",
......@@ -29,7 +31,8 @@
"x": 10,
"width": 30,
"height": 40,
"uid": "zone-2"
"uid": "zone-2",
"align": "center"
}
],
......
......@@ -6,7 +6,8 @@
"x": 234,
"width": 345,
"height": 456,
"uid": "zone-1"
"uid": "zone-1",
"align": "left"
},
{
"title": "Zone 2",
......@@ -14,7 +15,8 @@
"x": 10,
"width": 30,
"height": 40,
"uid": "zone-2"
"uid": "zone-2",
"align": "center"
}
],
......
......@@ -7,5 +7,6 @@
"weight": 1,
"item_background_color": "",
"item_text_color": "",
"url_name": "test"
"url_name": "test",
"max_items_per_zone": 4
}
......@@ -75,7 +75,7 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
"""
def test_drop_item_wrong_with_feedback(self):
item_id, zone_id = 0, self.ZONE_2
data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"}
data = {"val": item_id, "zone": zone_id}
res = self.call_handler(self.DROP_ITEM_HANDLER, data)
self.assertEqual(res, {
"overall_feedback": [self._make_feedback_message(message=self.INITIAL_FEEDBACK)],
......@@ -86,7 +86,7 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
def test_drop_item_wrong_without_feedback(self):
item_id, zone_id = 2, self.ZONE_1
data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"}
data = {"val": item_id, "zone": zone_id}
res = self.call_handler(self.DROP_ITEM_HANDLER, data)
self.assertEqual(res, {
"overall_feedback": [self._make_feedback_message(message=self.INITIAL_FEEDBACK)],
......@@ -97,7 +97,7 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
def test_drop_item_correct(self):
item_id, zone_id = 0, self.ZONE_1
data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"}
data = {"val": item_id, "zone": zone_id}
res = self.call_handler(self.DROP_ITEM_HANDLER, data)
self.assertEqual(res, {
"overall_feedback": [self._make_feedback_message(message=self.INITIAL_FEEDBACK)],
......@@ -114,27 +114,23 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
published_grades.append(params)
self.block.runtime.publish = mock_publish
self.call_handler(self.DROP_ITEM_HANDLER, {
"val": 0, "zone": self.ZONE_1, "y_percent": "11%", "x_percent": "33%"
})
self.call_handler(self.DROP_ITEM_HANDLER, {"val": 0, "zone": self.ZONE_1})
self.assertEqual(1, len(published_grades))
self.assertEqual({'value': 0.75, 'max_value': 1}, published_grades[-1])
self.call_handler(self.DROP_ITEM_HANDLER, {
"val": 1, "zone": self.ZONE_2, "y_percent": "90%", "x_percent": "42%"
})
self.call_handler(self.DROP_ITEM_HANDLER, {"val": 1, "zone": self.ZONE_2})
self.assertEqual(2, len(published_grades))
self.assertEqual({'value': 1, 'max_value': 1}, published_grades[-1])
def test_drop_item_final(self):
data = {"val": 0, "zone": self.ZONE_1, "x_percent": "33%", "y_percent": "11%"}
data = {"val": 0, "zone": self.ZONE_1}
self.call_handler(self.DROP_ITEM_HANDLER, data)
expected_state = {
"items": {
"0": {"x_percent": "33%", "y_percent": "11%", "correct": True, "zone": self.ZONE_1}
"0": {"correct": True, "zone": self.ZONE_1}
},
"finished": False,
"attempts": 0,
......@@ -142,8 +138,7 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
}
self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET"))
data = {"val": 1, "zone": self.ZONE_2, "x_percent": "22%", "y_percent": "22%"}
res = self.call_handler(self.DROP_ITEM_HANDLER, data)
res = self.call_handler(self.DROP_ITEM_HANDLER, {"val": 1, "zone": self.ZONE_2})
self.assertEqual(res, {
"overall_feedback": [self._make_feedback_message(message=self.FINAL_FEEDBACK)],
"finished": True,
......@@ -153,12 +148,8 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
expected_state = {
"items": {
"0": {
"x_percent": "33%", "y_percent": "11%", "correct": True, "zone": self.ZONE_1,
},
"1": {
"x_percent": "22%", "y_percent": "22%", "correct": True, "zone": self.ZONE_2,
}
"0": {"correct": True, "zone": self.ZONE_1},
"1": {"correct": True, "zone": self.ZONE_2}
},
"finished": True,
"attempts": 0,
......@@ -182,9 +173,7 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
"""
@staticmethod
def _make_submission(item_id, zone_id):
x_percent, y_percent = str(random.randint(0, 100)) + '%', str(random.randint(0, 100)) + '%'
data = {"val": item_id, "zone": zone_id, "x_percent": x_percent, "y_percent": y_percent}
return data
return {"val": item_id, "zone": zone_id}
def _submit_solution(self, solution):
for item_id, zone_id in solution.iteritems():
......@@ -209,12 +198,11 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
item_zone_map = {0: self.ZONE_1, 1: self.ZONE_2}
for item_id, zone_id in item_zone_map.iteritems():
data = self._make_submission(item_id, zone_id)
x_percent, y_percent = data['x_percent'], data['y_percent']
res = self.call_handler(self.DROP_ITEM_HANDLER, data)
self.assertEqual(res, {})
expected_item_state = {'zone': zone_id, 'correct': True, 'x_percent': x_percent, 'y_percent': y_percent}
expected_item_state = {'zone': zone_id, 'correct': True}
self.assertIn(str(item_id), self.block.item_state)
self.assertEqual(self.block.item_state[str(item_id)], expected_item_state)
......
import ddt
import unittest
from drag_and_drop_v2.drag_and_drop_v2 import DragAndDropBlock
from drag_and_drop_v2.utils import Constants
from drag_and_drop_v2.default_data import (
TARGET_IMG_DESCRIPTION, TOP_ZONE_ID, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID,
START_FEEDBACK, FINISH_FEEDBACK, DEFAULT_DATA
......@@ -8,6 +9,7 @@ from drag_and_drop_v2.default_data import (
from ..utils import make_block, TestCaseMixin
@ddt.ddt
class BasicTests(TestCaseMixin, unittest.TestCase):
""" Basic unit tests for the Drag and Drop block, using its default settings """
......@@ -15,6 +17,30 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
self.block = make_block()
self.patch_workbench()
@staticmethod
def _make_submission(modify_submission=None):
modify = modify_submission if modify_submission else lambda x: x
submission = {
'display_name': "Test Drag & Drop",
'mode': Constants.STANDARD_MODE,
'max_attempts': 1,
'show_title': False,
'problem_text': "Problem Drag & Drop",
'show_problem_header': False,
'item_background_color': 'cornflowerblue',
'item_text_color': 'coral',
'weight': '5',
'data': {
'foo': 1,
'items': []
},
}
modify(submission)
return submission
def test_template_contents(self):
context = {}
student_fragment = self.block.runtime.render(self.block, 'student_view', context)
......@@ -30,13 +56,14 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
zones = config.pop("zones")
items = config.pop("items")
self.assertEqual(config, {
"mode": DragAndDropBlock.STANDARD_MODE,
"mode": Constants.STANDARD_MODE,
"max_attempts": None,
"display_zone_borders": False,
"display_zone_labels": False,
"title": "Drag and Drop",
"show_title": True,
"problem_text": "",
"max_items_per_zone": None,
"show_problem_header": True,
"target_img_expanded_url": '/expanded/url/to/drag_and_drop_v2/public/img/triangle.png',
"target_img_description": TARGET_IMG_DESCRIPTION,
......@@ -75,29 +102,29 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
assert_user_state_empty()
# Drag three items into the correct spot:
data = {"val": 0, "zone": TOP_ZONE_ID, "x_percent": "33%", "y_percent": "11%"}
data = {"val": 0, "zone": TOP_ZONE_ID}
self.call_handler(self.DROP_ITEM_HANDLER, data)
data = {"val": 1, "zone": MIDDLE_ZONE_ID, "x_percent": "67%", "y_percent": "80%"}
data = {"val": 1, "zone": MIDDLE_ZONE_ID}
self.call_handler(self.DROP_ITEM_HANDLER, data)
data = {"val": 2, "zone": BOTTOM_ZONE_ID, "x_percent": "99%", "y_percent": "95%"}
data = {"val": 2, "zone": BOTTOM_ZONE_ID}
self.call_handler(self.DROP_ITEM_HANDLER, data)
data = {"val": 3, "zone": MIDDLE_ZONE_ID, "x_percent": "67%", "y_percent": "80%"}
data = {"val": 3, "zone": MIDDLE_ZONE_ID}
self.call_handler(self.DROP_ITEM_HANDLER, data)
# Check the result:
self.assertTrue(self.block.completed)
self.assertEqual(self.block.item_state, {
'0': {'x_percent': '33%', 'y_percent': '11%', 'correct': True, 'zone': TOP_ZONE_ID},
'1': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, 'zone': MIDDLE_ZONE_ID},
'2': {'x_percent': '99%', 'y_percent': '95%', 'correct': True, 'zone': BOTTOM_ZONE_ID},
'3': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, "zone": MIDDLE_ZONE_ID},
'0': {'correct': True, 'zone': TOP_ZONE_ID},
'1': {'correct': True, 'zone': MIDDLE_ZONE_ID},
'2': {'correct': True, 'zone': BOTTOM_ZONE_ID},
'3': {'correct': True, "zone": MIDDLE_ZONE_ID},
})
self.assertEqual(self.call_handler('get_user_state'), {
'items': {
'0': {'x_percent': '33%', 'y_percent': '11%', 'correct': True, 'zone': TOP_ZONE_ID},
'1': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, 'zone': MIDDLE_ZONE_ID},
'2': {'x_percent': '99%', 'y_percent': '95%', 'correct': True, 'zone': BOTTOM_ZONE_ID},
'3': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, "zone": MIDDLE_ZONE_ID},
'0': {'correct': True, 'zone': TOP_ZONE_ID},
'1': {'correct': True, 'zone': MIDDLE_ZONE_ID},
'2': {'correct': True, 'zone': BOTTOM_ZONE_ID},
'3': {'correct': True, "zone": MIDDLE_ZONE_ID},
},
'finished': True,
"attempts": 0,
......@@ -124,39 +151,28 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
'1': {'top': 45, 'left': 99},
# Legacy dict with no correctness info.
'2': {'x_percent': '99%', 'y_percent': '95%', 'zone': BOTTOM_ZONE_ID},
# Current dict form.
# Legacy with absolute placement info.
'3': {'x_percent': '67%', 'y_percent': '80%', 'zone': BOTTOM_ZONE_ID, 'correct': False},
# Current state form
'4': {'zone': BOTTOM_ZONE_ID, 'correct': False},
}
self.block.save()
self.assertEqual(self.call_handler('get_user_state')['items'], {
# Legacy top/left values are converted to x/y percentage on the client.
'0': {'top': 60, 'left': 20, 'correct': True, 'zone': TOP_ZONE_ID},
'1': {'top': 45, 'left': 99, 'correct': True, 'zone': MIDDLE_ZONE_ID},
'2': {'x_percent': '99%', 'y_percent': '95%', 'correct': True, 'zone': BOTTOM_ZONE_ID},
'3': {'x_percent': '67%', 'y_percent': '80%', 'correct': False, "zone": BOTTOM_ZONE_ID},
'0': {'correct': True, 'zone': TOP_ZONE_ID},
'1': {'correct': True, 'zone': MIDDLE_ZONE_ID},
'2': {'correct': True, 'zone': BOTTOM_ZONE_ID},
'3': {'correct': False, "zone": BOTTOM_ZONE_ID},
'4': {'correct': False, "zone": BOTTOM_ZONE_ID},
})
def test_studio_submit(self):
body = {
'display_name': "Test Drag & Drop",
'mode': DragAndDropBlock.ASSESSMENT_MODE,
'max_attempts': 1,
'show_title': False,
'problem_text': "Problem Drag & Drop",
'show_problem_header': False,
'item_background_color': 'cornflowerblue',
'item_text_color': 'coral',
'weight': '5',
'data': {
'foo': 1
},
}
body = self._make_submission()
res = self.call_handler('studio_submit', body)
self.assertEqual(res, {'result': 'success'})
self.assertEqual(self.block.show_title, False)
self.assertEqual(self.block.mode, DragAndDropBlock.ASSESSMENT_MODE)
self.assertEqual(self.block.mode, Constants.STANDARD_MODE)
self.assertEqual(self.block.max_attempts, 1)
self.assertEqual(self.block.display_name, "Test Drag & Drop")
self.assertEqual(self.block.question_text, "Problem Drag & Drop")
......@@ -164,7 +180,46 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
self.assertEqual(self.block.item_background_color, "cornflowerblue")
self.assertEqual(self.block.item_text_color, "coral")
self.assertEqual(self.block.weight, 5)
self.assertEqual(self.block.data, {'foo': 1})
self.assertEqual(self.block.max_items_per_zone, None)
self.assertEqual(self.block.data, {'foo': 1, 'items': []})
def test_studio_submit_assessment(self):
def modify_submission(submission):
submission.update({
'mode': Constants.ASSESSMENT_MODE,
'max_items_per_zone': 4,
'show_problem_header': True,
'show_title': True,
'max_attempts': 12,
'item_text_color': 'red',
'data': {'foo': 2, 'items': [{'zone': '1', 'title': 'qwe'}]},
})
body = self._make_submission(modify_submission)
res = self.call_handler('studio_submit', body)
self.assertEqual(res, {'result': 'success'})
self.assertEqual(self.block.show_title, True)
self.assertEqual(self.block.mode, Constants.ASSESSMENT_MODE)
self.assertEqual(self.block.max_attempts, 12)
self.assertEqual(self.block.display_name, "Test Drag & Drop")
self.assertEqual(self.block.question_text, "Problem Drag & Drop")
self.assertEqual(self.block.show_question_header, True)
self.assertEqual(self.block.item_background_color, "cornflowerblue")
self.assertEqual(self.block.item_text_color, "red")
self.assertEqual(self.block.weight, 5)
self.assertEqual(self.block.max_items_per_zone, 4)
self.assertEqual(self.block.data, {'foo': 2, 'items': [{'zone': '1', 'title': 'qwe'}]})
def test_studio_submit_empty_max_items(self):
def modify_submission(submission):
submission['max_items_per_zone'] = ''
body = self._make_submission(modify_submission)
res = self.call_handler('studio_submit', body)
self.assertEqual(res, {'result': 'success'})
self.assertIsNone(self.block.max_items_per_zone)
def test_expand_static_url(self):
""" Test the expand_static_url handler needed in Studio when changing the image """
......
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