Commit de5ac0c4 by E. Kolpakov

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

* "No alignment" zone option removed
* Max items per zone setting added
* Zone and state migrations are moved to a dedicated class
parent 6b211933
...@@ -11,7 +11,7 @@ The editor is fully guided. Features include: ...@@ -11,7 +11,7 @@ The editor is fully guided. Features include:
* custom zone labels * custom zone labels
* ability to show or hide zone borders * ability to show or hide zone borders
* custom text and background colors for items * custom text and background colors for items
* optional auto-alignment for items (left, right, center) * auto-alignment for items: left, right, center
* image items * image items
* decoy items that don't have a zone * decoy items that don't have a zone
* feedback popups for both correct and incorrect attempts * feedback popups for both correct and incorrect attempts
...@@ -122,15 +122,13 @@ whether or not to display borders outlining the zones. It is possible ...@@ -122,15 +122,13 @@ whether or not to display borders outlining the zones. It is possible
to define an arbitrary number of drop zones as long as their labels to define an arbitrary number of drop zones as long as their labels
are unique. are unique.
Additionally, you can specify the alignment for items once they are dropped in You can specify the alignment for items once they are dropped in
the zone. No alignment is the default, and causes items to stay where the the zone. Centered alignment is the default, and places items from top to bottom
learner drops them. Left alignment causes dropped items to be placed from left along the center of the zone. Left alignment causes dropped items to be placed from left
to right across the zone. Right alignment causes the items to be placed from to right across the zone. Right alignment causes the items to be placed from
right to left across the zone. Center alignment places items from top to bottom right to left across the zone. Items dropped in a zone will not overlap,
along the center of the zone. If left, right, or center alignment is chosen, but if the zone is not made large enough for all its items, they will overflow the bottom
items dropped in a zone will not overlap, but if the zone is not made large of the zone, and potentially overlap the zones below.
enough for all its items, they will overflow the bottom of the zone, and
potentially, overlap the zones below.
![Drag item edit](/doc/img/edit-view-items.png) ![Drag item edit](/doc/img/edit-view-items.png)
...@@ -151,6 +149,9 @@ You can leave all of the checkboxes unchecked in order to create a ...@@ -151,6 +149,9 @@ You can leave all of the checkboxes unchecked in order to create a
You can define an arbitrary number of drag items, each of which may You can define an arbitrary number of drag items, each of which may
be attached to any number of zones. be attached to any number of zones.
"Maximum items per Zone" setting controls how many items can be dropped into a
single zone, allowing some degree of control over items overlapping zones below.
Scoring Scoring
------- -------
......
...@@ -38,6 +38,7 @@ DEFAULT_DATA = { ...@@ -38,6 +38,7 @@ DEFAULT_DATA = {
"y": 30, "y": 30,
"width": 196, "width": 196,
"height": 178, "height": 178,
"align": "center"
}, },
{ {
"uid": MIDDLE_ZONE_ID, "uid": MIDDLE_ZONE_ID,
...@@ -47,6 +48,7 @@ DEFAULT_DATA = { ...@@ -47,6 +48,7 @@ DEFAULT_DATA = {
"y": 210, "y": 210,
"width": 340, "width": 340,
"height": 138, "height": 138,
"align": "center"
}, },
{ {
"uid": BOTTOM_ZONE_ID, "uid": BOTTOM_ZONE_ID,
...@@ -56,6 +58,7 @@ DEFAULT_DATA = { ...@@ -56,6 +58,7 @@ DEFAULT_DATA = {
"y": 350, "y": 350,
"width": 485, "width": 485,
"height": 135, "height": 135,
"align": "center"
} }
], ],
"items": [ "items": [
...@@ -65,7 +68,9 @@ DEFAULT_DATA = { ...@@ -65,7 +68,9 @@ DEFAULT_DATA = {
"incorrect": ITEM_INCORRECT_FEEDBACK, "incorrect": ITEM_INCORRECT_FEEDBACK,
"correct": ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE) "correct": ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE)
}, },
"zones": [TOP_ZONE_ID], "zones": [
TOP_ZONE_ID
],
"imageURL": "", "imageURL": "",
"id": 0, "id": 0,
}, },
...@@ -75,7 +80,9 @@ DEFAULT_DATA = { ...@@ -75,7 +80,9 @@ DEFAULT_DATA = {
"incorrect": ITEM_INCORRECT_FEEDBACK, "incorrect": ITEM_INCORRECT_FEEDBACK,
"correct": ITEM_CORRECT_FEEDBACK.format(zone=MIDDLE_ZONE_TITLE) "correct": ITEM_CORRECT_FEEDBACK.format(zone=MIDDLE_ZONE_TITLE)
}, },
"zones": [MIDDLE_ZONE_ID], "zones": [
MIDDLE_ZONE_ID
],
"imageURL": "", "imageURL": "",
"id": 1, "id": 1,
}, },
...@@ -85,7 +92,9 @@ DEFAULT_DATA = { ...@@ -85,7 +92,9 @@ DEFAULT_DATA = {
"incorrect": ITEM_INCORRECT_FEEDBACK, "incorrect": ITEM_INCORRECT_FEEDBACK,
"correct": ITEM_CORRECT_FEEDBACK.format(zone=BOTTOM_ZONE_TITLE) "correct": ITEM_CORRECT_FEEDBACK.format(zone=BOTTOM_ZONE_TITLE)
}, },
"zones": [BOTTOM_ZONE_ID], "zones": [
BOTTOM_ZONE_ID
],
"imageURL": "", "imageURL": "",
"id": 2, "id": 2,
}, },
...@@ -95,7 +104,11 @@ DEFAULT_DATA = { ...@@ -95,7 +104,11 @@ DEFAULT_DATA = {
"incorrect": "", "incorrect": "",
"correct": ITEM_ANY_ZONE_FEEDBACK "correct": ITEM_ANY_ZONE_FEEDBACK
}, },
"zones": [TOP_ZONE_ID, BOTTOM_ZONE_ID, MIDDLE_ZONE_ID], "zones": [
TOP_ZONE_ID,
BOTTOM_ZONE_ID,
MIDDLE_ZONE_ID
],
"imageURL": "", "imageURL": "",
"id": 3 "id": 3
}, },
......
...@@ -171,9 +171,9 @@ ...@@ -171,9 +171,9 @@
text-align: center; text-align: center;
} }
.xblock--drag-and-drop .zone .item-align-center .option { .xblock--drag-and-drop .zone .item-align-center .option {
display: block; display: inline-block;
margin-left: auto; margin-left: 1px;
margin-right: auto; margin-right: 1px;
} }
/* Focused option */ /* Focused option */
......
...@@ -166,14 +166,10 @@ function DragAndDropEditBlock(runtime, element, params) { ...@@ -166,14 +166,10 @@ function DragAndDropEditBlock(runtime, element, params) {
$(this).addClass('hidden'); $(this).addClass('hidden');
$('.save-button', element).parent() $('.save-button', element).parent()
.removeClass('hidden') .removeClass('hidden')
.one('click', function submitForm(e) { .on('click', function submitForm(e) {
// $itemTab -> submit // $itemTab -> submit
e.preventDefault(); e.preventDefault();
if (!self.validate()) {
$(e.target).one('click', submitForm);
return
}
_fn.build.form.submit(); _fn.build.form.submit();
}); });
}); });
...@@ -190,7 +186,7 @@ function DragAndDropEditBlock(runtime, element, params) { ...@@ -190,7 +186,7 @@ function DragAndDropEditBlock(runtime, element, params) {
}) })
.on('click', '.remove-zone', _fn.build.form.zone.remove) .on('click', '.remove-zone', _fn.build.form.zone.remove)
.on('input', '.zone-row input', _fn.build.form.zone.changedInputHandler) .on('input', '.zone-row input', _fn.build.form.zone.changedInputHandler)
.on('change', '.align-select', _fn.build.form.zone.changedInputHandler) .on('change', '.zone-align-select', _fn.build.form.zone.changedInputHandler)
.on('click', '.target-image-form button', function(e) { .on('click', '.target-image-form button', function(e) {
var new_img_url = $.trim($('.target-image-form .background-url', element).val()); var new_img_url = $.trim($('.target-image-form .background-url', element).val());
if (new_img_url) { if (new_img_url) {
...@@ -516,19 +512,21 @@ function DragAndDropEditBlock(runtime, element, params) { ...@@ -516,19 +512,21 @@ function DragAndDropEditBlock(runtime, element, params) {
'show_problem_header': $element.find('.show-problem-header').is(':checked'), 'show_problem_header': $element.find('.show-problem-header').is(':checked'),
'item_background_color': $element.find('.item-background-color').val(), 'item_background_color': $element.find('.item-background-color').val(),
'item_text_color': $element.find('.item-text-color').val(), 'item_text_color': $element.find('.item-text-color').val(),
'max_items_per_zone': $element.find('.max-items-per-zone').val(),
'data': _fn.data, 'data': _fn.data,
}; };
$('.xblock-editor-error-message', element).html();
$('.xblock-editor-error-message', element).css('display', 'none');
var handlerUrl = runtime.handlerUrl(element, 'studio_submit'); var handlerUrl = runtime.handlerUrl(element, 'studio_submit');
runtime.notify('save', {state: 'start', message: gettext("Saving")});
$.post(handlerUrl, JSON.stringify(data), 'json').done(function(response) { $.post(handlerUrl, JSON.stringify(data), 'json').done(function(response) {
if (response.result === 'success') { if (response.result === 'success') {
window.location.reload(false); runtime.notify('save', {state: 'end'});
} else { } else {
$('.xblock-editor-error-message', element) var message = response.messages.join(", ");
.html(gettext('Error: ') + response.message); runtime.notify('error', {
$('.xblock-editor-error-message', element).css('display', 'block'); 'title': window.gettext("There was an error with your form."),
'message': message
});
} }
}); });
} }
......
...@@ -174,6 +174,15 @@ ...@@ -174,6 +174,15 @@
<div id="item-text-color-description-{{id_suffix}}" class="form-help"> <div id="item-text-color-description-{{id_suffix}}" class="form-help">
{% trans fields.item_text_color.help %} {% trans fields.item_text_color.help %}
</div> </div>
<label class="h4">
<span>{% trans fields.max_items_per_zone.display_name %}</span>
<input type="number" min="1" step="1" class="max-items-per-zone"
value="{{ self.max_items_per_zone|unlocalize }}"
aria-describedby="max-items-per-zone-description-{{id_suffix}}">
</label>
<div id="max-items-per-zone-description-{{id_suffix}}" class="form-help">
{% trans fields.max_items_per_zone.help %}
</div>
</form> </form>
</section> </section>
<section class="tab-content"> <section class="tab-content">
...@@ -189,8 +198,7 @@ ...@@ -189,8 +198,7 @@
</section> </section>
<div class="xblock-actions"> <div class="xblock-actions">
<span class="xblock-editor-error-message"></span> <ul class="action-buttons">
<ul>
<li class="action-item"> <li class="action-item">
<a href="#" class="button action-primary continue-button">{% trans "Continue" %}</a> <a href="#" class="button action-primary continue-button">{% trans "Continue" %}</a>
</li> </li>
......
...@@ -64,10 +64,6 @@ ...@@ -64,10 +64,6 @@
<span>{{i18n "Alignment"}}</span> <span>{{i18n "Alignment"}}</span>
<select class="zone-align-select" <select class="zone-align-select"
aria-describedby="zone-align-description-{{zone.uid}}-{{id_suffix}}"> aria-describedby="zone-align-description-{{zone.uid}}-{{id_suffix}}">
<option value=""
{{#ifeq zone.align ""}}selected{{/ifeq}}>
{{i18n "none"}}
</option>
<option value="left" <option value="left"
{{#ifeq zone.align "left"}}selected{{/ifeq}}> {{#ifeq zone.align "left"}}selected{{/ifeq}}>
{{i18n "left"}} {{i18n "left"}}
...@@ -83,7 +79,7 @@ ...@@ -83,7 +79,7 @@
</select> </select>
</label> </label>
<div id="zone-align-description-{{zone.uid}}-{{id_suffix}}" class="form-help"> <div id="zone-align-description-{{zone.uid}}-{{id_suffix}}" class="form-help">
{{i18n "Align dropped items to the left, center, or right. Default is no alignment (items stay exactly where the user drops them)."}} {{i18n "Align dropped items to the left, center, or right."}}
</div> </div>
</div> </div>
</fieldset> </fieldset>
......
...@@ -90,6 +90,10 @@ msgid "I don't belong anywhere" ...@@ -90,6 +90,10 @@ msgid "I don't belong anywhere"
msgstr "" msgstr ""
#: drag_and_drop_v2.py #: drag_and_drop_v2.py
#: msgid "This setting limits the number of items that can be dropped into a single zone."
#: msgstr ""
#: drag_and_drop_v2.py
#: templates/html/js_templates.html #: templates/html/js_templates.html
msgid "Title" msgid "Title"
msgstr "" msgstr ""
...@@ -205,10 +209,6 @@ msgid "Indicates whether a learner has completed the problem at least once" ...@@ -205,10 +209,6 @@ msgid "Indicates whether a learner has completed the problem at least once"
msgstr "" msgstr ""
#: drag_and_drop_v2.py #: drag_and_drop_v2.py
msgid "Keeps maximum achieved score by student"
msgstr ""
#: drag_and_drop_v2.py
msgid "do_attempt handler should only be called for assessment mode" msgid "do_attempt handler should only be called for assessment mode"
msgstr "" msgstr ""
...@@ -224,6 +224,19 @@ msgstr "" ...@@ -224,6 +224,19 @@ msgstr ""
msgid "Remove zone" msgid "Remove zone"
msgstr "" msgstr ""
#: drag_and_drop_v2.py
msgid "Keeps maximum score achieved by student"
msgstr ""
#: drag_and_drop_v2.py
msgid "Failed to parse \"Maximum items per zone\""
msgstr ""
#: drag_and_drop_v2.py
msgid ""
"\"Maximum items per zone\" should be positive integer, got {max_items_per_zone}"
msgstr ""
#: templates/html/js_templates.html #: templates/html/js_templates.html
msgid "Text" msgid "Text"
msgstr "" msgstr ""
...@@ -265,9 +278,7 @@ msgid "right" ...@@ -265,9 +278,7 @@ msgid "right"
msgstr "" msgstr ""
#: templates/html/js_templates.html #: templates/html/js_templates.html
msgid "" msgid "Align dropped items to the left, center, or right."
"Align dropped items to the left, center, or right. Default is no alignment "
"(items stay exactly where the user drops them)."
msgstr "" msgstr ""
#: templates/html/js_templates.html #: templates/html/js_templates.html
...@@ -500,10 +511,6 @@ msgstr "" ...@@ -500,10 +511,6 @@ msgstr ""
msgid "None" msgid "None"
msgstr "" msgstr ""
#: public/js/drag_and_drop_edit.js
msgid "Error: "
msgstr ""
#: utils.py:18 #: utils.py:18
msgid "Final attempt was used, highest score is {score}" msgid "Final attempt was used, highest score is {score}"
msgstr "" msgstr ""
......
...@@ -118,6 +118,10 @@ msgid "Title" ...@@ -118,6 +118,10 @@ msgid "Title"
msgstr "Tïtlé Ⱡ'σяєм ιρѕ#" msgstr "Tïtlé Ⱡ'σяєм ιρѕ#"
#: drag_and_drop_v2.py #: drag_and_drop_v2.py
#: msgid "This setting limits the number of items that can be dropped into a single zone."
#: msgstr "Thïs séttïng lïmïts thé nümßér öf ïtéms thät çän ßé dröppéd ïntö ä sïnglé zöné."
#: drag_and_drop_v2.py
msgid "" msgid ""
"The title of the drag and drop problem. The title is displayed to learners." "The title of the drag and drop problem. The title is displayed to learners."
msgstr "" msgstr ""
...@@ -194,7 +198,7 @@ msgstr "" ...@@ -194,7 +198,7 @@ msgstr ""
#: drag_and_drop_v2.py #: drag_and_drop_v2.py
msgid "Maximum score" msgid "Maximum score"
msgstr Mäxïmüm sçöré Ⱡ'σяєм ιρѕυм ∂σłσя ѕι# msgstr "Mäxïmüm sçöré Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
#: drag_and_drop_v2.py #: drag_and_drop_v2.py
msgid "The maximum score the learner can receive for the problem." msgid "The maximum score the learner can receive for the problem."
...@@ -260,10 +264,6 @@ msgstr "" ...@@ -260,10 +264,6 @@ msgstr ""
"ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #" "ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #"
#: drag_and_drop_v2.py #: drag_and_drop_v2.py
msgid "Keeps maximum achieved score by student"
msgstr "Kééps mäxïmüm äçhïévéd sçöré ßý stüdént Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя#"
#: drag_and_drop_v2.py
msgid "do_attempt handler should only be called for assessment mode" msgid "do_attempt handler should only be called for assessment mode"
msgstr "dö_ättémpt händlér shöüld önlý ßé çälléd för ässéssmént mödé Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#" msgstr "dö_ättémpt händlér shöüld önlý ßé çälléd för ässéssmént mödé Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#"
...@@ -279,6 +279,19 @@ msgstr "Ûnknöwn DnDv2 mödé {mode} - çöürsé ïs mïsçönfïgüréd Ⱡ' ...@@ -279,6 +279,19 @@ msgstr "Ûnknöwn DnDv2 mödé {mode} - çöürsé ïs mïsçönfïgüréd Ⱡ'
msgid "Remove zone" msgid "Remove zone"
msgstr "Rémövé zöné Ⱡ'σяєм ιρѕυм ∂σłσя #" msgstr "Rémövé zöné Ⱡ'σяєм ιρѕυм ∂σłσя #"
#: drag_and_drop_v2.py
msgid "Keeps maximum score achieved by student"
msgstr "Kééps mäxïmüm sçöré äçhïévéd ßý stüdént Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя#"
#: drag_and_drop_v2.py
msgid "Failed to parse \"Maximum items per zone\""
msgstr "Fäïléd tö pärsé \"Mäxïmüm ïtéms pér zöné\" Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυ#"
#: drag_and_drop_v2.py
msgid ""
"\"Maximum items per zone\" should be positive integer, got {max_items_per_zone}"
msgstr "\"Mäxïmüm ïtéms pér zöné\" shöüld ßé pösïtïvé ïntégér, göt {max_items_per_zone} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#"
#: templates/html/js_templates.html #: templates/html/js_templates.html
msgid "Text" msgid "Text"
msgstr "Téxt Ⱡ'σяєм ι#" msgstr "Téxt Ⱡ'σяєм ι#"
...@@ -322,12 +335,8 @@ msgid "right" ...@@ -322,12 +335,8 @@ msgid "right"
msgstr "rïght Ⱡ'σяєм ιρѕ#" msgstr "rïght Ⱡ'σяєм ιρѕ#"
#: templates/html/js_templates.html #: templates/html/js_templates.html
msgid "" msgid "Align dropped items to the left, center, or right."
"Align dropped items to the left, center, or right. Default is no alignment " msgstr "Àlïgn dröppéd ïtéms tö thé léft, çéntér, ör rïght. Ⱡ'σяєм ιρ#"
"(items stay exactly where the user drops them)."
msgstr ""
"Àlïgn dröppéd ïtéms tö thé léft, çéntér, ör rïght. Défäült ïs nö älïgnmént "
"(ïtéms stäý éxäçtlý whéré thé üsér dröps thém). Ⱡ'σяєм ιρ#"
#: templates/html/js_templates.html #: templates/html/js_templates.html
msgid "Remove item" msgid "Remove item"
...@@ -590,11 +599,6 @@ msgstr "" ...@@ -590,11 +599,6 @@ msgstr ""
msgid "None" msgid "None"
msgstr "Nöné Ⱡ'σяєм ι#" msgstr "Nöné Ⱡ'σяєм ι#"
#: public/js/drag_and_drop_edit.js
msgid "Error: "
msgstr "Érrör: Ⱡ'σяєм ιρѕυм #"
#: utils.py:18 #: utils.py:18
msgid "Fïnäl ättémpt wäs üséd, hïghést sçöré ïs {score} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #" msgid "Fïnäl ättémpt wäs üséd, hïghést sçöré ïs {score} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #"
msgstr "" msgstr ""
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" Drag and Drop v2 XBlock - Utils """ """ Drag and Drop v2 XBlock - Utils """
import copy
from collections import namedtuple from collections import namedtuple
...@@ -82,3 +83,200 @@ ItemStats = namedtuple( # pylint: disable=invalid-name ...@@ -82,3 +83,200 @@ ItemStats = namedtuple( # pylint: disable=invalid-name
'ItemStats', 'ItemStats',
["required", "placed", "correctly_placed", "decoy", "decoy_in_bank"] ["required", "placed", "correctly_placed", "decoy", "decoy_in_bank"]
) )
class Constants(object):
"""
Namespace class for various constants
"""
ALLOWED_ZONE_ALIGNMENTS = ['left', 'right', 'center']
DEFAULT_ZONE_ALIGNMENT = 'center'
STANDARD_MODE = "standard"
ASSESSMENT_MODE = "assessment"
class StateMigration(object):
"""
Helper class to apply zone data and item state migrations
"""
def __init__(self, block):
self._block = block
@staticmethod
def _apply_migration(obj_id, obj, migrations):
"""
Applies migrations sequentially to a copy of an `obj`, to avoid updating actual data
"""
tmp = copy.deepcopy(obj)
for method in migrations:
tmp = method(obj_id, tmp)
return tmp
def apply_zone_migrations(self, zone):
"""
Applies zone migrations
"""
migrations = (self._zone_v1_to_v2, self._zone_v2_to_v2p1)
zone_id = zone.get('uid', zone.get('id'))
return self._apply_migration(zone_id, zone, migrations)
def apply_item_state_migrations(self, item_id, item_state):
"""
Applies item_state migrations
"""
migrations = (self._item_state_v1_to_v1p5, self._item_state_v1p5_to_v2, self._item_state_v2_to_v2p1)
return self._apply_migration(item_id, item_state, migrations)
@classmethod
def _zone_v1_to_v2(cls, unused_zone_id, zone):
"""
Migrates zone data from v1.0 format to v2.0 format.
Changes:
* v1 used zone "title" as UID, while v2 zone has dedicated "uid" property
* "id" and "index" properties are no longer used
In: {'id': 1, 'index': 2, 'title': "Zone", ...}
Out: {'uid': "Zone", ...}
"""
if "uid" not in zone:
zone["uid"] = zone.get("title")
zone.pop("id", None)
zone.pop("index", None)
return zone
@classmethod
def _zone_v2_to_v2p1(cls, unused_zone_id, zone):
"""
Migrates zone data from v2.0 to v2.1
Changes:
* Removed "none" zone alignment; default align is "center"
In: {
'uid': "Zone", "align": "none",
"x_percent": "10%", "y_percent": "10%", "width_percent": "10%", "height_percent": "10%"
}
Out: {
'uid': "Zone", "align": "center",
"x_percent": "10%", "y_percent": "10%", "width_percent": "10%", "height_percent": "10%"
}
"""
if zone.get('align', None) not in Constants.ALLOWED_ZONE_ALIGNMENTS:
zone['align'] = Constants.DEFAULT_ZONE_ALIGNMENT
return zone
@classmethod
def _item_state_v1_to_v1p5(cls, unused_item_id, item):
"""
Migrates item_state from v1.0 to v1.5
Changes:
* Item state is now a dict instead of tuple
In: ('100px', '120px')
Out: {'top': '100px', 'left': '120px'}
"""
if isinstance(item, dict):
return item
else:
return {'top': item[0], 'left': item[1]}
@classmethod
def _item_state_v1p5_to_v2(cls, unused_item_id, item):
"""
Migrates item_state from v1.5 to v2.0
Changes:
* Item placement attributes switched from absolute (left-top) to relative (x_percent-y_percent) units
In: {'zone': 'Zone", 'correct': True, 'top': '100px', 'left': '120px'}
Out: {'zone': 'Zone", 'correct': True, 'top': '100px', 'left': '120px'}
"""
# Conversion can't be made as parent dimensions are unknown to python - converted in JS
# Since 2.1 JS this conversion became unnecesary, so it was removed from JS code
return item
def _item_state_v2_to_v2p1(self, item_id, item):
"""
Migrates item_state from v2.0 to v2.1
* Single item can correspond to multiple zones - "zone" key is added to each item
* Assessment mode - "correct" key is added to each item
* Removed "no zone align" option; only automatic alignment is now allowed - removes attributes related to
"absolute" placement of an item (relative to background image, as opposed to the zone)
"""
self._multiple_zones_migration(item_id, item)
self._assessment_mode_migration(item)
self._automatic_alignment_migration(item)
return item
def _multiple_zones_migration(self, item_id, item):
"""
Changes:
* Adds "zone" attribute
In: {'item_id': 0}
Out: {'zone': 'Zone", 'item_id": 0}
In: {'item_id': 1}
Out: {'zone': 'unknown", 'item_id": 1}
"""
if item.get('zone') is None:
valid_zones = self._block.get_item_zones(int(item_id))
if valid_zones:
# If we get to this point, then the item was placed prior to support for
# multiple correct zones being added. As a result, it can only be correct
# on a single zone, and so we can trust that the item was placed on the
# zone with index 0.
item['zone'] = valid_zones[0]
else:
item['zone'] = 'unknown'
@classmethod
def _assessment_mode_migration(cls, item):
"""
Changes:
* Adds "correct" attribute if missing
In: {'item_id': 0}
Out: {'item_id': 'correct': True}
In: {'item_id': 0, 'correct': True}
Out: {'item_id': 'correct': True}
In: {'item_id': 0, 'correct': False}
Out: {'item_id': 'correct': False}
"""
# If correctness information is missing
# (because problem was completed before assessment mode was implemented),
# assume the item is in correct zone (in standard mode, only items placed
# into correct zone are stored in item state).
if item.get('correct') is None:
item['correct'] = True
@classmethod
def _automatic_alignment_migration(cls, item):
"""
Changes:
* Removed old "absolute" placement attributes
* Removed "none" zone alignment, making "x_percent" and "y_percent" attributes obsolete
In: {'zone': 'Zone", 'correct': True, 'top': '100px', 'left': '120px', 'absolute': true}
Out: {'zone': 'Zone", 'correct': True}
In: {'zone': 'Zone", 'correct': True, 'x_percent': '90%', 'y_percent': '20%'}
Out: {'zone': 'Zone", 'correct': True}
"""
attributes_to_remove = ['x_percent', 'y_percent', 'left', 'top', 'absolute']
for attribute in attributes_to_remove:
item.pop(attribute, None)
return item
...@@ -17,7 +17,7 @@ disable= ...@@ -17,7 +17,7 @@ disable=
min-similarity-lines=4 min-similarity-lines=4
[OPTIONS] [OPTIONS]
good-names=_,__,log,loader good-names=_,__,logger,loader
method-rgx=_?[a-z_][a-z0-9_]{2,40}$ method-rgx=_?[a-z_][a-z0-9_]{2,40}$
function-rgx=_?[a-z_][a-z0-9_]{2,40}$ function-rgx=_?[a-z_][a-z0-9_]{2,40}$
method-name-hint=_?[a-z_][a-z0-9_]{2,40}$ method-name-hint=_?[a-z_][a-z0-9_]{2,40}$
......
from ddt import ddt, data, unpack
from mock import Mock, patch
from workbench.runtime import WorkbenchRuntime
from drag_and_drop_v2.default_data import TOP_ZONE_TITLE, TOP_ZONE_ID, ITEM_CORRECT_FEEDBACK
from .test_base import BaseIntegrationTest, DefaultDataTestMixin
from .test_interaction import ParameterizedTestsMixin
from tests.integration.test_base import InteractionTestBase
@ddt
class EventsFiredTest(DefaultDataTestMixin, ParameterizedTestsMixin, InteractionTestBase, BaseIntegrationTest):
"""
Tests that the analytics events are fired and in the proper order.
"""
# These events must be fired in this order.
scenarios = (
{
'name': 'edx.drag_and_drop_v2.loaded',
'data': {},
},
{
'name': 'edx.drag_and_drop_v2.item.picked_up',
'data': {'item_id': 0},
},
{
'name': 'grade',
'data': {'max_value': 1, 'value': (2.0 / 5)},
},
{
'name': 'edx.drag_and_drop_v2.item.dropped',
'data': {
'is_correct': True,
'item_id': 0,
'location': TOP_ZONE_TITLE,
'location_id': TOP_ZONE_ID,
},
},
{
'name': 'edx.drag_and_drop_v2.feedback.opened',
'data': {
'content': ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE),
'truncated': False,
},
},
{
'name': 'edx.drag_and_drop_v2.feedback.closed',
'data': {
'manually': False,
'content': ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE),
'truncated': False,
},
},
)
def setUp(self):
mock = Mock()
context = patch.object(WorkbenchRuntime, 'publish', mock)
context.start()
self.addCleanup(context.stop)
self.publish = mock
super(EventsFiredTest, self).setUp()
def _get_scenario_xml(self): # pylint: disable=no-self-use
return "<vertical_demo><drag-and-drop-v2/></vertical_demo>"
@data(*enumerate(scenarios)) # pylint: disable=star-args
@unpack
def test_event(self, index, event):
self.parameterized_item_positive_feedback_on_good_move(self.items_map)
dummy, name, published_data = self.publish.call_args_list[index][0]
self.assertEqual(name, event['name'])
self.assertEqual(
published_data, event['data']
)
...@@ -13,9 +13,9 @@ from drag_and_drop_v2.default_data import ( ...@@ -13,9 +13,9 @@ from drag_and_drop_v2.default_data import (
TOP_ZONE_ID, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID, TOP_ZONE_ID, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID,
TOP_ZONE_TITLE, START_FEEDBACK, FINISH_FEEDBACK TOP_ZONE_TITLE, START_FEEDBACK, FINISH_FEEDBACK
) )
from drag_and_drop_v2.utils import FeedbackMessages from drag_and_drop_v2.utils import FeedbackMessages, Constants
from .test_base import BaseIntegrationTest from .test_base import BaseIntegrationTest
from .test_interaction import InteractionTestBase, DefaultDataTestMixin from .test_interaction import InteractionTestBase, DefaultDataTestMixin, ParameterizedTestsMixin, TestMaxItemsPerZone
# Globals ########################################################### # Globals ###########################################################
...@@ -33,8 +33,8 @@ class DefaultAssessmentDataTestMixin(DefaultDataTestMixin): ...@@ -33,8 +33,8 @@ class DefaultAssessmentDataTestMixin(DefaultDataTestMixin):
def _get_scenario_xml(self): # pylint: disable=no-self-use def _get_scenario_xml(self): # pylint: disable=no-self-use
return """ return """
<vertical_demo><drag-and-drop-v2 mode='assessment' max_attempts='{max_attempts}'/></vertical_demo> <vertical_demo><drag-and-drop-v2 mode='{mode}' max_attempts='{max_attempts}'/></vertical_demo>
""".format(max_attempts=self.MAX_ATTEMPTS) """.format(mode=Constants.ASSESSMENT_MODE, max_attempts=self.MAX_ATTEMPTS)
class AssessmentTestMixin(object): class AssessmentTestMixin(object):
...@@ -57,7 +57,8 @@ class AssessmentTestMixin(object): ...@@ -57,7 +57,8 @@ class AssessmentTestMixin(object):
@ddt @ddt
class AssessmentInteractionTest( class AssessmentInteractionTest(
DefaultAssessmentDataTestMixin, AssessmentTestMixin, InteractionTestBase, BaseIntegrationTest DefaultAssessmentDataTestMixin, AssessmentTestMixin, ParameterizedTestsMixin,
InteractionTestBase, BaseIntegrationTest
): ):
""" """
Testing interactions with Drag and Drop XBlock against default data in assessment mode. Testing interactions with Drag and Drop XBlock against default data in assessment mode.
...@@ -217,3 +218,28 @@ class AssessmentInteractionTest( ...@@ -217,3 +218,28 @@ class AssessmentInteractionTest(
published_grade = next((event[0][2] for event in events if event[0][1] == 'grade')) published_grade = next((event[0][2] for event in events if event[0][1] == 'grade'))
expected_grade = {'max_value': 1, 'value': (1.0 / 5.0)} expected_grade = {'max_value': 1, 'value': (1.0 / 5.0)}
self.assertEqual(published_grade, expected_grade) self.assertEqual(published_grade, expected_grade)
class TestMaxItemsPerZoneAssessment(TestMaxItemsPerZone):
assessment_mode = True
def _get_scenario_xml(self):
scenario_data = loader.load_unicode("data/test_zone_align.json")
return self._make_scenario_xml(data=scenario_data, max_items_per_zone=2, mode=Constants.ASSESSMENT_MODE)
def test_drop_item_to_same_zone_does_not_show_popup(self):
zone_id = "Zone Left Align"
self.place_item(6, zone_id)
self.place_item(7, zone_id)
popup = self._get_popup()
# precondition check - max items placed into zone
self.assert_placed_item(6, zone_id, assessment_mode=self.assessment_mode)
self.assert_placed_item(7, zone_id, assessment_mode=self.assessment_mode)
self.place_item(6, zone_id, Keys.RETURN)
self.assertFalse(popup.is_displayed())
self.place_item(7, zone_id, Keys.RETURN)
self.assertFalse(popup.is_displayed())
...@@ -189,7 +189,7 @@ class TestDragAndDropRender(BaseIntegrationTest): ...@@ -189,7 +189,7 @@ class TestDragAndDropRender(BaseIntegrationTest):
self.assertEqual(zone.get_attribute('dropzone'), 'move') self.assertEqual(zone.get_attribute('dropzone'), 'move')
self.assertEqual(zone.get_attribute('aria-dropeffect'), 'move') self.assertEqual(zone.get_attribute('aria-dropeffect'), 'move')
self.assertEqual(zone.get_attribute('data-uid'), 'Zone {}'.format(zone_number)) self.assertEqual(zone.get_attribute('data-uid'), 'Zone {}'.format(zone_number))
self.assertEqual(zone.get_attribute('data-zone_align'), 'none') self.assertEqual(zone.get_attribute('data-zone_align'), 'center')
self.assertIn('ui-droppable', self.get_element_classes(zone)) self.assertIn('ui-droppable', self.get_element_classes(zone))
zone_box_percentages = box_percentages[index] zone_box_percentages = box_percentages[index]
self._assert_box_percentages( # pylint: disable=star-args self._assert_box_percentages( # pylint: disable=star-args
...@@ -293,8 +293,8 @@ class TestDragAndDropRenderZoneAlign(BaseIntegrationTest): ...@@ -293,8 +293,8 @@ class TestDragAndDropRenderZoneAlign(BaseIntegrationTest):
def test_zone_align(self): def test_zone_align(self):
expected_alignments = { expected_alignments = {
"#-Zone_No_Align": "start", "#-Zone_No_Align": "center",
"#-Zone_Invalid_Align": "start", "#-Zone_Invalid_Align": "center",
"#-Zone_Left_Align": "left", "#-Zone_Left_Align": "left",
"#-Zone_Right_Align": "right", "#-Zone_Right_Align": "right",
"#-Zone_Center_Align": "center" "#-Zone_Center_Align": "center"
......
...@@ -8,7 +8,7 @@ from selenium.webdriver.support.ui import WebDriverWait ...@@ -8,7 +8,7 @@ from selenium.webdriver.support.ui import WebDriverWait
from xblockutils.resources import ResourceLoader from xblockutils.resources import ResourceLoader
from .test_base import BaseIntegrationTest from .test_base import BaseIntegrationTest
from .test_interaction import InteractionTestBase from tests.integration.test_base import InteractionTestBase
loader = ResourceLoader(__name__) loader = ResourceLoader(__name__)
...@@ -82,7 +82,7 @@ class SizingTests(InteractionTestBase, BaseIntegrationTest): ...@@ -82,7 +82,7 @@ class SizingTests(InteractionTestBase, BaseIntegrationTest):
EXPECTATIONS = [ EXPECTATIONS = [
# The text 'Auto' with no fixed size specified should be 5-20% wide # The text 'Auto' with no fixed size specified should be 5-20% wide
Expectation(item_id=0, zone_id=ZONE_33, width_percent=[5, 20]), Expectation(item_id=0, zone_id=ZONE_33, width_percent=[5, AUTO_MAX_WIDTH]),
# The long text with no fixed size specified should be wrapped at the maximum width # The long text with no fixed size specified should be wrapped at the maximum width
Expectation(item_id=1, zone_id=ZONE_33, width_percent=AUTO_MAX_WIDTH), Expectation(item_id=1, zone_id=ZONE_33, width_percent=AUTO_MAX_WIDTH),
# The text items that specify specific widths as a percentage of the background image: # The text items that specify specific widths as a percentage of the background image:
......
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
"display_zone_borders": false, "display_zone_borders": false,
"display_zone_labels": false, "display_zone_labels": false,
"url_name": "test", "url_name": "test",
"max_items_per_zone": null,
"zones": [ "zones": [
{ {
...@@ -21,7 +22,8 @@ ...@@ -21,7 +22,8 @@
"x": 234, "x": 234,
"width": 345, "width": 345,
"height": 456, "height": 456,
"uid": "zone-1" "uid": "zone-1",
"align": "right"
}, },
{ {
"title": "Zone 2", "title": "Zone 2",
...@@ -29,7 +31,8 @@ ...@@ -29,7 +31,8 @@
"x": 10, "x": 10,
"width": 30, "width": 30,
"height": 40, "height": 40,
"uid": "zone-2" "uid": "zone-2",
"align": "center"
} }
], ],
......
...@@ -6,7 +6,8 @@ ...@@ -6,7 +6,8 @@
"x": 234, "x": 234,
"width": 345, "width": 345,
"height": 456, "height": 456,
"uid": "zone-1" "uid": "zone-1",
"align": "right"
}, },
{ {
"title": "Zone 2", "title": "Zone 2",
......
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
"display_zone_borders": false, "display_zone_borders": false,
"display_zone_labels": false, "display_zone_labels": false,
"url_name": "unique_name", "url_name": "unique_name",
"max_items_per_zone": null,
"zones": [ "zones": [
{ {
...@@ -21,7 +22,8 @@ ...@@ -21,7 +22,8 @@
"y": 200, "y": 200,
"width": 200, "width": 200,
"height": 100, "height": 100,
"uid": "Zone <i>1</i>" "uid": "Zone <i>1</i>",
"align": "right"
}, },
{ {
"title": "Zone <b>2</b>", "title": "Zone <b>2</b>",
...@@ -29,7 +31,8 @@ ...@@ -29,7 +31,8 @@
"y": 0, "y": 0,
"width": 200, "width": 200,
"height": 100, "height": 100,
"uid": "Zone <b>2</b>" "uid": "Zone <b>2</b>",
"align": "center"
} }
], ],
......
...@@ -7,7 +7,8 @@ ...@@ -7,7 +7,8 @@
"height": 100, "height": 100,
"y": 200, "y": 200,
"x": 100, "x": 100,
"id": "zone-1" "id": "zone-1",
"align": "right"
}, },
{ {
"index": 2, "index": 2,
...@@ -16,7 +17,8 @@ ...@@ -16,7 +17,8 @@
"height": 100, "height": 100,
"y": 0, "y": 0,
"x": 0, "x": 0,
"id": "zone-2" "id": "zone-2",
"align": "center"
} }
], ],
......
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
"display_zone_borders": false, "display_zone_borders": false,
"display_zone_labels": false, "display_zone_labels": false,
"url_name": "", "url_name": "",
"max_items_per_zone": null,
"zones": [ "zones": [
{ {
...@@ -21,7 +22,8 @@ ...@@ -21,7 +22,8 @@
"y": "200", "y": "200",
"width": 200, "width": 200,
"height": 100, "height": 100,
"uid": "Zone 1" "uid": "Zone 1",
"align": "center"
}, },
{ {
"title": "Zone 2", "title": "Zone 2",
...@@ -29,7 +31,8 @@ ...@@ -29,7 +31,8 @@
"y": 0, "y": 0,
"width": 200, "width": 200,
"height": 100, "height": 100,
"uid": "Zone 2" "uid": "Zone 2",
"align": "center"
} }
], ],
......
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
"display_zone_borders": false, "display_zone_borders": false,
"display_zone_labels": false, "display_zone_labels": false,
"url_name": "test", "url_name": "test",
"max_items_per_zone": 4,
"zones": [ "zones": [
{ {
...@@ -21,7 +22,8 @@ ...@@ -21,7 +22,8 @@
"x": 234, "x": 234,
"width": 345, "width": 345,
"height": 456, "height": 456,
"uid": "zone-1" "uid": "zone-1",
"align": "left"
}, },
{ {
"title": "Zone 2", "title": "Zone 2",
...@@ -29,7 +31,8 @@ ...@@ -29,7 +31,8 @@
"x": 10, "x": 10,
"width": 30, "width": 30,
"height": 40, "height": 40,
"uid": "zone-2" "uid": "zone-2",
"align": "center"
} }
], ],
......
...@@ -6,7 +6,8 @@ ...@@ -6,7 +6,8 @@
"x": 234, "x": 234,
"width": 345, "width": 345,
"height": 456, "height": 456,
"uid": "zone-1" "uid": "zone-1",
"align": "left"
}, },
{ {
"title": "Zone 2", "title": "Zone 2",
...@@ -14,7 +15,8 @@ ...@@ -14,7 +15,8 @@
"x": 10, "x": 10,
"width": 30, "width": 30,
"height": 40, "height": 40,
"uid": "zone-2" "uid": "zone-2",
"align": "center"
} }
], ],
......
...@@ -7,5 +7,6 @@ ...@@ -7,5 +7,6 @@
"weight": 1, "weight": 1,
"item_background_color": "", "item_background_color": "",
"item_text_color": "", "item_text_color": "",
"url_name": "test" "url_name": "test",
"max_items_per_zone": 4
} }
...@@ -75,7 +75,7 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture): ...@@ -75,7 +75,7 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
""" """
def test_drop_item_wrong_with_feedback(self): def test_drop_item_wrong_with_feedback(self):
item_id, zone_id = 0, self.ZONE_2 item_id, zone_id = 0, self.ZONE_2
data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"} data = {"val": item_id, "zone": zone_id}
res = self.call_handler(self.DROP_ITEM_HANDLER, data) res = self.call_handler(self.DROP_ITEM_HANDLER, data)
self.assertEqual(res, { self.assertEqual(res, {
"overall_feedback": [self._make_feedback_message(message=self.INITIAL_FEEDBACK)], "overall_feedback": [self._make_feedback_message(message=self.INITIAL_FEEDBACK)],
...@@ -86,7 +86,7 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture): ...@@ -86,7 +86,7 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
def test_drop_item_wrong_without_feedback(self): def test_drop_item_wrong_without_feedback(self):
item_id, zone_id = 2, self.ZONE_1 item_id, zone_id = 2, self.ZONE_1
data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"} data = {"val": item_id, "zone": zone_id}
res = self.call_handler(self.DROP_ITEM_HANDLER, data) res = self.call_handler(self.DROP_ITEM_HANDLER, data)
self.assertEqual(res, { self.assertEqual(res, {
"overall_feedback": [self._make_feedback_message(message=self.INITIAL_FEEDBACK)], "overall_feedback": [self._make_feedback_message(message=self.INITIAL_FEEDBACK)],
...@@ -97,7 +97,7 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture): ...@@ -97,7 +97,7 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
def test_drop_item_correct(self): def test_drop_item_correct(self):
item_id, zone_id = 0, self.ZONE_1 item_id, zone_id = 0, self.ZONE_1
data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"} data = {"val": item_id, "zone": zone_id}
res = self.call_handler(self.DROP_ITEM_HANDLER, data) res = self.call_handler(self.DROP_ITEM_HANDLER, data)
self.assertEqual(res, { self.assertEqual(res, {
"overall_feedback": [self._make_feedback_message(message=self.INITIAL_FEEDBACK)], "overall_feedback": [self._make_feedback_message(message=self.INITIAL_FEEDBACK)],
...@@ -114,27 +114,23 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture): ...@@ -114,27 +114,23 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
published_grades.append(params) published_grades.append(params)
self.block.runtime.publish = mock_publish self.block.runtime.publish = mock_publish
self.call_handler(self.DROP_ITEM_HANDLER, { self.call_handler(self.DROP_ITEM_HANDLER, {"val": 0, "zone": self.ZONE_1})
"val": 0, "zone": self.ZONE_1, "y_percent": "11%", "x_percent": "33%"
})
self.assertEqual(1, len(published_grades)) self.assertEqual(1, len(published_grades))
self.assertEqual({'value': 0.75, 'max_value': 1}, published_grades[-1]) self.assertEqual({'value': 0.75, 'max_value': 1}, published_grades[-1])
self.call_handler(self.DROP_ITEM_HANDLER, { self.call_handler(self.DROP_ITEM_HANDLER, {"val": 1, "zone": self.ZONE_2})
"val": 1, "zone": self.ZONE_2, "y_percent": "90%", "x_percent": "42%"
})
self.assertEqual(2, len(published_grades)) self.assertEqual(2, len(published_grades))
self.assertEqual({'value': 1, 'max_value': 1}, published_grades[-1]) self.assertEqual({'value': 1, 'max_value': 1}, published_grades[-1])
def test_drop_item_final(self): def test_drop_item_final(self):
data = {"val": 0, "zone": self.ZONE_1, "x_percent": "33%", "y_percent": "11%"} data = {"val": 0, "zone": self.ZONE_1}
self.call_handler(self.DROP_ITEM_HANDLER, data) self.call_handler(self.DROP_ITEM_HANDLER, data)
expected_state = { expected_state = {
"items": { "items": {
"0": {"x_percent": "33%", "y_percent": "11%", "correct": True, "zone": self.ZONE_1} "0": {"correct": True, "zone": self.ZONE_1}
}, },
"finished": False, "finished": False,
"attempts": 0, "attempts": 0,
...@@ -142,8 +138,7 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture): ...@@ -142,8 +138,7 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
} }
self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET")) self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET"))
data = {"val": 1, "zone": self.ZONE_2, "x_percent": "22%", "y_percent": "22%"} res = self.call_handler(self.DROP_ITEM_HANDLER, {"val": 1, "zone": self.ZONE_2})
res = self.call_handler(self.DROP_ITEM_HANDLER, data)
self.assertEqual(res, { self.assertEqual(res, {
"overall_feedback": [self._make_feedback_message(message=self.FINAL_FEEDBACK)], "overall_feedback": [self._make_feedback_message(message=self.FINAL_FEEDBACK)],
"finished": True, "finished": True,
...@@ -153,12 +148,8 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture): ...@@ -153,12 +148,8 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture):
expected_state = { expected_state = {
"items": { "items": {
"0": { "0": {"correct": True, "zone": self.ZONE_1},
"x_percent": "33%", "y_percent": "11%", "correct": True, "zone": self.ZONE_1, "1": {"correct": True, "zone": self.ZONE_2}
},
"1": {
"x_percent": "22%", "y_percent": "22%", "correct": True, "zone": self.ZONE_2,
}
}, },
"finished": True, "finished": True,
"attempts": 0, "attempts": 0,
...@@ -182,9 +173,7 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture): ...@@ -182,9 +173,7 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
""" """
@staticmethod @staticmethod
def _make_submission(item_id, zone_id): def _make_submission(item_id, zone_id):
x_percent, y_percent = str(random.randint(0, 100)) + '%', str(random.randint(0, 100)) + '%' return {"val": item_id, "zone": zone_id}
data = {"val": item_id, "zone": zone_id, "x_percent": x_percent, "y_percent": y_percent}
return data
def _submit_solution(self, solution): def _submit_solution(self, solution):
for item_id, zone_id in solution.iteritems(): for item_id, zone_id in solution.iteritems():
...@@ -209,12 +198,11 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture): ...@@ -209,12 +198,11 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture):
item_zone_map = {0: self.ZONE_1, 1: self.ZONE_2} item_zone_map = {0: self.ZONE_1, 1: self.ZONE_2}
for item_id, zone_id in item_zone_map.iteritems(): for item_id, zone_id in item_zone_map.iteritems():
data = self._make_submission(item_id, zone_id) data = self._make_submission(item_id, zone_id)
x_percent, y_percent = data['x_percent'], data['y_percent']
res = self.call_handler(self.DROP_ITEM_HANDLER, data) res = self.call_handler(self.DROP_ITEM_HANDLER, data)
self.assertEqual(res, {}) self.assertEqual(res, {})
expected_item_state = {'zone': zone_id, 'correct': True, 'x_percent': x_percent, 'y_percent': y_percent} expected_item_state = {'zone': zone_id, 'correct': True}
self.assertIn(str(item_id), self.block.item_state) self.assertIn(str(item_id), self.block.item_state)
self.assertEqual(self.block.item_state[str(item_id)], expected_item_state) self.assertEqual(self.block.item_state[str(item_id)], expected_item_state)
......
import ddt
import unittest import unittest
from drag_and_drop_v2.drag_and_drop_v2 import DragAndDropBlock from drag_and_drop_v2.utils import Constants
from drag_and_drop_v2.default_data import ( from drag_and_drop_v2.default_data import (
TARGET_IMG_DESCRIPTION, TOP_ZONE_ID, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID, TARGET_IMG_DESCRIPTION, TOP_ZONE_ID, MIDDLE_ZONE_ID, BOTTOM_ZONE_ID,
START_FEEDBACK, FINISH_FEEDBACK, DEFAULT_DATA START_FEEDBACK, FINISH_FEEDBACK, DEFAULT_DATA
...@@ -8,6 +9,7 @@ from drag_and_drop_v2.default_data import ( ...@@ -8,6 +9,7 @@ from drag_and_drop_v2.default_data import (
from ..utils import make_block, TestCaseMixin from ..utils import make_block, TestCaseMixin
@ddt.ddt
class BasicTests(TestCaseMixin, unittest.TestCase): class BasicTests(TestCaseMixin, unittest.TestCase):
""" Basic unit tests for the Drag and Drop block, using its default settings """ """ Basic unit tests for the Drag and Drop block, using its default settings """
...@@ -15,6 +17,30 @@ class BasicTests(TestCaseMixin, unittest.TestCase): ...@@ -15,6 +17,30 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
self.block = make_block() self.block = make_block()
self.patch_workbench() self.patch_workbench()
@staticmethod
def _make_submission(modify_submission=None):
modify = modify_submission if modify_submission else lambda x: x
submission = {
'display_name': "Test Drag & Drop",
'mode': Constants.STANDARD_MODE,
'max_attempts': 1,
'show_title': False,
'problem_text': "Problem Drag & Drop",
'show_problem_header': False,
'item_background_color': 'cornflowerblue',
'item_text_color': 'coral',
'weight': '5',
'data': {
'foo': 1,
'items': []
},
}
modify(submission)
return submission
def test_template_contents(self): def test_template_contents(self):
context = {} context = {}
student_fragment = self.block.runtime.render(self.block, 'student_view', context) student_fragment = self.block.runtime.render(self.block, 'student_view', context)
...@@ -30,13 +56,14 @@ class BasicTests(TestCaseMixin, unittest.TestCase): ...@@ -30,13 +56,14 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
zones = config.pop("zones") zones = config.pop("zones")
items = config.pop("items") items = config.pop("items")
self.assertEqual(config, { self.assertEqual(config, {
"mode": DragAndDropBlock.STANDARD_MODE, "mode": Constants.STANDARD_MODE,
"max_attempts": None, "max_attempts": None,
"display_zone_borders": False, "display_zone_borders": False,
"display_zone_labels": False, "display_zone_labels": False,
"title": "Drag and Drop", "title": "Drag and Drop",
"show_title": True, "show_title": True,
"problem_text": "", "problem_text": "",
"max_items_per_zone": None,
"show_problem_header": True, "show_problem_header": True,
"target_img_expanded_url": '/expanded/url/to/drag_and_drop_v2/public/img/triangle.png', "target_img_expanded_url": '/expanded/url/to/drag_and_drop_v2/public/img/triangle.png',
"target_img_description": TARGET_IMG_DESCRIPTION, "target_img_description": TARGET_IMG_DESCRIPTION,
...@@ -75,29 +102,29 @@ class BasicTests(TestCaseMixin, unittest.TestCase): ...@@ -75,29 +102,29 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
assert_user_state_empty() assert_user_state_empty()
# Drag three items into the correct spot: # Drag three items into the correct spot:
data = {"val": 0, "zone": TOP_ZONE_ID, "x_percent": "33%", "y_percent": "11%"} data = {"val": 0, "zone": TOP_ZONE_ID}
self.call_handler(self.DROP_ITEM_HANDLER, data) self.call_handler(self.DROP_ITEM_HANDLER, data)
data = {"val": 1, "zone": MIDDLE_ZONE_ID, "x_percent": "67%", "y_percent": "80%"} data = {"val": 1, "zone": MIDDLE_ZONE_ID}
self.call_handler(self.DROP_ITEM_HANDLER, data) self.call_handler(self.DROP_ITEM_HANDLER, data)
data = {"val": 2, "zone": BOTTOM_ZONE_ID, "x_percent": "99%", "y_percent": "95%"} data = {"val": 2, "zone": BOTTOM_ZONE_ID}
self.call_handler(self.DROP_ITEM_HANDLER, data) self.call_handler(self.DROP_ITEM_HANDLER, data)
data = {"val": 3, "zone": MIDDLE_ZONE_ID, "x_percent": "67%", "y_percent": "80%"} data = {"val": 3, "zone": MIDDLE_ZONE_ID}
self.call_handler(self.DROP_ITEM_HANDLER, data) self.call_handler(self.DROP_ITEM_HANDLER, data)
# Check the result: # Check the result:
self.assertTrue(self.block.completed) self.assertTrue(self.block.completed)
self.assertEqual(self.block.item_state, { self.assertEqual(self.block.item_state, {
'0': {'x_percent': '33%', 'y_percent': '11%', 'correct': True, 'zone': TOP_ZONE_ID}, '0': {'correct': True, 'zone': TOP_ZONE_ID},
'1': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, 'zone': MIDDLE_ZONE_ID}, '1': {'correct': True, 'zone': MIDDLE_ZONE_ID},
'2': {'x_percent': '99%', 'y_percent': '95%', 'correct': True, 'zone': BOTTOM_ZONE_ID}, '2': {'correct': True, 'zone': BOTTOM_ZONE_ID},
'3': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, "zone": MIDDLE_ZONE_ID}, '3': {'correct': True, "zone": MIDDLE_ZONE_ID},
}) })
self.assertEqual(self.call_handler('get_user_state'), { self.assertEqual(self.call_handler('get_user_state'), {
'items': { 'items': {
'0': {'x_percent': '33%', 'y_percent': '11%', 'correct': True, 'zone': TOP_ZONE_ID}, '0': {'correct': True, 'zone': TOP_ZONE_ID},
'1': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, 'zone': MIDDLE_ZONE_ID}, '1': {'correct': True, 'zone': MIDDLE_ZONE_ID},
'2': {'x_percent': '99%', 'y_percent': '95%', 'correct': True, 'zone': BOTTOM_ZONE_ID}, '2': {'correct': True, 'zone': BOTTOM_ZONE_ID},
'3': {'x_percent': '67%', 'y_percent': '80%', 'correct': True, "zone": MIDDLE_ZONE_ID}, '3': {'correct': True, "zone": MIDDLE_ZONE_ID},
}, },
'finished': True, 'finished': True,
"attempts": 0, "attempts": 0,
...@@ -124,39 +151,28 @@ class BasicTests(TestCaseMixin, unittest.TestCase): ...@@ -124,39 +151,28 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
'1': {'top': 45, 'left': 99}, '1': {'top': 45, 'left': 99},
# Legacy dict with no correctness info. # Legacy dict with no correctness info.
'2': {'x_percent': '99%', 'y_percent': '95%', 'zone': BOTTOM_ZONE_ID}, '2': {'x_percent': '99%', 'y_percent': '95%', 'zone': BOTTOM_ZONE_ID},
# Current dict form. # Legacy with absolute placement info.
'3': {'x_percent': '67%', 'y_percent': '80%', 'zone': BOTTOM_ZONE_ID, 'correct': False}, '3': {'x_percent': '67%', 'y_percent': '80%', 'zone': BOTTOM_ZONE_ID, 'correct': False},
# Current state form
'4': {'zone': BOTTOM_ZONE_ID, 'correct': False},
} }
self.block.save() self.block.save()
self.assertEqual(self.call_handler('get_user_state')['items'], { self.assertEqual(self.call_handler('get_user_state')['items'], {
# Legacy top/left values are converted to x/y percentage on the client. '0': {'correct': True, 'zone': TOP_ZONE_ID},
'0': {'top': 60, 'left': 20, 'correct': True, 'zone': TOP_ZONE_ID}, '1': {'correct': True, 'zone': MIDDLE_ZONE_ID},
'1': {'top': 45, 'left': 99, 'correct': True, 'zone': MIDDLE_ZONE_ID}, '2': {'correct': True, 'zone': BOTTOM_ZONE_ID},
'2': {'x_percent': '99%', 'y_percent': '95%', 'correct': True, 'zone': BOTTOM_ZONE_ID}, '3': {'correct': False, "zone": BOTTOM_ZONE_ID},
'3': {'x_percent': '67%', 'y_percent': '80%', 'correct': False, "zone": BOTTOM_ZONE_ID}, '4': {'correct': False, "zone": BOTTOM_ZONE_ID},
}) })
def test_studio_submit(self): def test_studio_submit(self):
body = { body = self._make_submission()
'display_name': "Test Drag & Drop",
'mode': DragAndDropBlock.ASSESSMENT_MODE,
'max_attempts': 1,
'show_title': False,
'problem_text': "Problem Drag & Drop",
'show_problem_header': False,
'item_background_color': 'cornflowerblue',
'item_text_color': 'coral',
'weight': '5',
'data': {
'foo': 1
},
}
res = self.call_handler('studio_submit', body) res = self.call_handler('studio_submit', body)
self.assertEqual(res, {'result': 'success'}) self.assertEqual(res, {'result': 'success'})
self.assertEqual(self.block.show_title, False) self.assertEqual(self.block.show_title, False)
self.assertEqual(self.block.mode, DragAndDropBlock.ASSESSMENT_MODE) self.assertEqual(self.block.mode, Constants.STANDARD_MODE)
self.assertEqual(self.block.max_attempts, 1) self.assertEqual(self.block.max_attempts, 1)
self.assertEqual(self.block.display_name, "Test Drag & Drop") self.assertEqual(self.block.display_name, "Test Drag & Drop")
self.assertEqual(self.block.question_text, "Problem Drag & Drop") self.assertEqual(self.block.question_text, "Problem Drag & Drop")
...@@ -164,7 +180,46 @@ class BasicTests(TestCaseMixin, unittest.TestCase): ...@@ -164,7 +180,46 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
self.assertEqual(self.block.item_background_color, "cornflowerblue") self.assertEqual(self.block.item_background_color, "cornflowerblue")
self.assertEqual(self.block.item_text_color, "coral") self.assertEqual(self.block.item_text_color, "coral")
self.assertEqual(self.block.weight, 5) self.assertEqual(self.block.weight, 5)
self.assertEqual(self.block.data, {'foo': 1}) self.assertEqual(self.block.max_items_per_zone, None)
self.assertEqual(self.block.data, {'foo': 1, 'items': []})
def test_studio_submit_assessment(self):
def modify_submission(submission):
submission.update({
'mode': Constants.ASSESSMENT_MODE,
'max_items_per_zone': 4,
'show_problem_header': True,
'show_title': True,
'max_attempts': 12,
'item_text_color': 'red',
'data': {'foo': 2, 'items': [{'zone': '1', 'title': 'qwe'}]},
})
body = self._make_submission(modify_submission)
res = self.call_handler('studio_submit', body)
self.assertEqual(res, {'result': 'success'})
self.assertEqual(self.block.show_title, True)
self.assertEqual(self.block.mode, Constants.ASSESSMENT_MODE)
self.assertEqual(self.block.max_attempts, 12)
self.assertEqual(self.block.display_name, "Test Drag & Drop")
self.assertEqual(self.block.question_text, "Problem Drag & Drop")
self.assertEqual(self.block.show_question_header, True)
self.assertEqual(self.block.item_background_color, "cornflowerblue")
self.assertEqual(self.block.item_text_color, "red")
self.assertEqual(self.block.weight, 5)
self.assertEqual(self.block.max_items_per_zone, 4)
self.assertEqual(self.block.data, {'foo': 2, 'items': [{'zone': '1', 'title': 'qwe'}]})
def test_studio_submit_empty_max_items(self):
def modify_submission(submission):
submission['max_items_per_zone'] = ''
body = self._make_submission(modify_submission)
res = self.call_handler('studio_submit', body)
self.assertEqual(res, {'result': 'success'})
self.assertIsNone(self.block.max_items_per_zone)
def test_expand_static_url(self): def test_expand_static_url(self):
""" Test the expand_static_url handler needed in Studio when changing the image """ """ Test the expand_static_url handler needed in Studio when changing the image """
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment