diff --git a/README.md b/README.md
index ee72e34b3..8c0584284 100644
--- a/README.md
+++ b/README.md
@@ -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
-------
diff --git a/drag_and_drop_v2/default_data.py b/drag_and_drop_v2/default_data.py
index 6d6437f05..c5fb283ab 100644
--- a/drag_and_drop_v2/default_data.py
+++ b/drag_and_drop_v2/default_data.py
@@ -38,6 +38,7 @@
"y": 30,
"width": 196,
"height": 178,
+ "align": "center"
},
{
"uid": MIDDLE_ZONE_ID,
@@ -47,6 +48,7 @@
"y": 210,
"width": 340,
"height": 138,
+ "align": "center"
},
{
"uid": BOTTOM_ZONE_ID,
@@ -56,6 +58,7 @@
"y": 350,
"width": 485,
"height": 135,
+ "align": "center"
}
],
"items": [
@@ -65,7 +68,9 @@
"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 @@
"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 @@
"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 @@
"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
},
diff --git a/drag_and_drop_v2/drag_and_drop_v2.py b/drag_and_drop_v2/drag_and_drop_v2.py
index 9bf64bdf7..bb15de080 100644
--- a/drag_and_drop_v2/drag_and_drop_v2.py
+++ b/drag_and_drop_v2/drag_and_drop_v2.py
@@ -6,6 +6,7 @@
import copy
import json
+import logging
import urllib
import webob
@@ -16,25 +17,24 @@
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 @@ def items_without_answers():
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 @@ def studio_view(self, context):
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 @@ def studio_submit(self, submissions, suffix=''):
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 @@ def drop_item(self, item_attempt, suffix=''):
"""
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 @@ def _validate_do_attempt(self):
"""
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 @@ def _get_feedback(self):
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 @@ def _make_state_from_attempt(attempt, correct):
"""
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 @@ def _is_attempt_correct(self, attempt):
"""
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 @@ def _get_user_state(self):
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 @@ def _get_item_state(self):
# 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 @@ def _get_item_definition(self, item_id):
"""
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 @@ def _get_item_zones(self, item_id):
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 @@ def _get_item_raw_stats(self):
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
diff --git a/drag_and_drop_v2/public/css/drag_and_drop.css b/drag_and_drop_v2/public/css/drag_and_drop.css
index 33464ed7c..08dd1db4a 100644
--- a/drag_and_drop_v2/public/css/drag_and_drop.css
+++ b/drag_and_drop_v2/public/css/drag_and_drop.css
@@ -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 */
diff --git a/drag_and_drop_v2/public/js/drag_and_drop.js b/drag_and_drop_v2/public/js/drag_and_drop.js
index cb53fa9dd..7b763cf06 100644
--- a/drag_and_drop_v2/public/js/drag_and_drop.js
+++ b/drag_and_drop_v2/public/js/drag_and_drop.js
@@ -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;
});
};
diff --git a/drag_and_drop_v2/public/js/drag_and_drop_edit.js b/drag_and_drop_v2/public/js/drag_and_drop_edit.js
index af7161fb1..b483335c6 100644
--- a/drag_and_drop_v2/public/js/drag_and_drop_edit.js
+++ b/drag_and_drop_v2/public/js/drag_and_drop_edit.js
@@ -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
+ });
}
});
}
diff --git a/drag_and_drop_v2/templates/html/drag_and_drop_edit.html b/drag_and_drop_v2/templates/html/drag_and_drop_edit.html
index 7836892fe..3635f8ee1 100644
--- a/drag_and_drop_v2/templates/html/drag_and_drop_edit.html
+++ b/drag_and_drop_v2/templates/html/drag_and_drop_edit.html
@@ -174,6 +174,15 @@
- {{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."}}
diff --git a/drag_and_drop_v2/translations/en/LC_MESSAGES/text.po b/drag_and_drop_v2/translations/en/LC_MESSAGES/text.po
index eb4743526..fd55b0a32 100644
--- a/drag_and_drop_v2/translations/en/LC_MESSAGES/text.po
+++ b/drag_and_drop_v2/translations/en/LC_MESSAGES/text.po
@@ -89,6 +89,10 @@ msgstr ""
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"
@@ -204,10 +208,6 @@ msgstr ""
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 ""
diff --git a/drag_and_drop_v2/translations/eo/LC_MESSAGES/text.po b/drag_and_drop_v2/translations/eo/LC_MESSAGES/text.po
index 338c05ddb..d4a6d7bfb 100644
--- a/drag_and_drop_v2/translations/eo/LC_MESSAGES/text.po
+++ b/drag_and_drop_v2/translations/eo/LC_MESSAGES/text.po
@@ -117,6 +117,10 @@ msgstr "Göés änýwhéré Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
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."
@@ -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."
@@ -259,10 +263,6 @@ msgstr ""
"Ìndïçätés whéthér ä léärnér häs çömplétéd thé prößlém ät léäst önçé Ⱡ'σяєм "
"ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #"
-#: 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 ""
diff --git a/drag_and_drop_v2/utils.py b/drag_and_drop_v2/utils.py
index 95bacdedc..d9ffab2be 100644
--- a/drag_and_drop_v2/utils.py
+++ b/drag_and_drop_v2/utils.py
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
""" Drag and Drop v2 XBlock - Utils """
+import copy
from collections import namedtuple
@@ -82,3 +83,200 @@ def not_placed(number, ngettext=ngettext_fallback):
'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
diff --git a/pylintrc b/pylintrc
index fff473365..9d4d6e6af 100644
--- a/pylintrc
+++ b/pylintrc
@@ -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}$
diff --git a/tests/integration/test_base.py b/tests/integration/test_base.py
index b122174d6..65812b488 100644
--- a/tests/integration/test_base.py
+++ b/tests/integration/test_base.py
@@ -1,6 +1,11 @@
+# -*- 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.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 @@
# 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):
"'": "'"
}
- @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 """
""".format(
@@ -46,6 +75,9 @@ def _make_scenario_xml(display_name, show_title, problem_text, completed=False,
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 @@ def is_ajax_finished():
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 ""
+
+
+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)
diff --git a/tests/integration/test_events.py b/tests/integration/test_events.py
new file mode 100644
index 000000000..9f71279db
--- /dev/null
+++ b/tests/integration/test_events.py
@@ -0,0 +1,76 @@
+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 ""
+
+ @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']
+ )
diff --git a/tests/integration/test_interaction.py b/tests/integration/test_interaction.py
index 0c824a19a..5c0623b58 100644
--- a/tests/integration/test_interaction.py
+++ b/tests/integration/test_interaction.py
@@ -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 @@
# 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 @@ def interact_with_keyboard_help(self, scroll_down=250, use_keyboard=False):
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 ""
-
@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 @@ def _get_scenario_xml(self):
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 ""
-
- @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 @@ def _get_scenario_xml(self):
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 @@ def _assert_zone_align_item(self, item_id, zone_id, align, action_key=None):
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 @@ def test_zone_align(self, items, zone, alignment):
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)
diff --git a/tests/integration/test_interaction_assessment.py b/tests/integration/test_interaction_assessment.py
index 0030c876a..481d2438b 100644
--- a/tests/integration/test_interaction_assessment.py
+++ b/tests/integration/test_interaction_assessment.py
@@ -13,9 +13,9 @@
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 """
-
- """.format(max_attempts=self.MAX_ATTEMPTS)
+
+ """.format(mode=Constants.ASSESSMENT_MODE, max_attempts=self.MAX_ATTEMPTS)
class AssessmentTestMixin(object):
@@ -57,7 +57,8 @@ def click_submit(self):
@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 @@ def test_grade(self):
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())
diff --git a/tests/integration/test_render.py b/tests/integration/test_render.py
index 20555ce68..7eecee403 100644
--- a/tests/integration/test_render.py
+++ b/tests/integration/test_render.py
@@ -189,7 +189,7 @@ def test_zones(self):
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 @@ def setUp(self):
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"
diff --git a/tests/integration/test_sizing.py b/tests/integration/test_sizing.py
index f7af93bbc..a814484b3 100644
--- a/tests/integration/test_sizing.py
+++ b/tests/integration/test_sizing.py
@@ -8,7 +8,7 @@
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 @@ def _get_scenario_xml(cls):
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:
diff --git a/tests/unit/data/assessment/config_out.json b/tests/unit/data/assessment/config_out.json
index 9ed8c32b4..e4b9d3066 100644
--- a/tests/unit/data/assessment/config_out.json
+++ b/tests/unit/data/assessment/config_out.json
@@ -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"
}
],
diff --git a/tests/unit/data/assessment/data.json b/tests/unit/data/assessment/data.json
index f878a5cf6..84cd9b263 100644
--- a/tests/unit/data/assessment/data.json
+++ b/tests/unit/data/assessment/data.json
@@ -6,7 +6,8 @@
"x": 234,
"width": 345,
"height": 456,
- "uid": "zone-1"
+ "uid": "zone-1",
+ "align": "right"
},
{
"title": "Zone 2",
diff --git a/tests/unit/data/html/config_out.json b/tests/unit/data/html/config_out.json
index 7e809ea43..87f54a06e 100644
--- a/tests/unit/data/html/config_out.json
+++ b/tests/unit/data/html/config_out.json
@@ -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 1"
+ "uid": "Zone 1",
+ "align": "right"
},
{
"title": "Zone 2",
@@ -29,7 +31,8 @@
"y": 0,
"width": 200,
"height": 100,
- "uid": "Zone 2"
+ "uid": "Zone 2",
+ "align": "center"
}
],
diff --git a/tests/unit/data/html/data.json b/tests/unit/data/html/data.json
index d934c3ec5..59ce9e961 100644
--- a/tests/unit/data/html/data.json
+++ b/tests/unit/data/html/data.json
@@ -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"
}
],
diff --git a/tests/unit/data/old/config_out.json b/tests/unit/data/old/config_out.json
index f4c0dd888..8cc1e5100 100644
--- a/tests/unit/data/old/config_out.json
+++ b/tests/unit/data/old/config_out.json
@@ -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"
}
],
diff --git a/tests/unit/data/plain/config_out.json b/tests/unit/data/plain/config_out.json
index fd116d481..8957e22a4 100644
--- a/tests/unit/data/plain/config_out.json
+++ b/tests/unit/data/plain/config_out.json
@@ -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"
}
],
diff --git a/tests/unit/data/plain/data.json b/tests/unit/data/plain/data.json
index 849ae7937..7cd600e3d 100644
--- a/tests/unit/data/plain/data.json
+++ b/tests/unit/data/plain/data.json
@@ -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"
}
],
diff --git a/tests/unit/data/plain/settings.json b/tests/unit/data/plain/settings.json
index 27d7c22e5..5f051896f 100644
--- a/tests/unit/data/plain/settings.json
+++ b/tests/unit/data/plain/settings.json
@@ -7,5 +7,6 @@
"weight": 1,
"item_background_color": "",
"item_text_color": "",
- "url_name": "test"
+ "url_name": "test",
+ "max_items_per_zone": 4
}
diff --git a/tests/unit/test_advanced.py b/tests/unit/test_advanced.py
index 7fb0bfb3b..dc134c223 100644
--- a/tests/unit/test_advanced.py
+++ b/tests/unit/test_advanced.py
@@ -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 @@ def test_drop_item_wrong_with_feedback(self):
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 @@ def test_drop_item_wrong_without_feedback(self):
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 @@ def mock_publish(_, event, params):
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 @@ def test_drop_item_final(self):
}
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 @@ def test_drop_item_final(self):
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 @@ def test_multiple_drop_item(self):
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)
diff --git a/tests/unit/test_basics.py b/tests/unit/test_basics.py
index fb8af4d90..5d21c6de0 100644
--- a/tests/unit/test_basics.py
+++ b/tests/unit/test_basics.py
@@ -1,6 +1,7 @@
+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 ..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 @@ def setUp(self):
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 @@ def test_get_configuration(self):
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 @@ def assert_user_state_empty():
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 @@ def test_legacy_state_support(self):
'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 @@ def test_studio_submit(self):
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 """