Commit de5ac0c4 by E. Kolpakov

[SOL-1979] Preventing overlapping item placement + other improvements:

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