diff --git a/README.md b/README.md index ee72e34b3..8c0584284 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The editor is fully guided. Features include: * custom zone labels * ability to show or hide zone borders * custom text and background colors for items -* optional auto-alignment for items (left, right, center) +* auto-alignment for items: left, right, center * image items * decoy items that don't have a zone * feedback popups for both correct and incorrect attempts @@ -122,15 +122,13 @@ whether or not to display borders outlining the zones. It is possible to define an arbitrary number of drop zones as long as their labels are unique. -Additionally, you can specify the alignment for items once they are dropped in -the zone. No alignment is the default, and causes items to stay where the -learner drops them. Left alignment causes dropped items to be placed from left +You can specify the alignment for items once they are dropped in +the zone. Centered alignment is the default, and places items from top to bottom +along the center of the zone. Left alignment causes dropped items to be placed from left to right across the zone. Right alignment causes the items to be placed from -right to left across the zone. Center alignment places items from top to bottom -along the center of the zone. If left, right, or center alignment is chosen, -items dropped in a zone will not overlap, but if the zone is not made large -enough for all its items, they will overflow the bottom of the zone, and -potentially, overlap the zones below. +right to left across the zone. Items dropped in a zone will not overlap, +but if the zone is not made large enough for all its items, they will overflow the bottom +of the zone, and potentially overlap the zones below. ![Drag item edit](/doc/img/edit-view-items.png) @@ -151,6 +149,9 @@ You can leave all of the checkboxes unchecked in order to create a You can define an arbitrary number of drag items, each of which may be attached to any number of zones. +"Maximum items per Zone" setting controls how many items can be dropped into a +single zone, allowing some degree of control over items overlapping zones below. + Scoring ------- diff --git a/drag_and_drop_v2/default_data.py b/drag_and_drop_v2/default_data.py index 6d6437f05..c5fb283ab 100644 --- a/drag_and_drop_v2/default_data.py +++ b/drag_and_drop_v2/default_data.py @@ -38,6 +38,7 @@ "y": 30, "width": 196, "height": 178, + "align": "center" }, { "uid": MIDDLE_ZONE_ID, @@ -47,6 +48,7 @@ "y": 210, "width": 340, "height": 138, + "align": "center" }, { "uid": BOTTOM_ZONE_ID, @@ -56,6 +58,7 @@ "y": 350, "width": 485, "height": 135, + "align": "center" } ], "items": [ @@ -65,7 +68,9 @@ "incorrect": ITEM_INCORRECT_FEEDBACK, "correct": ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE) }, - "zones": [TOP_ZONE_ID], + "zones": [ + TOP_ZONE_ID + ], "imageURL": "", "id": 0, }, @@ -75,7 +80,9 @@ "incorrect": ITEM_INCORRECT_FEEDBACK, "correct": ITEM_CORRECT_FEEDBACK.format(zone=MIDDLE_ZONE_TITLE) }, - "zones": [MIDDLE_ZONE_ID], + "zones": [ + MIDDLE_ZONE_ID + ], "imageURL": "", "id": 1, }, @@ -85,7 +92,9 @@ "incorrect": ITEM_INCORRECT_FEEDBACK, "correct": ITEM_CORRECT_FEEDBACK.format(zone=BOTTOM_ZONE_TITLE) }, - "zones": [BOTTOM_ZONE_ID], + "zones": [ + BOTTOM_ZONE_ID + ], "imageURL": "", "id": 2, }, @@ -95,7 +104,11 @@ "incorrect": "", "correct": ITEM_ANY_ZONE_FEEDBACK }, - "zones": [TOP_ZONE_ID, BOTTOM_ZONE_ID, MIDDLE_ZONE_ID], + "zones": [ + TOP_ZONE_ID, + BOTTOM_ZONE_ID, + MIDDLE_ZONE_ID + ], "imageURL": "", "id": 3 }, diff --git a/drag_and_drop_v2/drag_and_drop_v2.py b/drag_and_drop_v2/drag_and_drop_v2.py index 9bf64bdf7..bb15de080 100644 --- a/drag_and_drop_v2/drag_and_drop_v2.py +++ b/drag_and_drop_v2/drag_and_drop_v2.py @@ -6,6 +6,7 @@ import copy import json +import logging import urllib import webob @@ -16,25 +17,24 @@ from xblockutils.resources import ResourceLoader from xblockutils.settings import XBlockWithSettingsMixin, ThemableXBlockMixin -from .utils import _, DummyTranslationService, FeedbackMessage, FeedbackMessages, ItemStats +from .utils import _, DummyTranslationService, FeedbackMessage, FeedbackMessages, ItemStats, StateMigration, Constants from .default_data import DEFAULT_DATA # Globals ########################################################### loader = ResourceLoader(__name__) - +logger = logging.getLogger(__name__) # Classes ########################################################### + @XBlock.wants('settings') @XBlock.needs('i18n') class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): """ XBlock that implements a friendly Drag-and-Drop problem """ - STANDARD_MODE = "standard" - ASSESSMENT_MODE = "assessment" SOLUTION_CORRECT = "correct" SOLUTION_PARTIAL = "partial" @@ -69,10 +69,10 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ), scope=Scope.settings, values=[ - {"display_name": _("Standard"), "value": STANDARD_MODE}, - {"display_name": _("Assessment"), "value": ASSESSMENT_MODE}, + {"display_name": _("Standard"), "value": Constants.STANDARD_MODE}, + {"display_name": _("Assessment"), "value": Constants.ASSESSMENT_MODE}, ], - default=STANDARD_MODE + default=Constants.STANDARD_MODE ) max_attempts = Integer( @@ -127,6 +127,13 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): default="", ) + max_items_per_zone = Integer( + display_name=_("Maximum items per zone"), + help=_("This setting limits the number of items that can be dropped into a single zone."), + scope=Scope.settings, + default=None + ) + data = Dict( display_name=_("Problem data"), help=_( @@ -157,7 +164,7 @@ class DragAndDropBlock(XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): ) grade = Float( - help=_("Keeps maximum achieved score by student"), + help=_("Keeps maximum score achieved by student"), scope=Scope.user_state, default=0 ) @@ -223,8 +230,9 @@ def items_without_answers(): return { "mode": self.mode, + "zones": self.zones, "max_attempts": self.max_attempts, - "zones": self._get_zones(), + "max_items_per_zone": self.max_items_per_zone, # SDK doesn't supply url_name. "url_name": getattr(self, 'url_name', ''), "display_zone_labels": self.data.get('displayLabels', False), @@ -286,7 +294,7 @@ def studio_view(self, context): items = self.data.get('items', []) for item in items: - zones = self._get_item_zones(item['id']) + zones = self.get_item_zones(item['id']) # Note that we appear to be mutating the state of the XBlock here, but because # the change won't be committed, we're actually just affecting the data that # we're going to send to the client, not what's saved in the backing store. @@ -315,12 +323,46 @@ def studio_submit(self, submissions, suffix=''): self.weight = float(submissions['weight']) self.item_background_color = submissions['item_background_color'] self.item_text_color = submissions['item_text_color'] + self.max_items_per_zone = self._get_max_items_per_zone(submissions) self.data = submissions['data'] return { 'result': 'success', } + @staticmethod + def _get_max_items_per_zone(submissions): + """ + Parses Max items per zone value coming from editor. + + Returns: + * None if invalid value is passed (i.e. not an integer) + * None if value is parsed into zero or negative integer + * Positive integer otherwise. + + Examples: + * _get_max_items_per_zone(None) -> None + * _get_max_items_per_zone('string') -> None + * _get_max_items_per_zone('-1') -> None + * _get_max_items_per_zone(-1) -> None + * _get_max_items_per_zone('0') -> None + * _get_max_items_per_zone('') -> None + * _get_max_items_per_zone('42') -> 42 + * _get_max_items_per_zone(42) -> 42 + """ + raw_max_items_per_zone = submissions.get('max_items_per_zone', None) + + # Entries that aren't numbers should be treated as null. We assume that if we can + # turn it into an int, a number was submitted. + try: + max_attempts = int(raw_max_items_per_zone) + if max_attempts > 0: + return max_attempts + else: + return None + except (ValueError, TypeError): + return None + @XBlock.json_handler def drop_item(self, item_attempt, suffix=''): """ @@ -328,9 +370,9 @@ def drop_item(self, item_attempt, suffix=''): """ self._validate_drop_item(item_attempt) - if self.mode == self.ASSESSMENT_MODE: + if self.mode == Constants.ASSESSMENT_MODE: return self._drop_item_assessment(item_attempt) - elif self.mode == self.STANDARD_MODE: + elif self.mode == Constants.STANDARD_MODE: return self._drop_item_standard(item_attempt) else: raise JsonHandlerError( @@ -434,7 +476,7 @@ def _validate_do_attempt(self): """ Validates if `do_attempt` handler should be executed """ - if self.mode != self.ASSESSMENT_MODE: + if self.mode != Constants.ASSESSMENT_MODE: raise JsonHandlerError( 400, self.i18n_service.gettext("do_attempt handler should only be called for assessment mode") @@ -452,7 +494,7 @@ def _get_feedback(self): answer_correctness = self._answer_correctness() is_correct = answer_correctness == self.SOLUTION_CORRECT - if self.mode == self.STANDARD_MODE or not self.attempts: + if self.mode == Constants.STANDARD_MODE or not self.attempts: feedback_key = 'finish' if is_correct else 'start' return [FeedbackMessage(self.data['feedback'][feedback_key], None)], set() @@ -563,9 +605,7 @@ def _make_state_from_attempt(attempt, correct): """ return { 'zone': attempt['zone'], - 'correct': correct, - 'x_percent': attempt['x_percent'], - 'y_percent': attempt['y_percent'], + 'correct': correct } def _mark_complete_and_publish_grade(self): @@ -613,7 +653,7 @@ def _is_attempt_correct(self, attempt): """ Check if the item was placed correctly. """ - correct_zones = self._get_item_zones(attempt['val']) + correct_zones = self.get_item_zones(attempt['val']) return attempt['zone'] in correct_zones def _expand_static_url(self, url): @@ -640,12 +680,12 @@ def _get_user_state(self): item_state = self._get_item_state() # In assessment mode, we do not want to leak the correctness info for individual items to the frontend, # so we remove "correct" from all items when in assessment mode. - if self.mode == self.ASSESSMENT_MODE: + if self.mode == Constants.ASSESSMENT_MODE: for item in item_state.values(): del item["correct"] overall_feedback_msgs, __ = self._get_feedback() - if self.mode == self.STANDARD_MODE: + if self.mode == Constants.STANDARD_MODE: is_finished = self._is_answer_correct() else: is_finished = not self.attempts_remain @@ -666,33 +706,10 @@ def _get_item_state(self): # IMPORTANT: this method should always return a COPY of self.item_state - it is called from get_user_state # handler and the data it returns is manipulated there to hide correctness of items placed. state = {} + migrator = StateMigration(self) - for item_id, raw_item in self.item_state.iteritems(): - if isinstance(raw_item, dict): - # Items are manipulated in _get_user_state, so we protect actual data. - item = copy.deepcopy(raw_item) - else: - item = {'top': raw_item[0], 'left': raw_item[1]} - # If information about zone is missing - # (because problem was completed before a11y enhancements were implemented), - # deduce zone in which item is placed from definition: - if item.get('zone') is None: - valid_zones = self._get_item_zones(int(item_id)) - if valid_zones: - # If we get to this point, then the item was placed prior to support for - # multiple correct zones being added. As a result, it can only be correct - # on a single zone, and so we can trust that the item was placed on the - # zone with index 0. - item['zone'] = valid_zones[0] - else: - item['zone'] = 'unknown' - # If correctness information is missing - # (because problem was completed before assessment mode was implemented), - # assume the item is in correct zone (in standard mode, only items placed - # into correct zone are stored in item state). - if item.get('correct') is None: - item['correct'] = True - state[item_id] = item + for item_id, item in self.item_state.iteritems(): + state[item_id] = migrator.apply_item_state_migrations(item_id, item) return state @@ -702,7 +719,7 @@ def _get_item_definition(self, item_id): """ return next(i for i in self.data['items'] if i['id'] == item_id) - def _get_item_zones(self, item_id): + def get_item_zones(self, item_id): """ Returns a list of the zones that are valid options for the item. @@ -720,27 +737,20 @@ def _get_item_zones(self, item_id): else: return [] - def _get_zones(self): + @property + def zones(self): """ Get drop zone data, defined by the author. """ # Convert zone data from old to new format if necessary - zones = [] - for zone in self.data.get('zones', []): - zone = zone.copy() - if "uid" not in zone: - zone["uid"] = zone.get("title") # Older versions used title as the zone UID - # Remove old, now-unused zone attributes, if present: - zone.pop("id", None) - zone.pop("index", None) - zones.append(zone) - return zones + migrator = StateMigration(self) + return [migrator.apply_zone_migrations(zone) for zone in self.data.get('zones', [])] def _get_zone_by_uid(self, uid): """ Given a zone UID, return that zone, or None. """ - for zone in self._get_zones(): + for zone in self.zones: if zone["uid"] == uid: return zone @@ -772,7 +782,7 @@ def _get_item_raw_stats(self): item_state = self._get_item_state() all_items = set(str(item['id']) for item in self.data['items']) - required = set(item_id for item_id in all_items if self._get_item_zones(int(item_id)) != []) + required = set(item_id for item_id in all_items if self.get_item_zones(int(item_id)) != []) placed = set(item_id for item_id in all_items if item_id in item_state) correctly_placed = set(item_id for item_id in placed if item_state[item_id]['correct']) decoy = all_items - required diff --git a/drag_and_drop_v2/public/css/drag_and_drop.css b/drag_and_drop_v2/public/css/drag_and_drop.css index 33464ed7c..08dd1db4a 100644 --- a/drag_and_drop_v2/public/css/drag_and_drop.css +++ b/drag_and_drop_v2/public/css/drag_and_drop.css @@ -171,9 +171,9 @@ text-align: center; } .xblock--drag-and-drop .zone .item-align-center .option { - display: block; - margin-left: auto; - margin-right: auto; + display: inline-block; + margin-left: 1px; + margin-right: 1px; } /* Focused option */ diff --git a/drag_and_drop_v2/public/js/drag_and_drop.js b/drag_and_drop_v2/public/js/drag_and_drop.js index cb53fa9dd..7b763cf06 100644 --- a/drag_and_drop_v2/public/js/drag_and_drop.js +++ b/drag_and_drop_v2/public/js/drag_and_drop.js @@ -84,23 +84,11 @@ function DragAndDropTemplates(configuration) { style['outline-color'] = item.color; } if (item.is_placed) { - if (item.zone_align === 'none') { - // This is not an "aligned" zone, so the item gets positioned where the learner dropped it. - style.left = item.x_percent + "%"; - style.top = item.y_percent + "%"; - if (item.widthPercent) { // This item has an author-defined explicit width - style.width = item.widthPercent + "%"; - style.maxWidth = item.widthPercent + "%"; // default maxWidth is ~30% - } - } else { - // This is an "aligned" zone, so the item position within the zone is calculated by the browser. - // Make up for the fact we're in a wrapper container by calculating percentage differences. - var maxWidth = (item.widthPercent || 30) / 100; - var widthPercent = zone.width_percent / 100; - style.maxWidth = ((1 / (widthPercent / maxWidth)) * 100) + '%'; - if (item.widthPercent) { - style.width = style.maxWidth; - } + var maxWidth = (item.widthPercent || 30) / 100; + var widthPercent = zone.width_percent / 100; + style.maxWidth = ((1 / (widthPercent / maxWidth)) * 100) + '%'; + if (item.widthPercent) { + style.width = style.maxWidth; } // Finally, if the item is using automatic sizing and contains an image, we // always prefer the natural width of the image (subject to the max-width): @@ -192,7 +180,6 @@ function DragAndDropTemplates(configuration) { var zoneTemplate = function(zone, ctx) { var className = ctx.display_zone_labels ? 'zone-name' : 'zone-name sr'; var selector = ctx.display_zone_borders ? 'div.zone.zone-with-borders' : 'div.zone'; - // If zone is aligned, mark its item alignment // and render its placed items as children var item_wrapper = 'div.item-wrapper'; @@ -389,8 +376,6 @@ function DragAndDropTemplates(configuration) { var is_item_placed = function(i) { return i.is_placed; }; var items_placed = $.grep(ctx.items, is_item_placed); var items_in_bank = $.grep(ctx.items, is_item_placed, true); - var is_item_placed_unaligned = function(i) { return i.zone_align === 'none'; }; - var items_placed_unaligned = $.grep(items_placed, is_item_placed_unaligned); var item_bank_properties = {}; if (ctx.item_bank_focusable) { item_bank_properties.attributes = { @@ -421,7 +406,6 @@ function DragAndDropTemplates(configuration) { h('img.target-img', {src: ctx.target_img_src, alt: ctx.target_img_description}), ] ), - renderCollection(itemTemplate, items_placed_unaligned, ctx), renderCollection(zoneTemplate, ctx.zones, ctx) ]), ]), @@ -463,6 +447,8 @@ function DragAndDropBlock(runtime, element, configuration) { // Event string size limit. var MAX_LENGTH = 255; + var DEFAULT_ZONE_ALIGN = 'center'; + // Keyboard accessibility var ESC = 27; var RET = 13; @@ -490,7 +476,7 @@ function DragAndDropBlock(runtime, element, configuration) { }); state = stateResult[0]; // stateResult is an array of [data, statusText, jqXHR] migrateConfiguration(bgImg.width); - migrateState(bgImg.width, bgImg.height); + migrateState(); markItemZoneAlign(); bgImgNaturalWidth = bgImg.width; @@ -755,42 +741,42 @@ function DragAndDropBlock(runtime, element, configuration) { var placeItem = function($zone, $item) { var item_id; - var $anchor; if ($item !== undefined) { item_id = $item.data('value'); - // Element was placed using the mouse, - // so use relevant properties of *item* when calculating new position below. - $anchor = $item; } else { item_id = $selectedItem.data('value'); - // Element was placed using the keyboard, - // so use relevant properties of *zone* when calculating new position below. - $anchor = $zone; } + var zone = String($zone.data('uid')); var zone_align = $zone.data('zone_align'); - var $target_img = $root.find('.target-img'); - // Calculate the position of the item to place relative to the image. - var x_pos = $anchor.offset().left + ($anchor.outerWidth()/2) - $target_img.offset().left; - var y_pos = $anchor.offset().top + ($anchor.outerHeight()/2) - $target_img.offset().top; - var x_pos_percent = x_pos / $target_img.width() * 100; - var y_pos_percent = y_pos / $target_img.height() * 100; + var items_in_zone_count = countItemsInZone(zone, [item_id.toString()]); + if (configuration.max_items_per_zone && configuration.max_items_per_zone <= items_in_zone_count) { + state.last_action_correct = false; + state.feedback = gettext("You cannot add any more items to this zone."); + applyState(); + return; + } state.items[item_id] = { zone: zone, zone_align: zone_align, - x_percent: x_pos_percent, - y_percent: y_pos_percent, submitting_location: true, }; // Wrap in setTimeout to let the droppable event finish. setTimeout(function() { applyState(); - submitLocation(item_id, zone, x_pos_percent, y_pos_percent); + submitLocation(item_id, zone); }, 0); }; + var countItemsInZone = function(zone, exclude_ids) { + var ids_to_exclude = exclude_ids ? exclude_ids : []; + return Object.keys(state.items).filter(function(item_id) { + return state.items[item_id].zone === zone && $.inArray(item_id, ids_to_exclude) === -1; + }).length; + }; + var initDroppable = function() { // Set up zones for keyboard interaction $root.find('.zone, .item-bank').each(function() { @@ -805,6 +791,7 @@ function DragAndDropBlock(runtime, element, configuration) { releaseItem($selectedItem); } else if (isActionKey(evt)) { evt.preventDefault(); + evt.stopPropagation(); state.keyboard_placement_mode = false; releaseItem($selectedItem); if ($zone.is('.item-bank')) { @@ -946,16 +933,14 @@ function DragAndDropBlock(runtime, element, configuration) { }); }; - var submitLocation = function(item_id, zone, x_percent, y_percent) { + var submitLocation = function(item_id, zone) { if (!zone) { return; } var url = runtime.handlerUrl(element, 'drop_item'); var data = { val: item_id, - zone: zone, - x_percent: x_percent, - y_percent: y_percent, + zone: zone }; $.post(url, JSON.stringify(data), 'json') @@ -1107,8 +1092,6 @@ function DragAndDropBlock(runtime, element, configuration) { if (item_user_state) { itemProperties.zone = item_user_state.zone; itemProperties.zone_align = item_user_state.zone_align; - itemProperties.x_percent = item_user_state.x_percent; - itemProperties.y_percent = item_user_state.y_percent; } if (configuration.item_background_color) { itemProperties.background_color = configuration.item_background_color; @@ -1172,35 +1155,12 @@ function DragAndDropBlock(runtime, element, configuration) { /** * migrateState: Apply any changes necessary to support the 'state' format used by older * versions of this XBlock. - * We have to do this in JS, not python, since some migrations depend on the image size, + * Most migrations are applied in python, but migrations may depend on the image size, * which is not known in Python-land. */ - var migrateState = function(bg_image_width, bg_image_height) { - Object.keys(state.items).forEach(function(item_id) { - var item = state.items[item_id]; - if (item.x_percent === undefined) { - // Find the matching item in the configuration - var width = 190; - var height = 44; - for (var i in configuration.items) { - if (configuration.items[i].id === +item_id) { - var size = configuration.items[i].size; - // size is an object like '{width: "50px", height: "auto"}' - if (parseInt(size.width ) > 0) {width = parseInt(size.width);} - if (parseInt(size.height) > 0) {height = parseInt(size.height);} - break; - } - } - // Update the user's item state to use centered relative coordinates - var left_px = parseFloat(item.left) - 220; // 220 px for the items container that used to be on the left - var top_px = parseFloat(item.top); - item.x_percent = (left_px + width/2) / bg_image_width * 100; - item.y_percent = (top_px + height/2) / bg_image_height * 100; - delete item.left; - delete item.top; - delete item.absolute; - } - }); + var migrateState = function() { + // JS migrations were squashed down to "do nothing", but decided to keep the method + // to give a hint to future developers that migrations can be applied in JS }; /** @@ -1211,12 +1171,12 @@ function DragAndDropBlock(runtime, element, configuration) { var markItemZoneAlign = function() { var zone_alignments = {}; configuration.zones.forEach(function(zone) { - if (!zone.align) zone.align = 'none'; + if (!zone.align) zone.align = DEFAULT_ZONE_ALIGN; zone_alignments[zone.uid] = zone.align; }); Object.keys(state.items).forEach(function(item_id) { var item = state.items[item_id]; - item.zone_align = zone_alignments[item.zone] || 'none'; + item.zone_align = zone_alignments[item.zone] || DEFAULT_ZONE_ALIGN; }); }; diff --git a/drag_and_drop_v2/public/js/drag_and_drop_edit.js b/drag_and_drop_v2/public/js/drag_and_drop_edit.js index af7161fb1..b483335c6 100644 --- a/drag_and_drop_v2/public/js/drag_and_drop_edit.js +++ b/drag_and_drop_v2/public/js/drag_and_drop_edit.js @@ -166,14 +166,10 @@ function DragAndDropEditBlock(runtime, element, params) { $(this).addClass('hidden'); $('.save-button', element).parent() .removeClass('hidden') - .one('click', function submitForm(e) { + .on('click', function submitForm(e) { // $itemTab -> submit e.preventDefault(); - if (!self.validate()) { - $(e.target).one('click', submitForm); - return - } _fn.build.form.submit(); }); }); @@ -190,7 +186,7 @@ function DragAndDropEditBlock(runtime, element, params) { }) .on('click', '.remove-zone', _fn.build.form.zone.remove) .on('input', '.zone-row input', _fn.build.form.zone.changedInputHandler) - .on('change', '.align-select', _fn.build.form.zone.changedInputHandler) + .on('change', '.zone-align-select', _fn.build.form.zone.changedInputHandler) .on('click', '.target-image-form button', function(e) { var new_img_url = $.trim($('.target-image-form .background-url', element).val()); if (new_img_url) { @@ -516,19 +512,21 @@ function DragAndDropEditBlock(runtime, element, params) { 'show_problem_header': $element.find('.show-problem-header').is(':checked'), 'item_background_color': $element.find('.item-background-color').val(), 'item_text_color': $element.find('.item-text-color').val(), + 'max_items_per_zone': $element.find('.max-items-per-zone').val(), 'data': _fn.data, }; - $('.xblock-editor-error-message', element).html(); - $('.xblock-editor-error-message', element).css('display', 'none'); var handlerUrl = runtime.handlerUrl(element, 'studio_submit'); + runtime.notify('save', {state: 'start', message: gettext("Saving")}); $.post(handlerUrl, JSON.stringify(data), 'json').done(function(response) { if (response.result === 'success') { - window.location.reload(false); + runtime.notify('save', {state: 'end'}); } else { - $('.xblock-editor-error-message', element) - .html(gettext('Error: ') + response.message); - $('.xblock-editor-error-message', element).css('display', 'block'); + var message = response.messages.join(", "); + runtime.notify('error', { + 'title': window.gettext("There was an error with your form."), + 'message': message + }); } }); } diff --git a/drag_and_drop_v2/templates/html/drag_and_drop_edit.html b/drag_and_drop_v2/templates/html/drag_and_drop_edit.html index 7836892fe..3635f8ee1 100644 --- a/drag_and_drop_v2/templates/html/drag_and_drop_edit.html +++ b/drag_and_drop_v2/templates/html/drag_and_drop_edit.html @@ -174,6 +174,15 @@

{% trans "Items" %}

{% trans fields.item_text_color.help %}
+ +
+ {% trans fields.max_items_per_zone.help %} +
@@ -189,8 +198,7 @@

{% trans "Item definitions" %}

- -
diff --git a/drag_and_drop_v2/translations/en/LC_MESSAGES/text.po b/drag_and_drop_v2/translations/en/LC_MESSAGES/text.po index eb4743526..fd55b0a32 100644 --- a/drag_and_drop_v2/translations/en/LC_MESSAGES/text.po +++ b/drag_and_drop_v2/translations/en/LC_MESSAGES/text.po @@ -89,6 +89,10 @@ msgstr "" msgid "I don't belong anywhere" msgstr "" +#: drag_and_drop_v2.py +#: msgid "This setting limits the number of items that can be dropped into a single zone." +#: msgstr "" + #: drag_and_drop_v2.py #: templates/html/js_templates.html msgid "Title" @@ -204,10 +208,6 @@ msgstr "" msgid "Indicates whether a learner has completed the problem at least once" msgstr "" -#: drag_and_drop_v2.py -msgid "Keeps maximum achieved score by student" -msgstr "" - #: drag_and_drop_v2.py msgid "do_attempt handler should only be called for assessment mode" msgstr "" @@ -224,6 +224,19 @@ msgstr "" msgid "Remove zone" msgstr "" +#: drag_and_drop_v2.py +msgid "Keeps maximum score achieved by student" +msgstr "" + +#: drag_and_drop_v2.py +msgid "Failed to parse \"Maximum items per zone\"" +msgstr "" + +#: drag_and_drop_v2.py +msgid "" +"\"Maximum items per zone\" should be positive integer, got {max_items_per_zone}" +msgstr "" + #: templates/html/js_templates.html msgid "Text" msgstr "" @@ -265,9 +278,7 @@ msgid "right" msgstr "" #: templates/html/js_templates.html -msgid "" -"Align dropped items to the left, center, or right. Default is no alignment " -"(items stay exactly where the user drops them)." +msgid "Align dropped items to the left, center, or right." msgstr "" #: templates/html/js_templates.html @@ -500,10 +511,6 @@ msgstr "" msgid "None" msgstr "" -#: public/js/drag_and_drop_edit.js -msgid "Error: " -msgstr "" - #: utils.py:18 msgid "Final attempt was used, highest score is {score}" msgstr "" diff --git a/drag_and_drop_v2/translations/eo/LC_MESSAGES/text.po b/drag_and_drop_v2/translations/eo/LC_MESSAGES/text.po index 338c05ddb..d4a6d7bfb 100644 --- a/drag_and_drop_v2/translations/eo/LC_MESSAGES/text.po +++ b/drag_and_drop_v2/translations/eo/LC_MESSAGES/text.po @@ -117,6 +117,10 @@ msgstr "Göés änýwhéré Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#" msgid "Title" msgstr "Tïtlé Ⱡ'σяєм ιρѕ#" +#: drag_and_drop_v2.py +#: msgid "This setting limits the number of items that can be dropped into a single zone." +#: msgstr "Thïs séttïng lïmïts thé nümßér öf ïtéms thät çän ßé dröppéd ïntö ä sïnglé zöné." + #: drag_and_drop_v2.py msgid "" "The title of the drag and drop problem. The title is displayed to learners." @@ -194,7 +198,7 @@ msgstr "" #: drag_and_drop_v2.py msgid "Maximum score" -msgstr Mäxïmüm sçöré Ⱡ'σяєм ιρѕυм ∂σłσя ѕι# +msgstr "Mäxïmüm sçöré Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#" #: drag_and_drop_v2.py msgid "The maximum score the learner can receive for the problem." @@ -259,10 +263,6 @@ msgstr "" "Ìndïçätés whéthér ä léärnér häs çömplétéd thé prößlém ät léäst önçé Ⱡ'σяєм " "ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #" -#: drag_and_drop_v2.py -msgid "Keeps maximum achieved score by student" -msgstr "Kééps mäxïmüm äçhïévéd sçöré ßý stüdént Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя#" - #: drag_and_drop_v2.py msgid "do_attempt handler should only be called for assessment mode" msgstr "dö_ättémpt händlér shöüld önlý ßé çälléd för ässéssmént mödé Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#" @@ -279,6 +279,19 @@ msgstr "Ûnknöwn DnDv2 mödé {mode} - çöürsé ïs mïsçönfïgüréd Ⱡ' msgid "Remove zone" msgstr "Rémövé zöné Ⱡ'σяєм ιρѕυм ∂σłσя #" +#: drag_and_drop_v2.py +msgid "Keeps maximum score achieved by student" +msgstr "Kééps mäxïmüm sçöré äçhïévéd ßý stüdént Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя#" + +#: drag_and_drop_v2.py +msgid "Failed to parse \"Maximum items per zone\"" +msgstr "Fäïléd tö pärsé \"Mäxïmüm ïtéms pér zöné\" Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυ#" + +#: drag_and_drop_v2.py +msgid "" +"\"Maximum items per zone\" should be positive integer, got {max_items_per_zone}" +msgstr "\"Mäxïmüm ïtéms pér zöné\" shöüld ßé pösïtïvé ïntégér, göt {max_items_per_zone} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#" + #: templates/html/js_templates.html msgid "Text" msgstr "Téxt Ⱡ'σяєм ι#" @@ -322,12 +335,8 @@ msgid "right" msgstr "rïght Ⱡ'σяєм ιρѕ#" #: templates/html/js_templates.html -msgid "" -"Align dropped items to the left, center, or right. Default is no alignment " -"(items stay exactly where the user drops them)." -msgstr "" -"Àlïgn dröppéd ïtéms tö thé léft, çéntér, ör rïght. Défäült ïs nö älïgnmént " -"(ïtéms stäý éxäçtlý whéré thé üsér dröps thém). Ⱡ'σяєм ιρ#" +msgid "Align dropped items to the left, center, or right." +msgstr "Àlïgn dröppéd ïtéms tö thé léft, çéntér, ör rïght. Ⱡ'σяєм ιρ#" #: templates/html/js_templates.html msgid "Remove item" @@ -590,11 +599,6 @@ msgstr "" msgid "None" msgstr "Nöné Ⱡ'σяєм ι#" -#: public/js/drag_and_drop_edit.js -msgid "Error: " -msgstr "Érrör: Ⱡ'σяєм ιρѕυм #" - - #: utils.py:18 msgid "Fïnäl ättémpt wäs üséd, hïghést sçöré ïs {score} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #" msgstr "" diff --git a/drag_and_drop_v2/utils.py b/drag_and_drop_v2/utils.py index 95bacdedc..d9ffab2be 100644 --- a/drag_and_drop_v2/utils.py +++ b/drag_and_drop_v2/utils.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- """ Drag and Drop v2 XBlock - Utils """ +import copy from collections import namedtuple @@ -82,3 +83,200 @@ def not_placed(number, ngettext=ngettext_fallback): 'ItemStats', ["required", "placed", "correctly_placed", "decoy", "decoy_in_bank"] ) + + +class Constants(object): + """ + Namespace class for various constants + """ + ALLOWED_ZONE_ALIGNMENTS = ['left', 'right', 'center'] + DEFAULT_ZONE_ALIGNMENT = 'center' + + STANDARD_MODE = "standard" + ASSESSMENT_MODE = "assessment" + + +class StateMigration(object): + """ + Helper class to apply zone data and item state migrations + """ + def __init__(self, block): + self._block = block + + @staticmethod + def _apply_migration(obj_id, obj, migrations): + """ + Applies migrations sequentially to a copy of an `obj`, to avoid updating actual data + """ + tmp = copy.deepcopy(obj) + for method in migrations: + tmp = method(obj_id, tmp) + + return tmp + + def apply_zone_migrations(self, zone): + """ + Applies zone migrations + """ + migrations = (self._zone_v1_to_v2, self._zone_v2_to_v2p1) + zone_id = zone.get('uid', zone.get('id')) + + return self._apply_migration(zone_id, zone, migrations) + + def apply_item_state_migrations(self, item_id, item_state): + """ + Applies item_state migrations + """ + migrations = (self._item_state_v1_to_v1p5, self._item_state_v1p5_to_v2, self._item_state_v2_to_v2p1) + + return self._apply_migration(item_id, item_state, migrations) + + @classmethod + def _zone_v1_to_v2(cls, unused_zone_id, zone): + """ + Migrates zone data from v1.0 format to v2.0 format. + + Changes: + * v1 used zone "title" as UID, while v2 zone has dedicated "uid" property + * "id" and "index" properties are no longer used + + In: {'id': 1, 'index': 2, 'title': "Zone", ...} + Out: {'uid': "Zone", ...} + """ + if "uid" not in zone: + zone["uid"] = zone.get("title") + zone.pop("id", None) + zone.pop("index", None) + + return zone + + @classmethod + def _zone_v2_to_v2p1(cls, unused_zone_id, zone): + """ + Migrates zone data from v2.0 to v2.1 + + Changes: + * Removed "none" zone alignment; default align is "center" + + In: { + 'uid': "Zone", "align": "none", + "x_percent": "10%", "y_percent": "10%", "width_percent": "10%", "height_percent": "10%" + } + Out: { + 'uid': "Zone", "align": "center", + "x_percent": "10%", "y_percent": "10%", "width_percent": "10%", "height_percent": "10%" + } + """ + if zone.get('align', None) not in Constants.ALLOWED_ZONE_ALIGNMENTS: + zone['align'] = Constants.DEFAULT_ZONE_ALIGNMENT + + return zone + + @classmethod + def _item_state_v1_to_v1p5(cls, unused_item_id, item): + """ + Migrates item_state from v1.0 to v1.5 + + Changes: + * Item state is now a dict instead of tuple + + In: ('100px', '120px') + Out: {'top': '100px', 'left': '120px'} + """ + if isinstance(item, dict): + return item + else: + return {'top': item[0], 'left': item[1]} + + @classmethod + def _item_state_v1p5_to_v2(cls, unused_item_id, item): + """ + Migrates item_state from v1.5 to v2.0 + + Changes: + * Item placement attributes switched from absolute (left-top) to relative (x_percent-y_percent) units + + In: {'zone': 'Zone", 'correct': True, 'top': '100px', 'left': '120px'} + Out: {'zone': 'Zone", 'correct': True, 'top': '100px', 'left': '120px'} + """ + # Conversion can't be made as parent dimensions are unknown to python - converted in JS + # Since 2.1 JS this conversion became unnecesary, so it was removed from JS code + return item + + def _item_state_v2_to_v2p1(self, item_id, item): + """ + Migrates item_state from v2.0 to v2.1 + + * Single item can correspond to multiple zones - "zone" key is added to each item + * Assessment mode - "correct" key is added to each item + * Removed "no zone align" option; only automatic alignment is now allowed - removes attributes related to + "absolute" placement of an item (relative to background image, as opposed to the zone) + """ + self._multiple_zones_migration(item_id, item) + self._assessment_mode_migration(item) + self._automatic_alignment_migration(item) + + return item + + def _multiple_zones_migration(self, item_id, item): + """ + Changes: + * Adds "zone" attribute + + In: {'item_id': 0} + Out: {'zone': 'Zone", 'item_id": 0} + + In: {'item_id': 1} + Out: {'zone': 'unknown", 'item_id": 1} + """ + if item.get('zone') is None: + valid_zones = self._block.get_item_zones(int(item_id)) + if valid_zones: + # If we get to this point, then the item was placed prior to support for + # multiple correct zones being added. As a result, it can only be correct + # on a single zone, and so we can trust that the item was placed on the + # zone with index 0. + item['zone'] = valid_zones[0] + else: + item['zone'] = 'unknown' + + @classmethod + def _assessment_mode_migration(cls, item): + """ + Changes: + * Adds "correct" attribute if missing + + In: {'item_id': 0} + Out: {'item_id': 'correct': True} + + In: {'item_id': 0, 'correct': True} + Out: {'item_id': 'correct': True} + + In: {'item_id': 0, 'correct': False} + Out: {'item_id': 'correct': False} + """ + # If correctness information is missing + # (because problem was completed before assessment mode was implemented), + # assume the item is in correct zone (in standard mode, only items placed + # into correct zone are stored in item state). + if item.get('correct') is None: + item['correct'] = True + + @classmethod + def _automatic_alignment_migration(cls, item): + """ + Changes: + * Removed old "absolute" placement attributes + * Removed "none" zone alignment, making "x_percent" and "y_percent" attributes obsolete + + In: {'zone': 'Zone", 'correct': True, 'top': '100px', 'left': '120px', 'absolute': true} + Out: {'zone': 'Zone", 'correct': True} + + In: {'zone': 'Zone", 'correct': True, 'x_percent': '90%', 'y_percent': '20%'} + Out: {'zone': 'Zone", 'correct': True} + """ + attributes_to_remove = ['x_percent', 'y_percent', 'left', 'top', 'absolute'] + for attribute in attributes_to_remove: + item.pop(attribute, None) + + return item diff --git a/pylintrc b/pylintrc index fff473365..9d4d6e6af 100644 --- a/pylintrc +++ b/pylintrc @@ -17,7 +17,7 @@ disable= min-similarity-lines=4 [OPTIONS] -good-names=_,__,log,loader +good-names=_,__,logger,loader method-rgx=_?[a-z_][a-z0-9_]{2,40}$ function-rgx=_?[a-z_][a-z0-9_]{2,40}$ method-name-hint=_?[a-z_][a-z0-9_]{2,40}$ diff --git a/tests/integration/test_base.py b/tests/integration/test_base.py index b122174d6..65812b488 100644 --- a/tests/integration/test_base.py +++ b/tests/integration/test_base.py @@ -1,6 +1,11 @@ +# -*- coding: utf-8 -*- +# # Imports ########################################################### +import json from xml.sax.saxutils import escape +from selenium.webdriver import ActionChains +from selenium.webdriver.common.keys import Keys from selenium.webdriver.support.ui import WebDriverWait from bok_choy.promise import EmptyPromise @@ -9,6 +14,13 @@ from xblockutils.base_test import SeleniumBaseTest +from drag_and_drop_v2.utils import Constants + +from drag_and_drop_v2.default_data import ( + DEFAULT_DATA, START_FEEDBACK, FINISH_FEEDBACK, + TOP_ZONE_ID, TOP_ZONE_TITLE, MIDDLE_ZONE_ID, MIDDLE_ZONE_TITLE, BOTTOM_ZONE_ID, BOTTOM_ZONE_TITLE, + ITEM_CORRECT_FEEDBACK, ITEM_INCORRECT_FEEDBACK, ITEM_ANY_ZONE_FEEDBACK, ITEM_NO_ZONE_FEEDBACK, +) # Globals ########################################################### @@ -17,6 +29,14 @@ # Classes ########################################################### +class ItemDefinition(object): + def __init__(self, item_id, zone_ids, zone_title, feedback_positive, feedback_negative): + self.feedback_negative = feedback_negative + self.feedback_positive = feedback_positive + self.zone_ids = zone_ids + self.zone_title = zone_title + self.item_id = item_id + class BaseIntegrationTest(SeleniumBaseTest): default_css_selector = 'section.themed-xblock.xblock--drag-and-drop' @@ -27,8 +47,14 @@ class BaseIntegrationTest(SeleniumBaseTest): "'": "'" } - @staticmethod - def _make_scenario_xml(display_name, show_title, problem_text, completed=False, show_problem_header=True): + # pylint: disable=too-many-arguments + @classmethod + def _make_scenario_xml( + cls, display_name="Test DnDv2", show_title=True, problem_text="Question", completed=False, + show_problem_header=True, max_items_per_zone=0, data=None, mode=Constants.STANDARD_MODE + ): + if not data: + data = json.dumps(DEFAULT_DATA) return """ """.format( @@ -46,6 +75,9 @@ def _make_scenario_xml(display_name, show_title, problem_text, completed=False, problem_text=escape(problem_text), show_problem_header=show_problem_header, completed=completed, + max_items_per_zone=max_items_per_zone, + mode=mode, + data=escape(data, cls._additional_escapes) ) def _get_custom_scenario_xml(self, filename): @@ -137,3 +169,263 @@ def is_ajax_finished(): return self.browser.execute_script("return typeof(jQuery)!='undefined' && jQuery.active==0") EmptyPromise(is_ajax_finished, "Finished waiting for ajax requests.", timeout=timeout).fulfill() + + +class DefaultDataTestMixin(object): + """ + Provides a test scenario with default options. + """ + PAGE_TITLE = 'Drag and Drop v2' + PAGE_ID = 'drag_and_drop_v2' + + items_map = { + 0: ItemDefinition( + 0, [TOP_ZONE_ID], TOP_ZONE_TITLE, + ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK + ), + 1: ItemDefinition( + 1, [MIDDLE_ZONE_ID], MIDDLE_ZONE_TITLE, + ITEM_CORRECT_FEEDBACK.format(zone=MIDDLE_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK + ), + 2: ItemDefinition( + 2, [BOTTOM_ZONE_ID], BOTTOM_ZONE_TITLE, + ITEM_CORRECT_FEEDBACK.format(zone=BOTTOM_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK + ), + 3: ItemDefinition( + 3, [MIDDLE_ZONE_ID, TOP_ZONE_ID, BOTTOM_ZONE_ID], MIDDLE_ZONE_TITLE, + ITEM_ANY_ZONE_FEEDBACK, ITEM_INCORRECT_FEEDBACK + ), + 4: ItemDefinition(4, [], None, "", ITEM_NO_ZONE_FEEDBACK), + } + + all_zones = [ + (TOP_ZONE_ID, TOP_ZONE_TITLE), + (MIDDLE_ZONE_ID, MIDDLE_ZONE_TITLE), + (BOTTOM_ZONE_ID, BOTTOM_ZONE_TITLE) + ] + + feedback = { + "intro": START_FEEDBACK, + "final": FINISH_FEEDBACK, + } + + def _get_scenario_xml(self): # pylint: disable=no-self-use + return "" + + +class InteractionTestBase(object): + @classmethod + def _get_items_with_zone(cls, items_map): + return { + item_key: definition for item_key, definition in items_map.items() + if definition.zone_ids != [] + } + + @classmethod + def _get_items_without_zone(cls, items_map): + return { + item_key: definition for item_key, definition in items_map.items() + if definition.zone_ids == [] + } + + @classmethod + def _get_items_by_zone(cls, items_map): + zone_ids = set([definition.zone_ids[0] for _, definition in items_map.items() if definition.zone_ids]) + return { + zone_id: {item_key: definition for item_key, definition in items_map.items() + if definition.zone_ids and definition.zone_ids[0] is zone_id} + for zone_id in zone_ids + } + + def setUp(self): + super(InteractionTestBase, self).setUp() + + scenario_xml = self._get_scenario_xml() + self._add_scenario(self.PAGE_ID, self.PAGE_TITLE, scenario_xml) + self._page = self.go_to_page(self.PAGE_TITLE) + # Resize window so that the entire drag container is visible. + # Selenium has issues when dragging to an area that is off screen. + self.browser.set_window_size(1024, 800) + + def _get_item_by_value(self, item_value): + return self._page.find_elements_by_xpath(".//div[@data-value='{item_id}']".format(item_id=item_value))[0] + + def _get_unplaced_item_by_value(self, item_value): + items_container = self._get_item_bank() + return items_container.find_elements_by_xpath(".//div[@data-value='{item_id}']".format(item_id=item_value))[0] + + def _get_placed_item_by_value(self, item_value): + items_container = self._page.find_element_by_css_selector('.target') + return items_container.find_elements_by_xpath(".//div[@data-value='{item_id}']".format(item_id=item_value))[0] + + def _get_zone_by_id(self, zone_id): + zones_container = self._page.find_element_by_css_selector('.target') + return zones_container.find_elements_by_xpath(".//div[@data-uid='{zone_id}']".format(zone_id=zone_id))[0] + + def _get_dialog_components(self, dialog): # pylint: disable=no-self-use + dialog_modal_overlay = dialog.find_element_by_css_selector('.modal-window-overlay') + dialog_modal = dialog.find_element_by_css_selector('.modal-window') + return dialog_modal_overlay, dialog_modal + + def _get_dialog_dismiss_button(self, dialog_modal): # pylint: disable=no-self-use + return dialog_modal.find_element_by_css_selector('.modal-dismiss-button') + + def _get_item_bank(self): + return self._page.find_element_by_css_selector('.item-bank') + + def _get_zone_position(self, zone_id): + return self.browser.execute_script( + 'return $("div[data-uid=\'{zone_id}\']").prevAll(".zone").length'.format(zone_id=zone_id) + ) + + def _get_draggable_property(self, item_value): + """ + Returns the value of the 'draggable' property of item. + + Selenium has the element.get_attribute method that looks up properties and attributes, + but for some reason it *always* returns "true" for the 'draggable' property, event though + both the HTML attribute and the DOM property are set to false. + We work around that selenium bug by using JavaScript to get the correct value of 'draggable'. + """ + script = "return $('div.option[data-value={}]').prop('draggable')".format(item_value) + return self.browser.execute_script(script) + + def assertDraggable(self, item_value): + self.assertTrue(self._get_draggable_property(item_value)) + + def assertNotDraggable(self, item_value): + self.assertFalse(self._get_draggable_property(item_value)) + + @staticmethod + def wait_until_ondrop_xhr_finished(elem): + """ + Waits until the XHR request triggered by dropping the item finishes loading. + """ + wait = WebDriverWait(elem, 2) + # While the XHR is in progress, a spinner icon is shown inside the item. + # When the spinner disappears, we can assume that the XHR request has finished. + wait.until( + lambda e: 'fa-spinner' not in e.get_attribute('innerHTML'), + u"Spinner should not be in {}".format(elem.get_attribute('innerHTML')) + ) + + def place_item(self, item_value, zone_id, action_key=None): + """ + Place item with ID of item_value into zone with ID of zone_id. + zone_id=None means place item back to the item bank. + action_key=None means simulate mouse drag/drop instead of placing the item with keyboard. + """ + if action_key is None: + self.drag_item_to_zone(item_value, zone_id) + else: + self.move_item_to_zone(item_value, zone_id, action_key) + self.wait_for_ajax() + + def drag_item_to_zone(self, item_value, zone_id): + """ + Drag item to desired zone using mouse interaction. + zone_id=None means drag item back to the item bank. + """ + element = self._get_item_by_value(item_value) + if zone_id is None: + target = self._get_item_bank() + else: + target = self._get_zone_by_id(zone_id) + action_chains = ActionChains(self.browser) + action_chains.drag_and_drop(element, target).perform() + + def move_item_to_zone(self, item_value, zone_id, action_key): + """ + Place item to descired zone using keybard interaction. + zone_id=None means place item back into the item bank. + """ + # Focus on the item, then press the action key: + item = self._get_item_by_value(item_value) + item.send_keys("") + item.send_keys(action_key) + # Focus is on first *zone* now + self.assert_grabbed_item(item) + # Get desired zone and figure out how many times we have to press Tab to focus the zone. + if zone_id is None: # moving back to the bank + zone = self._get_item_bank() + # When switching focus between zones in keyboard placement mode, + # the item bank always gets focused last (after all regular zones), + # so we have to press Tab once for every regular zone to move focus to the item bank. + tab_press_count = len(self.all_zones) + else: + zone = self._get_zone_by_id(zone_id) + # The number of times we have to press Tab to focus the desired zone equals the zero-based + # position of the zone (zero presses for first zone, one press for second zone, etc). + tab_press_count = self._get_zone_position(zone_id) + for _ in range(tab_press_count): + ActionChains(self.browser).send_keys(Keys.TAB).perform() + zone.send_keys(action_key) + + def assert_grabbed_item(self, item): + self.assertEqual(item.get_attribute('aria-grabbed'), 'true') + + def assert_placed_item(self, item_value, zone_title, assessment_mode=False): + item = self._get_placed_item_by_value(item_value) + self.wait_until_visible(item) + self.wait_until_ondrop_xhr_finished(item) + item_content = item.find_element_by_css_selector('.item-content') + self.wait_until_visible(item_content) + item_description = item.find_element_by_css_selector('.sr') + self.wait_until_visible(item_description) + item_description_id = '-item-{}-description'.format(item_value) + + self.assertEqual(item.get_attribute('aria-grabbed'), 'false') + self.assertEqual(item_content.get_attribute('aria-describedby'), item_description_id) + self.assertEqual(item_description.get_attribute('id'), item_description_id) + if assessment_mode: + self.assertDraggable(item_value) + self.assertEqual(item.get_attribute('class'), 'option') + self.assertEqual(item.get_attribute('tabindex'), '0') + self.assertEqual(item_description.text, 'Placed in: {}'.format(zone_title)) + else: + self.assertNotDraggable(item_value) + self.assertEqual(item.get_attribute('class'), 'option fade') + self.assertIsNone(item.get_attribute('tabindex')) + self.assertEqual(item_description.text, 'Correctly placed in: {}'.format(zone_title)) + + def assert_reverted_item(self, item_value): + item = self._get_item_by_value(item_value) + self.wait_until_visible(item) + self.wait_until_ondrop_xhr_finished(item) + item_content = item.find_element_by_css_selector('.item-content') + + self.assertDraggable(item_value) + self.assertEqual(item.get_attribute('class'), 'option') + self.assertEqual(item.get_attribute('tabindex'), '0') + self.assertEqual(item.get_attribute('aria-grabbed'), 'false') + item_description_id = '-item-{}-description'.format(item_value) + self.assertEqual(item_content.get_attribute('aria-describedby'), item_description_id) + + describedby_text = (u'Press "Enter", "Space", "Ctrl-m", or "⌘-m" on an item to select it for dropping, ' + 'then navigate to the zone you want to drop it on.') + self.assertEqual(item.find_element_by_css_selector('.sr').text, describedby_text) + + def place_decoy_items(self, items_map, action_key): + decoy_items = self._get_items_without_zone(items_map) + # Place decoy items into first available zone. + zone_id, zone_title = self.all_zones[0] + for definition in decoy_items.values(): + self.place_item(definition.item_id, zone_id, action_key) + self.assert_placed_item(definition.item_id, zone_title, assessment_mode=True) + + def assert_decoy_items(self, items_map, assessment_mode=False): + decoy_items = self._get_items_without_zone(items_map) + for item_key in decoy_items: + item = self._get_item_by_value(item_key) + self.assertEqual(item.get_attribute('aria-grabbed'), 'false') + if assessment_mode: + self.assertDraggable(item_key) + self.assertEqual(item.get_attribute('class'), 'option') + else: + self.assertNotDraggable(item_key) + self.assertEqual(item.get_attribute('class'), 'option fade') + + def _switch_to_block(self, idx): + """ Only needed if there are multiple blocks on the page. """ + self._page = self.browser.find_elements_by_css_selector(self.default_css_selector)[idx] + self.scroll_down(0) diff --git a/tests/integration/test_events.py b/tests/integration/test_events.py new file mode 100644 index 000000000..9f71279db --- /dev/null +++ b/tests/integration/test_events.py @@ -0,0 +1,76 @@ +from ddt import ddt, data, unpack +from mock import Mock, patch +from workbench.runtime import WorkbenchRuntime + +from drag_and_drop_v2.default_data import TOP_ZONE_TITLE, TOP_ZONE_ID, ITEM_CORRECT_FEEDBACK + +from .test_base import BaseIntegrationTest, DefaultDataTestMixin +from .test_interaction import ParameterizedTestsMixin +from tests.integration.test_base import InteractionTestBase + + +@ddt +class EventsFiredTest(DefaultDataTestMixin, ParameterizedTestsMixin, InteractionTestBase, BaseIntegrationTest): + """ + Tests that the analytics events are fired and in the proper order. + """ + # These events must be fired in this order. + scenarios = ( + { + 'name': 'edx.drag_and_drop_v2.loaded', + 'data': {}, + }, + { + 'name': 'edx.drag_and_drop_v2.item.picked_up', + 'data': {'item_id': 0}, + }, + { + 'name': 'grade', + 'data': {'max_value': 1, 'value': (2.0 / 5)}, + }, + { + 'name': 'edx.drag_and_drop_v2.item.dropped', + 'data': { + 'is_correct': True, + 'item_id': 0, + 'location': TOP_ZONE_TITLE, + 'location_id': TOP_ZONE_ID, + }, + }, + { + 'name': 'edx.drag_and_drop_v2.feedback.opened', + 'data': { + 'content': ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE), + 'truncated': False, + }, + }, + { + 'name': 'edx.drag_and_drop_v2.feedback.closed', + 'data': { + 'manually': False, + 'content': ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE), + 'truncated': False, + }, + }, + ) + + def setUp(self): + mock = Mock() + context = patch.object(WorkbenchRuntime, 'publish', mock) + context.start() + self.addCleanup(context.stop) + self.publish = mock + super(EventsFiredTest, self).setUp() + + def _get_scenario_xml(self): # pylint: disable=no-self-use + return "" + + @data(*enumerate(scenarios)) # pylint: disable=star-args + @unpack + def test_event(self, index, event): + self.parameterized_item_positive_feedback_on_good_move(self.items_map) + dummy, name, published_data = self.publish.call_args_list[index][0] + self.assertEqual(name, event['name']) + self.assertEqual( + published_data, event['data'] + ) diff --git a/tests/integration/test_interaction.py b/tests/integration/test_interaction.py index 0c824a19a..5c0623b58 100644 --- a/tests/integration/test_interaction.py +++ b/tests/integration/test_interaction.py @@ -4,25 +4,18 @@ # Imports ########################################################### from ddt import ddt, data, unpack -from mock import Mock, patch from selenium.common.exceptions import WebDriverException from selenium.webdriver import ActionChains from selenium.webdriver.common.keys import Keys from selenium.webdriver.support.ui import WebDriverWait - -from workbench.runtime import WorkbenchRuntime from xblockutils.resources import ResourceLoader -from drag_and_drop_v2.default_data import ( - TOP_ZONE_ID, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID, - TOP_ZONE_TITLE, MIDDLE_ZONE_TITLE, BOTTOM_ZONE_TITLE, - ITEM_CORRECT_FEEDBACK, ITEM_INCORRECT_FEEDBACK, ITEM_NO_ZONE_FEEDBACK, - ITEM_ANY_ZONE_FEEDBACK, START_FEEDBACK, FINISH_FEEDBACK +from tests.integration.test_base import ( + DefaultDataTestMixin, InteractionTestBase, ItemDefinition ) from .test_base import BaseIntegrationTest - # Globals ########################################################### loader = ResourceLoader(__name__) @@ -30,227 +23,7 @@ # Classes ########################################################### -class ItemDefinition(object): - def __init__(self, item_id, zone_ids, zone_title, feedback_positive, feedback_negative): - self.feedback_negative = feedback_negative - self.feedback_positive = feedback_positive - self.zone_ids = zone_ids - self.zone_title = zone_title - self.item_id = item_id - - -class InteractionTestBase(object): - @classmethod - def _get_items_with_zone(cls, items_map): - return { - item_key: definition for item_key, definition in items_map.items() - if definition.zone_ids != [] - } - - @classmethod - def _get_items_without_zone(cls, items_map): - return { - item_key: definition for item_key, definition in items_map.items() - if definition.zone_ids == [] - } - - @classmethod - def _get_items_by_zone(cls, items_map): - zone_ids = set([definition.zone_ids[0] for _, definition in items_map.items() if definition.zone_ids]) - return { - zone_id: {item_key: definition for item_key, definition in items_map.items() - if definition.zone_ids and definition.zone_ids[0] is zone_id} - for zone_id in zone_ids - } - - def setUp(self): - super(InteractionTestBase, self).setUp() - - scenario_xml = self._get_scenario_xml() - self._add_scenario(self.PAGE_ID, self.PAGE_TITLE, scenario_xml) - self._page = self.go_to_page(self.PAGE_TITLE) - # Resize window so that the entire drag container is visible. - # Selenium has issues when dragging to an area that is off screen. - self.browser.set_window_size(1024, 800) - - def _get_item_by_value(self, item_value): - return self._page.find_elements_by_xpath(".//div[@data-value='{item_id}']".format(item_id=item_value))[0] - - def _get_unplaced_item_by_value(self, item_value): - items_container = self._get_item_bank() - return items_container.find_elements_by_xpath(".//div[@data-value='{item_id}']".format(item_id=item_value))[0] - - def _get_placed_item_by_value(self, item_value): - items_container = self._page.find_element_by_css_selector('.target') - return items_container.find_elements_by_xpath(".//div[@data-value='{item_id}']".format(item_id=item_value))[0] - - def _get_zone_by_id(self, zone_id): - zones_container = self._page.find_element_by_css_selector('.target') - return zones_container.find_elements_by_xpath(".//div[@data-uid='{zone_id}']".format(zone_id=zone_id))[0] - - def _get_dialog_components(self, dialog): # pylint: disable=no-self-use - dialog_modal_overlay = dialog.find_element_by_css_selector('.modal-window-overlay') - dialog_modal = dialog.find_element_by_css_selector('.modal-window') - return dialog_modal_overlay, dialog_modal - - def _get_dialog_dismiss_button(self, dialog_modal): # pylint: disable=no-self-use - return dialog_modal.find_element_by_css_selector('.modal-dismiss-button') - - def _get_item_bank(self): - return self._page.find_element_by_css_selector('.item-bank') - - def _get_zone_position(self, zone_id): - return self.browser.execute_script( - 'return $("div[data-uid=\'{zone_id}\']").prevAll(".zone").length'.format(zone_id=zone_id) - ) - - def _get_draggable_property(self, item_value): - """ - Returns the value of the 'draggable' property of item. - - Selenium has the element.get_attribute method that looks up properties and attributes, - but for some reason it *always* returns "true" for the 'draggable' property, event though - both the HTML attribute and the DOM property are set to false. - We work around that selenium bug by using JavaScript to get the correct value of 'draggable'. - """ - script = "return $('div.option[data-value={}]').prop('draggable')".format(item_value) - return self.browser.execute_script(script) - - def assertDraggable(self, item_value): - self.assertTrue(self._get_draggable_property(item_value)) - - def assertNotDraggable(self, item_value): - self.assertFalse(self._get_draggable_property(item_value)) - - @staticmethod - def wait_until_ondrop_xhr_finished(elem): - """ - Waits until the XHR request triggered by dropping the item finishes loading. - """ - wait = WebDriverWait(elem, 2) - # While the XHR is in progress, a spinner icon is shown inside the item. - # When the spinner disappears, we can assume that the XHR request has finished. - wait.until( - lambda e: 'fa-spinner' not in e.get_attribute('innerHTML'), - u"Spinner should not be in {}".format(elem.get_attribute('innerHTML')) - ) - - def place_item(self, item_value, zone_id, action_key=None): - """ - Place item with ID of item_value into zone with ID of zone_id. - zone_id=None means place item back to the item bank. - action_key=None means simulate mouse drag/drop instead of placing the item with keyboard. - """ - if action_key is None: - self.drag_item_to_zone(item_value, zone_id) - else: - self.move_item_to_zone(item_value, zone_id, action_key) - self.wait_for_ajax() - - def drag_item_to_zone(self, item_value, zone_id): - """ - Drag item to desired zone using mouse interaction. - zone_id=None means drag item back to the item bank. - """ - element = self._get_item_by_value(item_value) - if zone_id is None: - target = self._get_item_bank() - else: - target = self._get_zone_by_id(zone_id) - action_chains = ActionChains(self.browser) - action_chains.drag_and_drop(element, target).perform() - - def move_item_to_zone(self, item_value, zone_id, action_key): - """ - Place item to descired zone using keybard interaction. - zone_id=None means place item back into the item bank. - """ - # Focus on the item, then press the action key: - item = self._get_item_by_value(item_value) - item.send_keys("") - item.send_keys(action_key) - # Focus is on first *zone* now - self.assert_grabbed_item(item) - # Get desired zone and figure out how many times we have to press Tab to focus the zone. - if zone_id is None: # moving back to the bank - zone = self._get_item_bank() - # When switching focus between zones in keyboard placement mode, - # the item bank always gets focused last (after all regular zones), - # so we have to press Tab once for every regular zone to move focus to the item bank. - tab_press_count = len(self.all_zones) - else: - zone = self._get_zone_by_id(zone_id) - # The number of times we have to press Tab to focus the desired zone equals the zero-based - # position of the zone (zero presses for first zone, one press for second zone, etc). - tab_press_count = self._get_zone_position(zone_id) - for _ in range(tab_press_count): - ActionChains(self.browser).send_keys(Keys.TAB).perform() - zone.send_keys(action_key) - - def assert_grabbed_item(self, item): - self.assertEqual(item.get_attribute('aria-grabbed'), 'true') - - def assert_placed_item(self, item_value, zone_title, assessment_mode=False): - item = self._get_placed_item_by_value(item_value) - self.wait_until_visible(item) - self.wait_until_ondrop_xhr_finished(item) - item_content = item.find_element_by_css_selector('.item-content') - self.wait_until_visible(item_content) - item_description = item.find_element_by_css_selector('.sr') - self.wait_until_visible(item_description) - item_description_id = '-item-{}-description'.format(item_value) - - self.assertEqual(item.get_attribute('aria-grabbed'), 'false') - self.assertEqual(item_content.get_attribute('aria-describedby'), item_description_id) - self.assertEqual(item_description.get_attribute('id'), item_description_id) - if assessment_mode: - self.assertDraggable(item_value) - self.assertEqual(item.get_attribute('class'), 'option') - self.assertEqual(item.get_attribute('tabindex'), '0') - self.assertEqual(item_description.text, 'Placed in: {}'.format(zone_title)) - else: - self.assertNotDraggable(item_value) - self.assertEqual(item.get_attribute('class'), 'option fade') - self.assertIsNone(item.get_attribute('tabindex')) - self.assertEqual(item_description.text, 'Correctly placed in: {}'.format(zone_title)) - - def assert_reverted_item(self, item_value): - item = self._get_item_by_value(item_value) - self.wait_until_visible(item) - self.wait_until_ondrop_xhr_finished(item) - item_content = item.find_element_by_css_selector('.item-content') - - self.assertDraggable(item_value) - self.assertEqual(item.get_attribute('class'), 'option') - self.assertEqual(item.get_attribute('tabindex'), '0') - self.assertEqual(item.get_attribute('aria-grabbed'), 'false') - item_description_id = '-item-{}-description'.format(item_value) - self.assertEqual(item_content.get_attribute('aria-describedby'), item_description_id) - - describedby_text = (u'Press "Enter", "Space", "Ctrl-m", or "⌘-m" on an item to select it for dropping, ' - 'then navigate to the zone you want to drop it on.') - self.assertEqual(item.find_element_by_css_selector('.sr').text, describedby_text) - - def place_decoy_items(self, items_map, action_key): - decoy_items = self._get_items_without_zone(items_map) - # Place decoy items into first available zone. - zone_id, zone_title = self.all_zones[0] - for definition in decoy_items.values(): - self.place_item(definition.item_id, zone_id, action_key) - self.assert_placed_item(definition.item_id, zone_title, assessment_mode=True) - - def assert_decoy_items(self, items_map, assessment_mode=False): - decoy_items = self._get_items_without_zone(items_map) - for item_key in decoy_items: - item = self._get_item_by_value(item_key) - self.assertEqual(item.get_attribute('aria-grabbed'), 'false') - if assessment_mode: - self.assertDraggable(item_key) - self.assertEqual(item.get_attribute('class'), 'option') - else: - self.assertNotDraggable(item_key) - self.assertEqual(item.get_attribute('class'), 'option fade') - +class ParameterizedTestsMixin(object): def parameterized_item_positive_feedback_on_good_move( self, items_map, scroll_down=100, action_key=None, assessment_mode=False ): @@ -429,56 +202,9 @@ def interact_with_keyboard_help(self, scroll_down=250, use_keyboard=False): self.assertFalse(dialog_modal_overlay.is_displayed()) self.assertFalse(dialog_modal.is_displayed()) - def _switch_to_block(self, idx): - """ Only needed if ther eare multiple blocks on the page. """ - self._page = self.browser.find_elements_by_css_selector(self.default_css_selector)[idx] - self.scroll_down(0) - - -class DefaultDataTestMixin(object): - """ - Provides a test scenario with default options. - """ - PAGE_TITLE = 'Drag and Drop v2' - PAGE_ID = 'drag_and_drop_v2' - - items_map = { - 0: ItemDefinition( - 0, [TOP_ZONE_ID], TOP_ZONE_TITLE, - ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK - ), - 1: ItemDefinition( - 1, [MIDDLE_ZONE_ID], MIDDLE_ZONE_TITLE, - ITEM_CORRECT_FEEDBACK.format(zone=MIDDLE_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK - ), - 2: ItemDefinition( - 2, [BOTTOM_ZONE_ID], BOTTOM_ZONE_TITLE, - ITEM_CORRECT_FEEDBACK.format(zone=BOTTOM_ZONE_TITLE), ITEM_INCORRECT_FEEDBACK - ), - 3: ItemDefinition( - 3, [MIDDLE_ZONE_ID, TOP_ZONE_ID, BOTTOM_ZONE_ID], MIDDLE_ZONE_TITLE, - ITEM_ANY_ZONE_FEEDBACK, ITEM_INCORRECT_FEEDBACK - ), - 4: ItemDefinition(4, [], None, "", ITEM_NO_ZONE_FEEDBACK), - } - - all_zones = [ - (TOP_ZONE_ID, TOP_ZONE_TITLE), - (MIDDLE_ZONE_ID, MIDDLE_ZONE_TITLE), - (BOTTOM_ZONE_ID, BOTTOM_ZONE_TITLE) - ] - - feedback = { - "intro": START_FEEDBACK, - "final": FINISH_FEEDBACK, - } - - def _get_scenario_xml(self): # pylint: disable=no-self-use - return "" - @ddt -class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest): +class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, ParameterizedTestsMixin, BaseIntegrationTest): """ Testing interactions with Drag and Drop XBlock against default data. All interactions are tested using mouse (action_key=None) and four different keyboard action keys. @@ -571,73 +297,6 @@ def _get_scenario_xml(self): return self._get_custom_scenario_xml("data/test_multiple_options_data.json") -@ddt -class EventsFiredTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest): - """ - Tests that the analytics events are fired and in the proper order. - """ - # These events must be fired in this order. - scenarios = ( - { - 'name': 'edx.drag_and_drop_v2.loaded', - 'data': {}, - }, - { - 'name': 'edx.drag_and_drop_v2.item.picked_up', - 'data': {'item_id': 0}, - }, - { - 'name': 'grade', - 'data': {'max_value': 1, 'value': (2.0 / 5)}, - }, - { - 'name': 'edx.drag_and_drop_v2.item.dropped', - 'data': { - 'is_correct': True, - 'item_id': 0, - 'location': TOP_ZONE_TITLE, - 'location_id': TOP_ZONE_ID, - }, - }, - { - 'name': 'edx.drag_and_drop_v2.feedback.opened', - 'data': { - 'content': ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE), - 'truncated': False, - }, - }, - { - 'name': 'edx.drag_and_drop_v2.feedback.closed', - 'data': { - 'manually': False, - 'content': ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE), - 'truncated': False, - }, - }, - ) - - def setUp(self): - mock = Mock() - context = patch.object(WorkbenchRuntime, 'publish', mock) - context.start() - self.addCleanup(context.stop) - self.publish = mock - super(EventsFiredTest, self).setUp() - - def _get_scenario_xml(self): # pylint: disable=no-self-use - return "" - - @data(*enumerate(scenarios)) # pylint: disable=star-args - @unpack - def test_event(self, index, event): - self.parameterized_item_positive_feedback_on_good_move(self.items_map) - dummy, name, published_data = self.publish.call_args_list[index][0] - self.assertEqual(name, event['name']) - self.assertEqual( - published_data, event['data'] - ) - - class PreventSpaceBarScrollTest(DefaultDataTestMixin, InteractionTestBase, BaseIntegrationTest): """" Test that browser default page down action is prevented when pressing the space bar while @@ -709,7 +368,7 @@ def _get_scenario_xml(self): return self._get_custom_scenario_xml("data/test_html_data.json") -class MultipleBlocksDataInteraction(InteractionTestBase, BaseIntegrationTest): +class MultipleBlocksDataInteraction(ParameterizedTestsMixin, InteractionTestBase, BaseIntegrationTest): PAGE_TITLE = 'Drag and Drop v2 Multiple Blocks' PAGE_ID = 'drag_and_drop_v2_multi' @@ -821,35 +480,11 @@ def _assert_zone_align_item(self, item_id, zone_id, align, action_key=None): self.assertEquals(self._get_style(zone_item_selector, 'left'), '0px') self.assertEquals(self._get_style(zone_item_selector, 'top'), '0px') - # Center-aligned items are display block - if align == 'center': - self.assertEquals(self._get_style(zone_item_selector, 'display'), 'block') - # but other aligned items are just inline-block - else: - self.assertEquals(self._get_style(zone_item_selector, 'display'), 'inline-block') - - def test_no_zone_align(self): - """ - Test items placed in a zone with no align setting. - Ensure that they are children of div.target, not the zone. - """ - zone_id = "Zone No Align" - self.place_item(0, zone_id) - zone_item_selector = "div[data-uid='{zone_id}'] .item-wrapper .option".format(zone_id=zone_id) - self.assertEquals(len(self._page.find_elements_by_css_selector(zone_item_selector)), 0) - - target_item_selector = '.target > .option' - placed_items = self._page.find_elements_by_css_selector(target_item_selector) - self.assertEquals(len(placed_items), 1) - self.assertEquals(placed_items[0].get_attribute('data-value'), '0') - - # Non-aligned items are absolute positioned, with top/bottom set to px - self.assertEquals(self._get_style(target_item_selector, 'position'), 'absolute') - self.assertRegexpMatches(self._get_style(target_item_selector, 'left'), r'^\d+(\.\d+)?px$') - self.assertRegexpMatches(self._get_style(target_item_selector, 'top'), r'^\d+(\.\d+)?px$') + self.assertEquals(self._get_style(zone_item_selector, 'display'), 'inline-block') @data( - ([3, 4, 5], "Zone Invalid Align", "start"), + ([0, 1, 2], "Zone No Align", "center"), + ([3, 4, 5], "Zone Invalid Align", "center"), ([6, 7, 8], "Zone Left Align", "left"), ([9, 10, 11], "Zone Right Align", "right"), ([12, 13, 14], "Zone Center Align", "center"), @@ -865,3 +500,60 @@ def test_zone_align(self, items, zone, alignment): reset.click() self.scroll_down(pixels=0) self.wait_until_disabled(reset) + + +class TestMaxItemsPerZone(InteractionTestBase, BaseIntegrationTest): + """ + Tests for max items per dropzone feature + """ + PAGE_TITLE = 'Drag and Drop v2' + PAGE_ID = 'drag_and_drop_v2' + + assessment_mode = False + + def _get_scenario_xml(self): + scenario_data = loader.load_unicode("data/test_zone_align.json") + return self._make_scenario_xml(data=scenario_data, max_items_per_zone=2) + + def test_item_returned_to_bank(self): + """ + Tests that an item is returned to bank if max items per zone reached + """ + zone_id = "Zone No Align" + self.place_item(0, zone_id) + self.place_item(1, zone_id) + + # precondition check - max items placed into zone + self.assert_placed_item(0, zone_id, assessment_mode=self.assessment_mode) + self.assert_placed_item(1, zone_id, assessment_mode=self.assessment_mode) + + self.place_item(2, zone_id) + + self.assert_reverted_item(2) + feedback_popup = self._get_popup() + self.assertTrue(feedback_popup.is_displayed()) + + feedback_popup_content = self._get_popup_content() + self.assertEqual( + feedback_popup_content.get_attribute('innerHTML'), + "You cannot add any more items to this zone." + ) + + def test_item_returned_to_bank_after_refresh(self): + zone_id = "Zone Left Align" + self.place_item(6, zone_id) + self.place_item(7, zone_id) + + # precondition check - max items placed into zone + self.assert_placed_item(6, zone_id, assessment_mode=self.assessment_mode) + self.assert_placed_item(7, zone_id, assessment_mode=self.assessment_mode) + + self.place_item(8, zone_id) + + self.assert_reverted_item(8) + + self._page = self.go_to_page(self.PAGE_TITLE) # refresh the page + + self.assert_placed_item(6, zone_id, assessment_mode=self.assessment_mode) + self.assert_placed_item(7, zone_id, assessment_mode=self.assessment_mode) + self.assert_reverted_item(8) diff --git a/tests/integration/test_interaction_assessment.py b/tests/integration/test_interaction_assessment.py index 0030c876a..481d2438b 100644 --- a/tests/integration/test_interaction_assessment.py +++ b/tests/integration/test_interaction_assessment.py @@ -13,9 +13,9 @@ TOP_ZONE_ID, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID, TOP_ZONE_TITLE, START_FEEDBACK, FINISH_FEEDBACK ) -from drag_and_drop_v2.utils import FeedbackMessages +from drag_and_drop_v2.utils import FeedbackMessages, Constants from .test_base import BaseIntegrationTest -from .test_interaction import InteractionTestBase, DefaultDataTestMixin +from .test_interaction import InteractionTestBase, DefaultDataTestMixin, ParameterizedTestsMixin, TestMaxItemsPerZone # Globals ########################################################### @@ -33,8 +33,8 @@ class DefaultAssessmentDataTestMixin(DefaultDataTestMixin): def _get_scenario_xml(self): # pylint: disable=no-self-use return """ - - """.format(max_attempts=self.MAX_ATTEMPTS) + + """.format(mode=Constants.ASSESSMENT_MODE, max_attempts=self.MAX_ATTEMPTS) class AssessmentTestMixin(object): @@ -57,7 +57,8 @@ def click_submit(self): @ddt class AssessmentInteractionTest( - DefaultAssessmentDataTestMixin, AssessmentTestMixin, InteractionTestBase, BaseIntegrationTest + DefaultAssessmentDataTestMixin, AssessmentTestMixin, ParameterizedTestsMixin, + InteractionTestBase, BaseIntegrationTest ): """ Testing interactions with Drag and Drop XBlock against default data in assessment mode. @@ -217,3 +218,28 @@ def test_grade(self): published_grade = next((event[0][2] for event in events if event[0][1] == 'grade')) expected_grade = {'max_value': 1, 'value': (1.0 / 5.0)} self.assertEqual(published_grade, expected_grade) + + +class TestMaxItemsPerZoneAssessment(TestMaxItemsPerZone): + assessment_mode = True + + def _get_scenario_xml(self): + scenario_data = loader.load_unicode("data/test_zone_align.json") + return self._make_scenario_xml(data=scenario_data, max_items_per_zone=2, mode=Constants.ASSESSMENT_MODE) + + def test_drop_item_to_same_zone_does_not_show_popup(self): + zone_id = "Zone Left Align" + self.place_item(6, zone_id) + self.place_item(7, zone_id) + + popup = self._get_popup() + + # precondition check - max items placed into zone + self.assert_placed_item(6, zone_id, assessment_mode=self.assessment_mode) + self.assert_placed_item(7, zone_id, assessment_mode=self.assessment_mode) + + self.place_item(6, zone_id, Keys.RETURN) + self.assertFalse(popup.is_displayed()) + + self.place_item(7, zone_id, Keys.RETURN) + self.assertFalse(popup.is_displayed()) diff --git a/tests/integration/test_render.py b/tests/integration/test_render.py index 20555ce68..7eecee403 100644 --- a/tests/integration/test_render.py +++ b/tests/integration/test_render.py @@ -189,7 +189,7 @@ def test_zones(self): self.assertEqual(zone.get_attribute('dropzone'), 'move') self.assertEqual(zone.get_attribute('aria-dropeffect'), 'move') self.assertEqual(zone.get_attribute('data-uid'), 'Zone {}'.format(zone_number)) - self.assertEqual(zone.get_attribute('data-zone_align'), 'none') + self.assertEqual(zone.get_attribute('data-zone_align'), 'center') self.assertIn('ui-droppable', self.get_element_classes(zone)) zone_box_percentages = box_percentages[index] self._assert_box_percentages( # pylint: disable=star-args @@ -293,8 +293,8 @@ def setUp(self): def test_zone_align(self): expected_alignments = { - "#-Zone_No_Align": "start", - "#-Zone_Invalid_Align": "start", + "#-Zone_No_Align": "center", + "#-Zone_Invalid_Align": "center", "#-Zone_Left_Align": "left", "#-Zone_Right_Align": "right", "#-Zone_Center_Align": "center" diff --git a/tests/integration/test_sizing.py b/tests/integration/test_sizing.py index f7af93bbc..a814484b3 100644 --- a/tests/integration/test_sizing.py +++ b/tests/integration/test_sizing.py @@ -8,7 +8,7 @@ from xblockutils.resources import ResourceLoader from .test_base import BaseIntegrationTest -from .test_interaction import InteractionTestBase +from tests.integration.test_base import InteractionTestBase loader = ResourceLoader(__name__) @@ -82,7 +82,7 @@ def _get_scenario_xml(cls): EXPECTATIONS = [ # The text 'Auto' with no fixed size specified should be 5-20% wide - Expectation(item_id=0, zone_id=ZONE_33, width_percent=[5, 20]), + Expectation(item_id=0, zone_id=ZONE_33, width_percent=[5, AUTO_MAX_WIDTH]), # The long text with no fixed size specified should be wrapped at the maximum width Expectation(item_id=1, zone_id=ZONE_33, width_percent=AUTO_MAX_WIDTH), # The text items that specify specific widths as a percentage of the background image: diff --git a/tests/unit/data/assessment/config_out.json b/tests/unit/data/assessment/config_out.json index 9ed8c32b4..e4b9d3066 100644 --- a/tests/unit/data/assessment/config_out.json +++ b/tests/unit/data/assessment/config_out.json @@ -13,6 +13,7 @@ "display_zone_borders": false, "display_zone_labels": false, "url_name": "test", + "max_items_per_zone": null, "zones": [ { @@ -21,7 +22,8 @@ "x": 234, "width": 345, "height": 456, - "uid": "zone-1" + "uid": "zone-1", + "align": "right" }, { "title": "Zone 2", @@ -29,7 +31,8 @@ "x": 10, "width": 30, "height": 40, - "uid": "zone-2" + "uid": "zone-2", + "align": "center" } ], diff --git a/tests/unit/data/assessment/data.json b/tests/unit/data/assessment/data.json index f878a5cf6..84cd9b263 100644 --- a/tests/unit/data/assessment/data.json +++ b/tests/unit/data/assessment/data.json @@ -6,7 +6,8 @@ "x": 234, "width": 345, "height": 456, - "uid": "zone-1" + "uid": "zone-1", + "align": "right" }, { "title": "Zone 2", diff --git a/tests/unit/data/html/config_out.json b/tests/unit/data/html/config_out.json index 7e809ea43..87f54a06e 100644 --- a/tests/unit/data/html/config_out.json +++ b/tests/unit/data/html/config_out.json @@ -13,6 +13,7 @@ "display_zone_borders": false, "display_zone_labels": false, "url_name": "unique_name", + "max_items_per_zone": null, "zones": [ { @@ -21,7 +22,8 @@ "y": 200, "width": 200, "height": 100, - "uid": "Zone 1" + "uid": "Zone 1", + "align": "right" }, { "title": "Zone 2", @@ -29,7 +31,8 @@ "y": 0, "width": 200, "height": 100, - "uid": "Zone 2" + "uid": "Zone 2", + "align": "center" } ], diff --git a/tests/unit/data/html/data.json b/tests/unit/data/html/data.json index d934c3ec5..59ce9e961 100644 --- a/tests/unit/data/html/data.json +++ b/tests/unit/data/html/data.json @@ -7,7 +7,8 @@ "height": 100, "y": 200, "x": 100, - "id": "zone-1" + "id": "zone-1", + "align": "right" }, { "index": 2, @@ -16,7 +17,8 @@ "height": 100, "y": 0, "x": 0, - "id": "zone-2" + "id": "zone-2", + "align": "center" } ], diff --git a/tests/unit/data/old/config_out.json b/tests/unit/data/old/config_out.json index f4c0dd888..8cc1e5100 100644 --- a/tests/unit/data/old/config_out.json +++ b/tests/unit/data/old/config_out.json @@ -13,6 +13,7 @@ "display_zone_borders": false, "display_zone_labels": false, "url_name": "", + "max_items_per_zone": null, "zones": [ { @@ -21,7 +22,8 @@ "y": "200", "width": 200, "height": 100, - "uid": "Zone 1" + "uid": "Zone 1", + "align": "center" }, { "title": "Zone 2", @@ -29,7 +31,8 @@ "y": 0, "width": 200, "height": 100, - "uid": "Zone 2" + "uid": "Zone 2", + "align": "center" } ], diff --git a/tests/unit/data/plain/config_out.json b/tests/unit/data/plain/config_out.json index fd116d481..8957e22a4 100644 --- a/tests/unit/data/plain/config_out.json +++ b/tests/unit/data/plain/config_out.json @@ -13,6 +13,7 @@ "display_zone_borders": false, "display_zone_labels": false, "url_name": "test", + "max_items_per_zone": 4, "zones": [ { @@ -21,7 +22,8 @@ "x": 234, "width": 345, "height": 456, - "uid": "zone-1" + "uid": "zone-1", + "align": "left" }, { "title": "Zone 2", @@ -29,7 +31,8 @@ "x": 10, "width": 30, "height": 40, - "uid": "zone-2" + "uid": "zone-2", + "align": "center" } ], diff --git a/tests/unit/data/plain/data.json b/tests/unit/data/plain/data.json index 849ae7937..7cd600e3d 100644 --- a/tests/unit/data/plain/data.json +++ b/tests/unit/data/plain/data.json @@ -6,7 +6,8 @@ "x": 234, "width": 345, "height": 456, - "uid": "zone-1" + "uid": "zone-1", + "align": "left" }, { "title": "Zone 2", @@ -14,7 +15,8 @@ "x": 10, "width": 30, "height": 40, - "uid": "zone-2" + "uid": "zone-2", + "align": "center" } ], diff --git a/tests/unit/data/plain/settings.json b/tests/unit/data/plain/settings.json index 27d7c22e5..5f051896f 100644 --- a/tests/unit/data/plain/settings.json +++ b/tests/unit/data/plain/settings.json @@ -7,5 +7,6 @@ "weight": 1, "item_background_color": "", "item_text_color": "", - "url_name": "test" + "url_name": "test", + "max_items_per_zone": 4 } diff --git a/tests/unit/test_advanced.py b/tests/unit/test_advanced.py index 7fb0bfb3b..dc134c223 100644 --- a/tests/unit/test_advanced.py +++ b/tests/unit/test_advanced.py @@ -75,7 +75,7 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture): """ def test_drop_item_wrong_with_feedback(self): item_id, zone_id = 0, self.ZONE_2 - data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"} + data = {"val": item_id, "zone": zone_id} res = self.call_handler(self.DROP_ITEM_HANDLER, data) self.assertEqual(res, { "overall_feedback": [self._make_feedback_message(message=self.INITIAL_FEEDBACK)], @@ -86,7 +86,7 @@ def test_drop_item_wrong_with_feedback(self): def test_drop_item_wrong_without_feedback(self): item_id, zone_id = 2, self.ZONE_1 - data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"} + data = {"val": item_id, "zone": zone_id} res = self.call_handler(self.DROP_ITEM_HANDLER, data) self.assertEqual(res, { "overall_feedback": [self._make_feedback_message(message=self.INITIAL_FEEDBACK)], @@ -97,7 +97,7 @@ def test_drop_item_wrong_without_feedback(self): def test_drop_item_correct(self): item_id, zone_id = 0, self.ZONE_1 - data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"} + data = {"val": item_id, "zone": zone_id} res = self.call_handler(self.DROP_ITEM_HANDLER, data) self.assertEqual(res, { "overall_feedback": [self._make_feedback_message(message=self.INITIAL_FEEDBACK)], @@ -114,27 +114,23 @@ def mock_publish(_, event, params): published_grades.append(params) self.block.runtime.publish = mock_publish - self.call_handler(self.DROP_ITEM_HANDLER, { - "val": 0, "zone": self.ZONE_1, "y_percent": "11%", "x_percent": "33%" - }) + self.call_handler(self.DROP_ITEM_HANDLER, {"val": 0, "zone": self.ZONE_1}) self.assertEqual(1, len(published_grades)) self.assertEqual({'value': 0.75, 'max_value': 1}, published_grades[-1]) - self.call_handler(self.DROP_ITEM_HANDLER, { - "val": 1, "zone": self.ZONE_2, "y_percent": "90%", "x_percent": "42%" - }) + self.call_handler(self.DROP_ITEM_HANDLER, {"val": 1, "zone": self.ZONE_2}) self.assertEqual(2, len(published_grades)) self.assertEqual({'value': 1, 'max_value': 1}, published_grades[-1]) def test_drop_item_final(self): - data = {"val": 0, "zone": self.ZONE_1, "x_percent": "33%", "y_percent": "11%"} + data = {"val": 0, "zone": self.ZONE_1} self.call_handler(self.DROP_ITEM_HANDLER, data) expected_state = { "items": { - "0": {"x_percent": "33%", "y_percent": "11%", "correct": True, "zone": self.ZONE_1} + "0": {"correct": True, "zone": self.ZONE_1} }, "finished": False, "attempts": 0, @@ -142,8 +138,7 @@ def test_drop_item_final(self): } self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET")) - data = {"val": 1, "zone": self.ZONE_2, "x_percent": "22%", "y_percent": "22%"} - res = self.call_handler(self.DROP_ITEM_HANDLER, data) + res = self.call_handler(self.DROP_ITEM_HANDLER, {"val": 1, "zone": self.ZONE_2}) self.assertEqual(res, { "overall_feedback": [self._make_feedback_message(message=self.FINAL_FEEDBACK)], "finished": True, @@ -153,12 +148,8 @@ def test_drop_item_final(self): expected_state = { "items": { - "0": { - "x_percent": "33%", "y_percent": "11%", "correct": True, "zone": self.ZONE_1, - }, - "1": { - "x_percent": "22%", "y_percent": "22%", "correct": True, "zone": self.ZONE_2, - } + "0": {"correct": True, "zone": self.ZONE_1}, + "1": {"correct": True, "zone": self.ZONE_2} }, "finished": True, "attempts": 0, @@ -182,9 +173,7 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture): """ @staticmethod def _make_submission(item_id, zone_id): - x_percent, y_percent = str(random.randint(0, 100)) + '%', str(random.randint(0, 100)) + '%' - data = {"val": item_id, "zone": zone_id, "x_percent": x_percent, "y_percent": y_percent} - return data + return {"val": item_id, "zone": zone_id} def _submit_solution(self, solution): for item_id, zone_id in solution.iteritems(): @@ -209,12 +198,11 @@ def test_multiple_drop_item(self): item_zone_map = {0: self.ZONE_1, 1: self.ZONE_2} for item_id, zone_id in item_zone_map.iteritems(): data = self._make_submission(item_id, zone_id) - x_percent, y_percent = data['x_percent'], data['y_percent'] res = self.call_handler(self.DROP_ITEM_HANDLER, data) self.assertEqual(res, {}) - expected_item_state = {'zone': zone_id, 'correct': True, 'x_percent': x_percent, 'y_percent': y_percent} + expected_item_state = {'zone': zone_id, 'correct': True} self.assertIn(str(item_id), self.block.item_state) self.assertEqual(self.block.item_state[str(item_id)], expected_item_state) diff --git a/tests/unit/test_basics.py b/tests/unit/test_basics.py index fb8af4d90..5d21c6de0 100644 --- a/tests/unit/test_basics.py +++ b/tests/unit/test_basics.py @@ -1,6 +1,7 @@ +import ddt import unittest -from drag_and_drop_v2.drag_and_drop_v2 import DragAndDropBlock +from drag_and_drop_v2.utils import Constants from drag_and_drop_v2.default_data import ( TARGET_IMG_DESCRIPTION, TOP_ZONE_ID, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID, START_FEEDBACK, FINISH_FEEDBACK, DEFAULT_DATA @@ -8,6 +9,7 @@ from ..utils import make_block, TestCaseMixin +@ddt.ddt class BasicTests(TestCaseMixin, unittest.TestCase): """ Basic unit tests for the Drag and Drop block, using its default settings """ @@ -15,6 +17,30 @@ def setUp(self): self.block = make_block() self.patch_workbench() + @staticmethod + def _make_submission(modify_submission=None): + modify = modify_submission if modify_submission else lambda x: x + + submission = { + 'display_name': "Test Drag & Drop", + 'mode': Constants.STANDARD_MODE, + 'max_attempts': 1, + 'show_title': False, + 'problem_text': "Problem Drag & Drop", + 'show_problem_header': False, + 'item_background_color': 'cornflowerblue', + 'item_text_color': 'coral', + 'weight': '5', + 'data': { + 'foo': 1, + 'items': [] + }, + } + + modify(submission) + + return submission + def test_template_contents(self): context = {} student_fragment = self.block.runtime.render(self.block, 'student_view', context) @@ -30,13 +56,14 @@ def test_get_configuration(self): zones = config.pop("zones") items = config.pop("items") self.assertEqual(config, { - "mode": DragAndDropBlock.STANDARD_MODE, + "mode": Constants.STANDARD_MODE, "max_attempts": None, "display_zone_borders": False, "display_zone_labels": False, "title": "Drag and Drop", "show_title": True, "problem_text": "", + "max_items_per_zone": None, "show_problem_header": True, "target_img_expanded_url": '/expanded/url/to/drag_and_drop_v2/public/img/triangle.png', "target_img_description": TARGET_IMG_DESCRIPTION, @@ -75,29 +102,29 @@ def assert_user_state_empty(): assert_user_state_empty() # Drag three items into the correct spot: - data = {"val": 0, "zone": TOP_ZONE_ID, "x_percent": "33%", "y_percent": "11%"} + data = {"val": 0, "zone": TOP_ZONE_ID} self.call_handler(self.DROP_ITEM_HANDLER, data) - data = {"val": 1, "zone": MIDDLE_ZONE_ID, "x_percent": "67%", "y_percent": "80%"} + data = {"val": 1, "zone": MIDDLE_ZONE_ID} self.call_handler(self.DROP_ITEM_HANDLER, data) - data = {"val": 2, "zone": BOTTOM_ZONE_ID, "x_percent": "99%", "y_percent": "95%"} + data = {"val": 2, "zone": BOTTOM_ZONE_ID} self.call_handler(self.DROP_ITEM_HANDLER, data) - data = {"val": 3, "zone": MIDDLE_ZONE_ID, "x_percent": "67%", "y_percent": "80%"} + data = {"val": 3, "zone": MIDDLE_ZONE_ID} self.call_handler(self.DROP_ITEM_HANDLER, data) # Check the result: self.assertTrue(self.block.completed) self.assertEqual(self.block.item_state, { - '0': {'x_percent': '33%', 'y_percent': '11%', 'correct': True, 'zone': TOP_ZONE_ID}, - '1': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, 'zone': MIDDLE_ZONE_ID}, - '2': {'x_percent': '99%', 'y_percent': '95%', 'correct': True, 'zone': BOTTOM_ZONE_ID}, - '3': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, "zone": MIDDLE_ZONE_ID}, + '0': {'correct': True, 'zone': TOP_ZONE_ID}, + '1': {'correct': True, 'zone': MIDDLE_ZONE_ID}, + '2': {'correct': True, 'zone': BOTTOM_ZONE_ID}, + '3': {'correct': True, "zone": MIDDLE_ZONE_ID}, }) self.assertEqual(self.call_handler('get_user_state'), { 'items': { - '0': {'x_percent': '33%', 'y_percent': '11%', 'correct': True, 'zone': TOP_ZONE_ID}, - '1': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, 'zone': MIDDLE_ZONE_ID}, - '2': {'x_percent': '99%', 'y_percent': '95%', 'correct': True, 'zone': BOTTOM_ZONE_ID}, - '3': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, "zone": MIDDLE_ZONE_ID}, + '0': {'correct': True, 'zone': TOP_ZONE_ID}, + '1': {'correct': True, 'zone': MIDDLE_ZONE_ID}, + '2': {'correct': True, 'zone': BOTTOM_ZONE_ID}, + '3': {'correct': True, "zone": MIDDLE_ZONE_ID}, }, 'finished': True, "attempts": 0, @@ -124,39 +151,28 @@ def test_legacy_state_support(self): '1': {'top': 45, 'left': 99}, # Legacy dict with no correctness info. '2': {'x_percent': '99%', 'y_percent': '95%', 'zone': BOTTOM_ZONE_ID}, - # Current dict form. + # Legacy with absolute placement info. '3': {'x_percent': '67%', 'y_percent': '80%', 'zone': BOTTOM_ZONE_ID, 'correct': False}, + # Current state form + '4': {'zone': BOTTOM_ZONE_ID, 'correct': False}, } self.block.save() self.assertEqual(self.call_handler('get_user_state')['items'], { - # Legacy top/left values are converted to x/y percentage on the client. - '0': {'top': 60, 'left': 20, 'correct': True, 'zone': TOP_ZONE_ID}, - '1': {'top': 45, 'left': 99, 'correct': True, 'zone': MIDDLE_ZONE_ID}, - '2': {'x_percent': '99%', 'y_percent': '95%', 'correct': True, 'zone': BOTTOM_ZONE_ID}, - '3': {'x_percent': '67%', 'y_percent': '80%', 'correct': False, "zone": BOTTOM_ZONE_ID}, + '0': {'correct': True, 'zone': TOP_ZONE_ID}, + '1': {'correct': True, 'zone': MIDDLE_ZONE_ID}, + '2': {'correct': True, 'zone': BOTTOM_ZONE_ID}, + '3': {'correct': False, "zone": BOTTOM_ZONE_ID}, + '4': {'correct': False, "zone": BOTTOM_ZONE_ID}, }) def test_studio_submit(self): - body = { - 'display_name': "Test Drag & Drop", - 'mode': DragAndDropBlock.ASSESSMENT_MODE, - 'max_attempts': 1, - 'show_title': False, - 'problem_text': "Problem Drag & Drop", - 'show_problem_header': False, - 'item_background_color': 'cornflowerblue', - 'item_text_color': 'coral', - 'weight': '5', - 'data': { - 'foo': 1 - }, - } + body = self._make_submission() res = self.call_handler('studio_submit', body) self.assertEqual(res, {'result': 'success'}) self.assertEqual(self.block.show_title, False) - self.assertEqual(self.block.mode, DragAndDropBlock.ASSESSMENT_MODE) + self.assertEqual(self.block.mode, Constants.STANDARD_MODE) self.assertEqual(self.block.max_attempts, 1) self.assertEqual(self.block.display_name, "Test Drag & Drop") self.assertEqual(self.block.question_text, "Problem Drag & Drop") @@ -164,7 +180,46 @@ def test_studio_submit(self): self.assertEqual(self.block.item_background_color, "cornflowerblue") self.assertEqual(self.block.item_text_color, "coral") self.assertEqual(self.block.weight, 5) - self.assertEqual(self.block.data, {'foo': 1}) + self.assertEqual(self.block.max_items_per_zone, None) + self.assertEqual(self.block.data, {'foo': 1, 'items': []}) + + def test_studio_submit_assessment(self): + def modify_submission(submission): + submission.update({ + 'mode': Constants.ASSESSMENT_MODE, + 'max_items_per_zone': 4, + 'show_problem_header': True, + 'show_title': True, + 'max_attempts': 12, + 'item_text_color': 'red', + 'data': {'foo': 2, 'items': [{'zone': '1', 'title': 'qwe'}]}, + }) + + body = self._make_submission(modify_submission) + res = self.call_handler('studio_submit', body) + self.assertEqual(res, {'result': 'success'}) + + self.assertEqual(self.block.show_title, True) + self.assertEqual(self.block.mode, Constants.ASSESSMENT_MODE) + self.assertEqual(self.block.max_attempts, 12) + self.assertEqual(self.block.display_name, "Test Drag & Drop") + self.assertEqual(self.block.question_text, "Problem Drag & Drop") + self.assertEqual(self.block.show_question_header, True) + self.assertEqual(self.block.item_background_color, "cornflowerblue") + self.assertEqual(self.block.item_text_color, "red") + self.assertEqual(self.block.weight, 5) + self.assertEqual(self.block.max_items_per_zone, 4) + self.assertEqual(self.block.data, {'foo': 2, 'items': [{'zone': '1', 'title': 'qwe'}]}) + + def test_studio_submit_empty_max_items(self): + def modify_submission(submission): + submission['max_items_per_zone'] = '' + + body = self._make_submission(modify_submission) + res = self.call_handler('studio_submit', body) + self.assertEqual(res, {'result': 'success'}) + + self.assertIsNone(self.block.max_items_per_zone) def test_expand_static_url(self): """ Test the expand_static_url handler needed in Studio when changing the image """