Commit 1c392662 by Matjaz Gregoric

Add ability to ask for numerical value on drop.

If the input value (with optional margin) is set for a drop item,
an input field is shown and focused when the user drops the item
on the correct zone.

The value is submitted on field blur. If correct (within the margin),
the field is colored green. If the value is not correct, the field is
colored red. In either case, the field gets disabled and user cannot
try again until resetting the exercise.

The exercise is not considered finished until all required input
fields are filled.

If the item has an input set, the user only gets points for the item if
the input value is within the correct margin.

The patch includes changes to the testing infrastructure.

Instead of having to checkout the `xblock-sdk` repository
and set `xblock-drag-and-drop-v2` to run within it,
the `xblock-sdk` repository has been added to the test requirements.

A barebone django app that loads the workbench to run integration tests
has been created inside the tests folder.

All test dependencies have been explicitly pinned in `test/requirements.txt`.

Assuming you have Firefox installed, running the tests should be
as simple as `pip install -r tests/requirements.txt` in a fresh virtual
environment and then `tests/manage.py test`.
parent 4482aa60
...@@ -93,32 +93,22 @@ You can define an arbitrary number of drag items. ...@@ -93,32 +93,22 @@ You can define an arbitrary number of drag items.
Testing Testing
------- -------
1. In a virtualenv, run In a virtualenv, run
```bash ```bash
$ (cd .../xblock-sdk/; pip install -r requirements.txt) $ cd .../xblock-drag-and-drop-v2/
$ (cd .../xblock-drag-and-drop-v2/; pip install -r tests/requirements.txt) $ pip install -r tests/requirements.txt
``` ```
2. In the xblock-sdk repository, create the following configuration To run the tests, from the xblock-drag-and-drop-v2 repository root:
file in `workbench/settings_drag_and_drop_v2.py`
```python
from settings import *
INSTALLED_APPS += ('drag_and_drop_v2',)
DATABASES['default']['NAME'] = 'workbench.db'
```
3. Run this to sync the database before starting the workbench
(answering no to the superuser question is ok):
```bash ```bash
$ ../xblock-sdk/manage.py syncdb --settings=workbench.settings_drag_and_drop_v2 $ tests/manage.py test --rednose
``` ```
4. To run the tests, from the xblock-drag-and-drop-v2 repository root: To include coverage report (although selenium tends to crash with
segmentation faults when collection test coverage):
```bash ```bash
$ DJANGO_SETTINGS_MODULE="workbench.settings_drag_and_drop_v2" nosetests --rednose --verbose --with-cover --cover-package=drag_and_drop_v2 --with-django $ tests/manage.py test --rednose --with-cover --cover-package=drag_and_drop_v2
``` ```
...@@ -155,12 +155,18 @@ class DragAndDropBlock(XBlock): ...@@ -155,12 +155,18 @@ class DragAndDropBlock(XBlock):
# Strip answers # Strip answers
del item['feedback'] del item['feedback']
del item['zone'] del item['zone']
item['inputOptions'] = item.has_key('inputOptions')
if not self._is_finished(): if not self._is_finished():
del data['feedback']['finish'] del data['feedback']['finish']
item_state = self._get_item_state()
for item_id, item in item_state.iteritems():
definition = next(i for i in self.data['items'] if str(i['id']) == item_id)
item['correct_input'] = self._is_correct_input(definition, item.get('input'))
data['state'] = { data['state'] = {
'items': self.item_state, 'items': item_state,
'finished': self._is_finished() 'finished': self._is_finished()
} }
...@@ -171,44 +177,71 @@ class DragAndDropBlock(XBlock): ...@@ -171,44 +177,71 @@ class DragAndDropBlock(XBlock):
item = next(i for i in self.data['items'] if i['id'] == attempt['val']) item = next(i for i in self.data['items'] if i['id'] == attempt['val'])
tot_items = sum(1 for i in self.data['items'] if i['zone'] != 'none') tot_items = sum(1 for i in self.data['items'] if i['zone'] != 'none')
state = None
feedback = item['feedback']['incorrect']
final_feedback = None final_feedback = None
is_correct = False is_correct = False
is_correct_location = False
if item['zone'] == attempt['zone']:
self.item_state[item['id']] = (attempt['top'], attempt['left']) if 'input' in attempt:
state = self._get_item_state().get(str(item['id']))
is_correct = True if state:
state['input'] = attempt['input']
is_correct_location = True
if self._is_correct_input(item, attempt['input']):
is_correct = True
feedback = item['feedback']['correct']
else:
is_correct = False
elif item['zone'] == attempt['zone']:
is_correct_location = True
if item.has_key('inputOptions'):
# Input value will have to be provided for the item.
# It is not (yet) correct and no feedback should be shown yet.
is_correct = False
feedback = None
else:
# If this item has no input value set, we are done with it.
is_correct = True
feedback = item['feedback']['correct']
state = {'top': attempt['top'], 'left': attempt['left']}
if state:
self.item_state[str(item['id'])] = state
if self._is_finished():
final_feedback = self.data['feedback']['finish']
# don't publish the grade if the student has already completed the exercise
if not self.completed:
if self._is_finished(): if self._is_finished():
final_feedback = self.data['feedback']['finish'] self.completed = True
try:
# don't publish the grade if the student has already completed the exercise self.runtime.publish(self, 'grade', {
if not self.completed: 'value': self._get_grade(),
if self._is_finished(): 'max_value': self.weight,
self.completed = True })
try: except NotImplementedError:
self.runtime.publish(self, 'grade', { # Note, this publish method is unimplemented in Studio runtimes,
'value': len(self.item_state) / float(tot_items) * self.weight, # so we have to figure that we're running in Studio for now
'max_value': self.weight, pass
})
except NotImplementedError:
# Note, this publish method is unimplemented in Studio runtimes,
# so we have to figure that we're running in Studio for now
pass
self.runtime.publish(self, 'xblock.drag-and-drop-v2.item.dropped', { self.runtime.publish(self, 'xblock.drag-and-drop-v2.item.dropped', {
'user_id': self.scope_ids.user_id, 'user_id': self.scope_ids.user_id,
'component_id': self._get_unique_id(), 'component_id': self._get_unique_id(),
'item_id': item['id'], 'item_id': item['id'],
'location': attempt['zone'], 'location': attempt.get('zone'),
'input': attempt.get('input'),
'is_correct_location': is_correct_location,
'is_correct': is_correct, 'is_correct': is_correct,
}) })
return { return {
'correct': is_correct, 'correct': is_correct,
'correct_location': is_correct_location,
'finished': self._is_finished(), 'finished': self._is_finished(),
'final_feedback': final_feedback, 'final_feedback': final_feedback,
'feedback': item['feedback']['correct'] if is_correct else item['feedback']['incorrect'] 'feedback': feedback
} }
@XBlock.json_handler @XBlock.json_handler
...@@ -216,10 +249,77 @@ class DragAndDropBlock(XBlock): ...@@ -216,10 +249,77 @@ class DragAndDropBlock(XBlock):
self.item_state = {} self.item_state = {}
return {'result':'success'} return {'result':'success'}
def _get_item_state(self):
"""
Returns the user item state.
Converts to a dict if data is stored in legacy tuple form.
"""
state = {}
for item_id, item in self.item_state.iteritems():
if isinstance(item, dict):
state[item_id] = item
else:
state[item_id] = {'top': item[0], 'left': item[1]}
return state
def _is_correct_input(self, item, val):
"""
Is submitted numerical value within the tolerated margin for this item.
"""
input_options = item.get('inputOptions')
if input_options:
try:
submitted_value = float(val)
except:
return False
else:
expected_value = input_options['value']
margin = input_options['margin']
return abs(submitted_value - expected_value) <= margin
else:
return True
def _get_grade(self):
"""
Returns the student's grade for this block.
"""
correct_count = 0
total_count = 0
item_state = self._get_item_state()
for item in self.data['items']:
if item['zone'] != 'none':
total_count += 1
item_id = str(item['id'])
if item_id in item_state:
if self._is_correct_input(item, item_state[item_id].get('input')):
correct_count += 1
return correct_count / float(total_count) * self.weight
def _is_finished(self): def _is_finished(self):
"""All items are at their correct place""" """
tot_items = sum(1 for i in self.data['items'] if i['zone'] != 'none') All items are at their correct place and a value has been
return len(self.item_state) == tot_items submitted for each item that expects a value.
"""
completed_count = 0
total_count = 0
item_state = self._get_item_state()
for item in self.data['items']:
if item['zone'] != 'none':
total_count += 1
item_id = str(item['id'])
if item_id in item_state:
if item.has_key('inputOptions'):
if item_state[item_id].has_key('input'):
completed_count += 1
else:
completed_count += 1
return completed_count == total_count
@XBlock.json_handler @XBlock.json_handler
def publish_event(self, data, suffix=''): def publish_event(self, data, suffix=''):
...@@ -244,5 +344,7 @@ class DragAndDropBlock(XBlock): ...@@ -244,5 +344,7 @@ class DragAndDropBlock(XBlock):
@staticmethod @staticmethod
def workbench_scenarios(): def workbench_scenarios():
"""A canned scenario for display in the workbench.""" """
A canned scenario for display in the workbench.
"""
return [("Drag-and-drop-v2 scenario", "<vertical_demo><drag-and-drop-v2/></vertical_demo>")] return [("Drag-and-drop-v2 scenario", "<vertical_demo><drag-and-drop-v2/></vertical_demo>")]
...@@ -65,13 +65,56 @@ ...@@ -65,13 +65,56 @@
z-index: 10 !important; z-index: 10 !important;
margin-bottom: 5px; margin-bottom: 5px;
padding: 10px; padding: 10px;
opacity: 1;
} }
.xblock--drag-and-drop .drag-container .items .option img { .xblock--drag-and-drop .drag-container .items .option img {
max-width: 100%; max-width: 100%;
} }
.xblock--drag-and-drop .option.fade { opacity: 0.6; } .xblock--drag-and-drop .drag-container .items .option .numerical-input {
display: none;
height: 32px;
position: absolute;
left: calc(100% + 5px);
top: calc(50% - 16px);
}
.xblock--drag-and-drop .drag-container .items .option.within-dropzone .numerical-input {
display: block;
}
.xblock--drag-and-drop .drag-container .items .option.within-dropzone .numerical-input .input {
display: inline-block;
width: 144px;
}
.xblock--drag-and-drop .drag-container .items .option.within-dropzone .numerical-input .submit-input {
box-sizing: border-box;
position: absolute;
left: 150px;
top: 4px;
height: 24px;
}
.xblock--drag-and-drop .drag-container .items .option.within-dropzone .numerical-input.correct .input-submit,
.xblock--drag-and-drop .drag-container .items .option.within-dropzone .numerical-input.incorrect .input-submit {
display: none;
}
.xblock--drag-and-drop .drag-container .items .option.within-dropzone .numerical-input.correct .input {
background: #ceffce;
color: #0dad0d;
}
.xblock--drag-and-drop .drag-container .items .option.within-dropzone .numerical-input.incorrect .input {
background: #ffcece;
color: #ad0d0d;
}
.xblock--drag-and-drop .drag-container .items .option.fade {
opacity: 0.5;
}
/*** Drop Target ***/ /*** Drop Target ***/
......
...@@ -207,6 +207,11 @@ ...@@ -207,6 +207,11 @@
width: 40px; width: 40px;
} }
.xblock--drag-and-drop .items-form .item-numerical-value,
.xblock--drag-and-drop .items-form .item-numerical-margin {
width: 60px;
}
.xblock--drag-and-drop .items-form textarea { .xblock--drag-and-drop .items-form textarea {
width: 97%; width: 97%;
margin: 0 1%; margin: 0 1%;
......
...@@ -9,6 +9,7 @@ function DragAndDropBlock(runtime, element) { ...@@ -9,6 +9,7 @@ function DragAndDropBlock(runtime, element) {
var dragAndDrop = (function($) { var dragAndDrop = (function($) {
var _fn = { var _fn = {
pupup_ts: Date.now(),
// DOM Elements // DOM Elements
$ul: $('.xblock--drag-and-drop .items', element), $ul: $('.xblock--drag-and-drop .items', element),
...@@ -59,7 +60,7 @@ function DragAndDropBlock(runtime, element) { ...@@ -59,7 +60,7 @@ function DragAndDropBlock(runtime, element) {
_fn.$zones.droppable(_fn.options.drop); _fn.$zones.droppable(_fn.options.drop);
// Init click handlers // Init click handlers
_fn.clickHandlers.init(_fn.$items, _fn.$zones); _fn.eventHandlers.init(_fn.$items, _fn.$zones);
// Position the already correct items // Position the already correct items
_fn.items.init(); _fn.items.init();
...@@ -94,26 +95,38 @@ function DragAndDropBlock(runtime, element) { ...@@ -94,26 +95,38 @@ function DragAndDropBlock(runtime, element) {
reset: function() { reset: function() {
_fn.$items.draggable('enable'); _fn.$items.draggable('enable');
_fn.$items.find('.numerical-input').removeClass('correct incorrect');
_fn.$items.find('.numerical-input .input').prop('disabled', false).val('');
_fn.$items.find('.numerical-input .submit-input').prop('disabled', false);
_fn.$items.each(function(index, element) { _fn.$items.each(function(index, element) {
_fn.clickHandlers.drag.reset($(element)); _fn.eventHandlers.drag.reset($(element));
}); });
_fn.$popup.hide(); _fn.$popup.hide();
_fn.$reset_button.hide(); _fn.$reset_button.hide();
_fn.feedback.set(_fn.data.feedback.start); _fn.feedback.set(_fn.data.feedback.start);
}, },
clickHandlers: { eventHandlers: {
init: function($drag, $dropzone) { init: function($drag, $dropzone) {
var clk = _fn.clickHandlers; var handlers = _fn.eventHandlers;
$drag.on('dragstart', clk.drag.start); $drag.on('dragstart', handlers.drag.start);
$drag.on('dragstop', clk.drag.stop); $drag.on('dragstop', handlers.drag.stop);
$dropzone.on('drop', clk.drop.success); $dropzone.on('drop', handlers.drop.success);
$dropzone.on('dropover', clk.drop.hover); $dropzone.on('dropover', handlers.drop.hover);
$(document).on('click', clk.popup.close); $(element).on('click', '.submit-input', handlers.drag.submitInput);
_fn.$reset_button.on('click', clk.problem.reset);
$(document).on('click', function(evt) {
// Click should only close the popup if the popup has been
// visible for at least one second.
var popup_timeout = 1000;
if (Date.now() - _fn.popup_ts > popup_timeout) {
handlers.popup.close(evt);
}
});
_fn.$reset_button.on('click', handlers.problem.reset);
}, },
problem: { problem: {
reset: function(event, ui) { reset: function(event, ui) {
...@@ -147,7 +160,7 @@ function DragAndDropBlock(runtime, element) { ...@@ -147,7 +160,7 @@ function DragAndDropBlock(runtime, element) {
}, },
drag: { drag: {
start: function(event, ui) { start: function(event, ui) {
_fn.clickHandlers.popup.close(event, ui); _fn.eventHandlers.popup.close(event, ui);
target = $(event.currentTarget); target = $(event.currentTarget);
target.removeClass('within-dropzone fade'); target.removeClass('within-dropzone fade');
...@@ -157,15 +170,19 @@ function DragAndDropBlock(runtime, element) { ...@@ -157,15 +170,19 @@ function DragAndDropBlock(runtime, element) {
}, },
stop: function(event, ui) { stop: function(event, ui) {
var $el = $(event.currentTarget), var $el = $(event.currentTarget);
val = $el.data('value'),
zone = $el.data('zone') || null;
if (!$el.hasClass('within-dropzone')) { if (!$el.hasClass('within-dropzone')) {
// Return to original position // Return to original position
_fn.clickHandlers.drag.reset($el); _fn.eventHandlers.drag.reset($el);
return; } else {
_fn.eventHandlers.drag.submitLocation($el);
} }
},
submitLocation: function($el) {
var val = $el.data('value'),
zone = $el.data('zone') || null;
$.post(runtime.handlerUrl(element, 'do_attempt'), $.post(runtime.handlerUrl(element, 'do_attempt'),
JSON.stringify({ JSON.stringify({
...@@ -174,7 +191,7 @@ function DragAndDropBlock(runtime, element) { ...@@ -174,7 +191,7 @@ function DragAndDropBlock(runtime, element) {
top: $el.css('top'), top: $el.css('top'),
left: $el.css('left') left: $el.css('left')
}), 'json').done(function(data){ }), 'json').done(function(data){
if (data.correct) { if (data.correct_location) {
$el.draggable('disable'); $el.draggable('disable');
if (data.finished) { if (data.finished) {
...@@ -182,7 +199,42 @@ function DragAndDropBlock(runtime, element) { ...@@ -182,7 +199,42 @@ function DragAndDropBlock(runtime, element) {
} }
} else { } else {
// Return to original position // Return to original position
_fn.clickHandlers.drag.reset($el); _fn.eventHandlers.drag.reset($el);
}
if (data.feedback) {
_fn.feedback.popup(data.feedback, data.correct);
}
});
},
submitInput: function(evt) {
var $el = $(this).closest('li.option');
var $input_div = $el.find('.numerical-input');
var $input = $input_div.find('.input');
var val = $el.data('value');
if (!$input.val()) {
// Don't submit if the user didn't enter anything yet.
return;
}
$input.prop('disabled', true);
$input_div.find('.submit-input').prop('disabled', true);
$.post(runtime.handlerUrl(element, 'do_attempt'),
JSON.stringify({
val: val,
input: $input.val()
}), 'json').done(function(data){
if (data.correct) {
$input_div.removeClass('incorrect').addClass('correct');
} else {
$input_div.removeClass('correct').addClass('incorrect');
}
if (data.finished) {
_fn.finish(data.final_feedback);
} }
if (data.feedback) { if (data.feedback) {
...@@ -192,7 +244,7 @@ function DragAndDropBlock(runtime, element) { ...@@ -192,7 +244,7 @@ function DragAndDropBlock(runtime, element) {
}, },
set: function($el, top, left) { set: function($el, top, left) {
$el.addClass('within-dropzone fade') $el.addClass('within-dropzone')
.css({ .css({
top: top, top: top,
left: left left: left
...@@ -215,6 +267,10 @@ function DragAndDropBlock(runtime, element) { ...@@ -215,6 +267,10 @@ function DragAndDropBlock(runtime, element) {
}, },
success: function(event, ui) { success: function(event, ui) {
ui.draggable.addClass('within-dropzone'); ui.draggable.addClass('within-dropzone');
var item = _fn.data.items[ui.draggable.data('value')];
if (item.inputOptions) {
ui.draggable.find('.input').focus();
}
} }
} }
}, },
...@@ -225,8 +281,19 @@ function DragAndDropBlock(runtime, element) { ...@@ -225,8 +281,19 @@ function DragAndDropBlock(runtime, element) {
var $el = $(this), var $el = $(this),
saved_entry = _fn.data.state.items[$el.data('value')]; saved_entry = _fn.data.state.items[$el.data('value')];
if (saved_entry) { if (saved_entry) {
_fn.clickHandlers.drag.set($el, var $input_div = $el.find('.numerical-input')
saved_entry[0], saved_entry[1]); var $input = $input_div.find('.input');
$input.val(saved_entry.input);
console.log('wuwuwu', saved_entry)
if ('input' in saved_entry) {
$input_div.addClass(saved_entry.correct_input ? 'correct' : 'incorrect');
$input.prop('disabled', true);
$input_div.find('.submit-input').prop('disabled', true);
}
if ('input' in saved_entry || saved_entry.correct_input) {
$el.addClass('fade');
}
_fn.eventHandlers.drag.set($el, saved_entry.top, saved_entry.left);
} }
}); });
}, },
...@@ -297,7 +364,9 @@ function DragAndDropBlock(runtime, element) { ...@@ -297,7 +364,9 @@ function DragAndDropBlock(runtime, element) {
}); });
_fn.$popup.find(".popup-content").html(str); _fn.$popup.find(".popup-content").html(str);
return _fn.$popup.show(); _fn.$popup.show();
_fn.popup_ts = Date.now();
} }
}, },
......
...@@ -312,6 +312,11 @@ function DragAndDropEditBlock(runtime, element) { ...@@ -312,6 +312,11 @@ function DragAndDropEditBlock(runtime, element) {
oldItem.size.height.length - 2); oldItem.size.height.length - 2);
if (ctx.height == 'au') ctx.height = '0'; if (ctx.height == 'au') ctx.height = '0';
if (oldItem && oldItem.inputOptions) {
ctx.numericalValue = oldItem.inputOptions.value;
ctx.numericalMargin = oldItem.inputOptions.margin;
}
_fn.build.form.item.count++; _fn.build.form.item.count++;
$form.append(tpl(ctx)); $form.append(tpl(ctx));
_fn.build.form.item.enableDelete(); _fn.build.form.item.enableDelete();
...@@ -361,7 +366,7 @@ function DragAndDropEditBlock(runtime, element) { ...@@ -361,7 +366,7 @@ function DragAndDropEditBlock(runtime, element) {
if (width === '0') width = 'auto'; if (width === '0') width = 'auto';
else width = width + 'px'; else width = width + 'px';
items.push({ var data = {
displayName: name, displayName: name,
zone: $el.find('.zone-select').val(), zone: $el.find('.zone-select').val(),
id: i, id: i,
...@@ -374,7 +379,18 @@ function DragAndDropEditBlock(runtime, element) { ...@@ -374,7 +379,18 @@ function DragAndDropEditBlock(runtime, element) {
height: height height: height
}, },
backgroundImage: backgroundImage backgroundImage: backgroundImage
}); };
var numValue = parseFloat($el.find('.item-numerical-value').val());
var numMargin = parseFloat($el.find('.item-numerical-margin').val());
if (isFinite(numValue)) {
data.inputOptions = {
value: numValue,
margin: isFinite(numMargin) ? numMargin : 0
}
}
items.push(data);
} }
}); });
......
...@@ -2,6 +2,12 @@ ...@@ -2,6 +2,12 @@
<li class="option" data-value="{{ id }}" <li class="option" data-value="{{ id }}"
style="width: {{ size.width }}; height: {{ size.height }}"> style="width: {{ size.width }}; height: {{ size.height }}">
{{{ displayName }}} {{{ displayName }}}
{{#if inputOptions}}
<div class="numerical-input">
<input class="input" type="text" />
<button class="submit-input">ok</button>
</div>
{{/if}}
</li> </li>
</script> </script>
...@@ -9,6 +15,10 @@ ...@@ -9,6 +15,10 @@
<li class="option" data-value="{{ id }}" <li class="option" data-value="{{ id }}"
style="width: {{ size.width }}; height: {{ size.height }}"> style="width: {{ size.width }}; height: {{ size.height }}">
<img src="{{ backgroundImage }}" /> <img src="{{ backgroundImage }}" />
{{#if inputOptions}}
<input class="input" type="text" />
<button class="submit-input">ok</button>
{{/if}}
</li> </li>
</script> </script>
...@@ -76,5 +86,11 @@ ...@@ -76,5 +86,11 @@
<label>Height (px - 0 for auto)</label> <label>Height (px - 0 for auto)</label>
<input type="text" class="item-height" value="{{ height }}"></input> <input type="text" class="item-height" value="{{ height }}"></input>
</div> </div>
<div class="row">
<label>Optional numerical value</label>
<input type="text" class="item-numerical-value" value="{{ numericalValue }}"></input>
<label>Margin ±</label>
<input type="text" class="item-numerical-margin" value="{{ numericalMargin }}"></input>
</div>
</div> </div>
</script> </script>
...@@ -46,6 +46,10 @@ ...@@ -46,6 +46,10 @@
"size": { "size": {
"width": "190px", "width": "190px",
"height": "auto" "height": "auto"
},
"inputOptions": {
"value": 100,
"margin": 5
} }
}, },
{ {
......
...@@ -27,7 +27,8 @@ ...@@ -27,7 +27,8 @@
"size": { "size": {
"width": "190px", "width": "190px",
"height": "auto" "height": "auto"
} },
"inputOptions": false
}, },
{ {
"displayName": "B", "displayName": "B",
...@@ -36,7 +37,8 @@ ...@@ -36,7 +37,8 @@
"size": { "size": {
"width": "190px", "width": "190px",
"height": "auto" "height": "auto"
} },
"inputOptions": true
}, },
{ {
"displayName": "X", "displayName": "X",
...@@ -45,7 +47,8 @@ ...@@ -45,7 +47,8 @@
"size": { "size": {
"width": "100px", "width": "100px",
"height": "100px" "height": "100px"
} },
"inputOptions": false
}, },
{ {
"displayName": "", "displayName": "",
...@@ -54,7 +57,8 @@ ...@@ -54,7 +57,8 @@
"size": { "size": {
"width": "190px", "width": "190px",
"height": "auto" "height": "auto"
} },
"inputOptions": false
} }
], ],
"state": { "state": {
......
...@@ -27,7 +27,8 @@ ...@@ -27,7 +27,8 @@
"size": { "size": {
"width": "190px", "width": "190px",
"height": "auto" "height": "auto"
} },
"inputOptions": false
}, },
{ {
"displayName": "<i>B</i>", "displayName": "<i>B</i>",
...@@ -36,7 +37,8 @@ ...@@ -36,7 +37,8 @@
"size": { "size": {
"width": "190px", "width": "190px",
"height": "auto" "height": "auto"
} },
"inputOptions": true
}, },
{ {
"displayName": "X", "displayName": "X",
...@@ -45,7 +47,8 @@ ...@@ -45,7 +47,8 @@
"size": { "size": {
"width": "100px", "width": "100px",
"height": "100px" "height": "100px"
} },
"inputOptions": false
}, },
{ {
"displayName": "", "displayName": "",
...@@ -54,7 +57,8 @@ ...@@ -54,7 +57,8 @@
"size": { "size": {
"width": "190px", "width": "190px",
"height": "auto" "height": "auto"
} },
"inputOptions": false
} }
], ],
"state": { "state": {
......
...@@ -46,6 +46,10 @@ ...@@ -46,6 +46,10 @@
"size": { "size": {
"width": "190px", "width": "190px",
"height": "auto" "height": "auto"
},
"inputOptions": {
"value": 100,
"margin": 5
} }
}, },
{ {
......
...@@ -46,6 +46,10 @@ ...@@ -46,6 +46,10 @@
"size": { "size": {
"width": "190px", "width": "190px",
"height": "auto" "height": "auto"
},
"inputOptions": {
"value": 100,
"margin": 5
} }
}, },
{ {
......
...@@ -46,6 +46,10 @@ ...@@ -46,6 +46,10 @@
"size": { "size": {
"width": "190px", "width": "190px",
"height": "auto" "height": "auto"
},
"inputOptions": {
"value": 100,
"margin": 5
} }
}, },
{ {
......
# Imports ########################################################### # Imports ###########################################################
from xml.sax.saxutils import escape from xml.sax.saxutils import escape
from selenium.webdriver.support.ui import WebDriverWait
from tests.utils import load_resource from tests.utils import load_resource
from workbench import scenarios from workbench import scenarios
...@@ -72,4 +73,15 @@ class BaseIntegrationTest(SeleniumTest): ...@@ -72,4 +73,15 @@ class BaseIntegrationTest(SeleniumTest):
return element.get_attribute('class').split() return element.get_attribute('class').split()
def scroll_to(self, y): def scroll_to(self, y):
self.browser.execute_script('window.scrollTo(0, {0})'.format(y)) self.browser.execute_script('window.scrollTo(0, {0})'.format(y))
\ No newline at end of file
def wait_until_contains_html(self, html, elem):
wait = WebDriverWait(elem, 2)
wait.until(lambda e: html in e.get_attribute('innerHTML'),
u"{} should be in {}".format(html, elem.get_attribute('innerHTML')))
def wait_until_has_class(self, class_name, elem):
wait = WebDriverWait(elem, 2)
wait.until(lambda e: class_name in e.get_attribute('class').split(),
u"Class name {} not in {}".format(class_name, elem.get_attribute('class')))
...@@ -20,6 +20,7 @@ class TestCustomDataDragAndDropRendering(BaseIntegrationTest): ...@@ -20,6 +20,7 @@ class TestCustomDataDragAndDropRendering(BaseIntegrationTest):
items = self._get_items() items = self._get_items()
self.assertEqual(len(items), 3) self.assertEqual(len(items), 3)
self.assertEqual(self.get_element_html(items[0]), "<b>A</b>") self.assertIn('<b>A</b>', self.get_element_html(items[0]))
self.assertEqual(self.get_element_html(items[1]), "<i>B</i>") self.assertIn('<i>B</i>', self.get_element_html(items[1]))
self.assertEqual(self.get_element_html(items[2]), '<span style="color:red">X</span>') self.assertIn('<input class="input" type="text">', self.get_element_html(items[1]))
\ No newline at end of file self.assertIn('<span style="color:red">X</span>', self.get_element_html(items[2]))
...@@ -4,11 +4,12 @@ from tests.integration.test_base import BaseIntegrationTest ...@@ -4,11 +4,12 @@ from tests.integration.test_base import BaseIntegrationTest
class ItemDefinition(object): class ItemDefinition(object):
def __init__(self, item_id, zone_id, feedback_positive, feedback_negative): def __init__(self, item_id, zone_id, feedback_positive, feedback_negative, input=None):
self.feedback_negative = feedback_negative self.feedback_negative = feedback_negative
self.feedback_positive = feedback_positive self.feedback_positive = feedback_positive
self.zone_id = zone_id self.zone_id = zone_id
self.item_id = item_id self.item_id = item_id
self.input = input
class InteractionTestFixture(BaseIntegrationTest): class InteractionTestFixture(BaseIntegrationTest):
...@@ -58,21 +59,40 @@ class InteractionTestFixture(BaseIntegrationTest): ...@@ -58,21 +59,40 @@ class InteractionTestFixture(BaseIntegrationTest):
zones_container = self._page.find_element_by_css_selector('div.target') zones_container = self._page.find_element_by_css_selector('div.target')
return zones_container.find_elements_by_xpath("//div[@data-zone='{zone_id}']".format(zone_id=zone_id))[0] return zones_container.find_elements_by_xpath("//div[@data-zone='{zone_id}']".format(zone_id=zone_id))[0]
def _get_input_div_by_value(self, item_value):
element = self._get_item_by_value(item_value)
return element.find_element_by_class_name('numerical-input')
def _send_input(self, item_value, value):
element = self._get_item_by_value(item_value)
element.find_element_by_class_name('input').send_keys(value)
element.find_element_by_class_name('submit-input').click()
def drag_item_to_zone(self, item_value, zone_id): def drag_item_to_zone(self, item_value, zone_id):
element = self._get_item_by_value(item_value) element = self._get_item_by_value(item_value)
target = self._get_zone_by_id(zone_id) target = self._get_zone_by_id(zone_id)
action_chains = ActionChains(self.browser) action_chains = ActionChains(self.browser)
action_chains.drag_and_drop(element, target).perform() action_chains.drag_and_drop(element, target).perform()
def test_correct_item_positive_feedback(self): def test_item_positive_feedback_on_good_move(self):
feedback_popup = self._page.find_element_by_css_selector(".popup-content") feedback_popup = self._page.find_element_by_css_selector(".popup-content")
for definition in self._get_correct_item_for_zone().values(): for definition in self._get_correct_item_for_zone().values():
self.drag_item_to_zone(definition.item_id, definition.zone_id) if not definition.input:
self.assertEqual(self.get_element_html(feedback_popup), definition.feedback_positive) self.drag_item_to_zone(definition.item_id, definition.zone_id)
self.wait_until_contains_html(definition.feedback_positive, feedback_popup)
def test_incorrect_item_negative_feedback(self): def test_item_positive_feedback_on_good_input(self):
feedback_popup = self._page.find_element_by_css_selector(".popup-content")
for definition in self._get_correct_item_for_zone().values():
if definition.input:
self.drag_item_to_zone(definition.item_id, definition.zone_id)
self._send_input(definition.item_id, definition.input)
input_div = self._get_input_div_by_value(definition.item_id)
self.wait_until_has_class('correct', input_div)
self.wait_until_contains_html(definition.feedback_positive, feedback_popup)
def test_item_negative_feedback_on_bad_move(self):
feedback_popup = self._page.find_element_by_css_selector(".popup-content") feedback_popup = self._page.find_element_by_css_selector(".popup-content")
for definition in self.items_map.values(): for definition in self.items_map.values():
...@@ -80,7 +100,17 @@ class InteractionTestFixture(BaseIntegrationTest): ...@@ -80,7 +100,17 @@ class InteractionTestFixture(BaseIntegrationTest):
if zone == definition.zone_id: if zone == definition.zone_id:
continue continue
self.drag_item_to_zone(definition.item_id, zone) self.drag_item_to_zone(definition.item_id, zone)
self.assertEqual(self.get_element_html(feedback_popup), definition.feedback_negative) self.wait_until_contains_html(definition.feedback_negative, feedback_popup)
def test_item_positive_feedback_on_bad_input(self):
feedback_popup = self._page.find_element_by_css_selector(".popup-content")
for definition in self._get_correct_item_for_zone().values():
if definition.input:
self.drag_item_to_zone(definition.item_id, definition.zone_id)
self._send_input(definition.item_id, '1999999')
input_div = self._get_input_div_by_value(definition.item_id)
self.wait_until_has_class('incorrect', input_div)
self.wait_until_contains_html(definition.feedback_negative, feedback_popup)
def test_final_feedback_and_reset(self): def test_final_feedback_and_reset(self):
feedback_message = self._get_feedback_message() feedback_message = self._get_feedback_message()
...@@ -93,8 +123,12 @@ class InteractionTestFixture(BaseIntegrationTest): ...@@ -93,8 +123,12 @@ class InteractionTestFixture(BaseIntegrationTest):
for item_key, definition in items.items(): for item_key, definition in items.items():
self.drag_item_to_zone(item_key, definition.zone_id) self.drag_item_to_zone(item_key, definition.zone_id)
if definition.input:
self._send_input(item_key, definition.input)
input_div = self._get_input_div_by_value(item_key)
self.wait_until_has_class('correct', input_div)
self.assertEqual(self.get_element_html(feedback_message), self.feedback['final']) self.wait_until_contains_html(self.feedback['final'], feedback_message)
# scrolling to have `reset` visible, otherwise it does not receive a click # scrolling to have `reset` visible, otherwise it does not receive a click
# this is due to xblock workbench header that consumes top 40px - selenium scrolls so page so that target # this is due to xblock workbench header that consumes top 40px - selenium scrolls so page so that target
...@@ -103,7 +137,7 @@ class InteractionTestFixture(BaseIntegrationTest): ...@@ -103,7 +137,7 @@ class InteractionTestFixture(BaseIntegrationTest):
reset = self._page.find_element_by_css_selector(".reset-button") reset = self._page.find_element_by_css_selector(".reset-button")
reset.click() reset.click()
self.assertEqual(self.get_element_html(feedback_message), self.feedback['intro']) self.wait_until_contains_html(self.feedback['intro'], feedback_message)
locations_after_reset = get_locations() locations_after_reset = get_locations()
for item_key in items.keys(): for item_key in items.keys():
...@@ -113,7 +147,7 @@ class InteractionTestFixture(BaseIntegrationTest): ...@@ -113,7 +147,7 @@ class InteractionTestFixture(BaseIntegrationTest):
class CustomDataInteractionTest(InteractionTestFixture): class CustomDataInteractionTest(InteractionTestFixture):
items_map = { items_map = {
0: ItemDefinition(0, 'Zone A', "Yes A", "No A"), 0: ItemDefinition(0, 'Zone A', "Yes A", "No A"),
1: ItemDefinition(1, 'Zone B', "Yes B", "No B"), 1: ItemDefinition(1, 'Zone B', "Yes B", "No B", "102"),
2: ItemDefinition(2, None, "", "No Zone for this") 2: ItemDefinition(2, None, "", "No Zone for this")
} }
...@@ -131,7 +165,7 @@ class CustomDataInteractionTest(InteractionTestFixture): ...@@ -131,7 +165,7 @@ class CustomDataInteractionTest(InteractionTestFixture):
class CustomHtmlDataInteractionTest(InteractionTestFixture): class CustomHtmlDataInteractionTest(InteractionTestFixture):
items_map = { items_map = {
0: ItemDefinition(0, 'Zone <i>A</i>', "Yes <b>A</b>", "No <b>A</b>"), 0: ItemDefinition(0, 'Zone <i>A</i>', "Yes <b>A</b>", "No <b>A</b>"),
1: ItemDefinition(1, 'Zone <b>B</b>', "Yes <i>B</i>", "No <i>B</i>"), 1: ItemDefinition(1, 'Zone <b>B</b>', "Yes <i>B</i>", "No <i>B</i>", "95"),
2: ItemDefinition(2, None, "", "No Zone for <i>X</i>") 2: ItemDefinition(2, None, "", "No Zone for <i>X</i>")
} }
...@@ -143,4 +177,4 @@ class CustomHtmlDataInteractionTest(InteractionTestFixture): ...@@ -143,4 +177,4 @@ class CustomHtmlDataInteractionTest(InteractionTestFixture):
} }
def _get_scenario_xml(self): def _get_scenario_xml(self):
return self._get_custom_scenario_xml("integration/data/test_html_data.json") return self._get_custom_scenario_xml("integration/data/test_html_data.json")
\ No newline at end of file
#!/usr/bin/env python
"""Manage.py file for xblock-drag-and-drop-v2"""
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
mock bok_choy==0.3.2
nose cookiecutter==0.7.1
coverage coverage==3.7.1
rednose diff-cover==0.7.2
nose-parameterized Django==1.4.16
django_nose==1.2
fs==0.5.0
lazy==1.2
lxml==3.4.1
mock==1.0.1
nose==1.3.4
nose-parameterized==0.3.5
pep8==1.5.7
pylint==0.28
pypng==0.0.17
rednose==0.4.1
requests==2.4.3
selenium==2.44.0
simplejson==3.6.5
webob==1.4
-e git+https://github.com/open-craft/XBlock.git@3ece535ee8e095f21de2f8b28cc720145749f0d6#egg=XBlock
-e git+https://github.com/edx/acid-block.git@459aff7b63db8f2c5decd1755706c1a64fb4ebb1#egg=acid-xblock
-e git+https://github.com/pmitros/django-pyfs.git@514607d78535fd80bfd23184cd292ee5799b500d#egg=djpyfs
-e git+https://github.com/open-craft/xblock-sdk.git@39b00c58931664ce29bb4b38df827fe237a69613#egg=xblock-sdk
-e . -e .
DEBUG = True
INSTALLED_APPS = (
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'workbench',
'sample_xblocks.basic',
'django_nose',
)
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': 'drag_and_drop_v2.db'
}
}
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'
}
}
TEMPLATE_LOADERS = (
'django.template.loaders.filesystem.Loader',
'django.template.loaders.eggs.Loader',
)
ROOT_URLCONF = 'urls'
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
STATIC_ROOT = ''
STATIC_URL = '/static/'
WORKBENCH = {'reset_state_on_restart': False}
import os
import logging import logging
import json import json
import unittest import unittest
...@@ -13,9 +14,12 @@ from nose.tools import ( ...@@ -13,9 +14,12 @@ from nose.tools import (
assert_in assert_in
) )
from tests.utils import load_resource
import drag_and_drop_v2 import drag_and_drop_v2
# Silence too verbose Django logging # Silence too verbose Django logging
logging.disable(logging.DEBUG) logging.disable(logging.DEBUG)
...@@ -130,6 +134,7 @@ class BaseDragAndDropAjaxFixture(object): ...@@ -130,6 +134,7 @@ class BaseDragAndDropAjaxFixture(object):
"final_feedback": None, "final_feedback": None,
"finished": False, "finished": False,
"correct": False, "correct": False,
"correct_location": False,
"feedback": self.FEEDBACK[item_id]["incorrect"] "feedback": self.FEEDBACK[item_id]["incorrect"]
}) })
...@@ -141,6 +146,7 @@ class BaseDragAndDropAjaxFixture(object): ...@@ -141,6 +146,7 @@ class BaseDragAndDropAjaxFixture(object):
"final_feedback": None, "final_feedback": None,
"finished": False, "finished": False,
"correct": False, "correct": False,
"correct_location": False,
"feedback": self.FEEDBACK[item_id]["incorrect"] "feedback": self.FEEDBACK[item_id]["incorrect"]
}) })
...@@ -152,9 +158,96 @@ class BaseDragAndDropAjaxFixture(object): ...@@ -152,9 +158,96 @@ class BaseDragAndDropAjaxFixture(object):
"final_feedback": None, "final_feedback": None,
"finished": False, "finished": False,
"correct": True, "correct": True,
"correct_location": True,
"feedback": self.FEEDBACK[item_id]["correct"] "feedback": self.FEEDBACK[item_id]["correct"]
}) })
def test_do_attempt_with_input(self):
data = json.dumps({"val": 1, "zone": self.ZONE_B, "top": "22px", "left": "222px"})
res = json.loads(self._block.handle('do_attempt', make_request(data)).body)
assert_equals(res, {
"finished": False,
"correct": False,
"correct_location": True,
"feedback": None,
"final_feedback": None
})
expected = self.get_data_response()
expected["state"] = {
"items": {
"1": {"top": "22px", "left": "222px", "correct_input": False}
},
"finished": False
}
get_data = json.loads(self._block.handle('get_data', Mock()).body)
assert_equals(expected, get_data)
data = json.dumps({"val": 1, "input": "250"})
res = json.loads(self._block.handle('do_attempt', make_request(data)).body)
assert_equals(res, {
"finished": False,
"correct": False,
"correct_location": True,
"feedback": self.FEEDBACK[1]['incorrect'],
"final_feedback": None
})
expected = self.get_data_response()
expected["state"] = {
"items": {
"1": {"top": "22px", "left": "222px", "input": "250", "correct_input": False}
},
"finished": False
}
get_data = json.loads(self._block.handle('get_data', Mock()).body)
assert_equals(expected, get_data)
data = json.dumps({"val": 1, "input": "103"})
res = json.loads(self._block.handle('do_attempt', make_request(data)).body)
assert_equals(res, {
"finished": False,
"correct": True,
"correct_location": True,
"feedback": self.FEEDBACK[1]['correct'],
"final_feedback": None
})
expected = self.get_data_response()
expected["state"] = {
"items": {
"1": {"top": "22px", "left": "222px", "input": "103", "correct_input": True}
},
"finished": False
}
get_data = json.loads(self._block.handle('get_data', Mock()).body)
assert_equals(expected, get_data)
def test_grading(self):
published_grades = []
def mock_publish(self, event, params):
if event == 'grade':
published_grades.append(params)
self._block.runtime.publish = mock_publish
data = json.dumps({"val": 0, "zone": self.ZONE_A, "top": "11px", "left": "111px"})
self._block.handle('do_attempt', make_request(data))
assert_equals(1, len(published_grades))
assert_equals({'value': 0.5, 'max_value': 1}, published_grades[-1])
data = json.dumps({"val": 1, "zone": self.ZONE_B, "top": "22px", "left": "222px"})
json.loads(self._block.handle('do_attempt', make_request(data)).body)
assert_equals(2, len(published_grades))
assert_equals({'value': 0.5, 'max_value': 1}, published_grades[-1])
data = json.dumps({"val": 1, "input": "99"})
json.loads(self._block.handle('do_attempt', make_request(data)).body)
assert_equals(3, len(published_grades))
assert_equals({'value': 1, 'max_value': 1}, published_grades[-1])
def test_do_attempt_final(self): def test_do_attempt_final(self):
data = json.dumps({"val": 0, "zone": self.ZONE_A, "top": "11px", "left": "111px"}) data = json.dumps({"val": 0, "zone": self.ZONE_A, "top": "11px", "left": "111px"})
self._block.handle('do_attempt', make_request(data)) self._block.handle('do_attempt', make_request(data))
...@@ -162,7 +255,7 @@ class BaseDragAndDropAjaxFixture(object): ...@@ -162,7 +255,7 @@ class BaseDragAndDropAjaxFixture(object):
expected = self.get_data_response() expected = self.get_data_response()
expected["state"] = { expected["state"] = {
"items": { "items": {
"0": ["11px", "111px"] "0": {"top": "11px", "left": "111px", "correct_input": True}
}, },
"finished": False "finished": False
} }
...@@ -171,18 +264,21 @@ class BaseDragAndDropAjaxFixture(object): ...@@ -171,18 +264,21 @@ class BaseDragAndDropAjaxFixture(object):
data = json.dumps({"val": 1, "zone": self.ZONE_B, "top": "22px", "left": "222px"}) data = json.dumps({"val": 1, "zone": self.ZONE_B, "top": "22px", "left": "222px"})
res = json.loads(self._block.handle('do_attempt', make_request(data)).body) res = json.loads(self._block.handle('do_attempt', make_request(data)).body)
data = json.dumps({"val": 1, "input": "99"})
res = json.loads(self._block.handle('do_attempt', make_request(data)).body)
assert_equals(res, { assert_equals(res, {
"final_feedback": self.FINAL_FEEDBACK, "final_feedback": self.FINAL_FEEDBACK,
"finished": True, "finished": True,
"correct": True, "correct": True,
"correct_location": True,
"feedback": self.FEEDBACK[1]["correct"] "feedback": self.FEEDBACK[1]["correct"]
}) })
expected = self.get_data_response() expected = self.get_data_response()
expected["state"] = { expected["state"] = {
"items": { "items": {
"0": ["11px", "111px"], "0": {"top": "11px", "left": "111px", "correct_input": True},
"1": ["22px", "222px"] "1": {"top": "22px", "left": "222px", "input": "99", "correct_input": True}
}, },
"finished": True "finished": True
} }
...@@ -204,12 +300,10 @@ class TestDragAndDropHtmlData(BaseDragAndDropAjaxFixture, unittest.TestCase): ...@@ -204,12 +300,10 @@ class TestDragAndDropHtmlData(BaseDragAndDropAjaxFixture, unittest.TestCase):
FINAL_FEEDBACK = "Final <b>Feed</b>" FINAL_FEEDBACK = "Final <b>Feed</b>"
def initial_data(self): def initial_data(self):
with open('tests/data/test_html_data.json') as f: return json.loads(load_resource('data/test_html_data.json'))
return json.load(f)
def get_data_response(self): def get_data_response(self):
with open('tests/data/test_get_html_data.json') as f: return json.loads(load_resource('data/test_get_html_data.json'))
return json.load(f)
class TestDragAndDropPlainData(BaseDragAndDropAjaxFixture, unittest.TestCase): class TestDragAndDropPlainData(BaseDragAndDropAjaxFixture, unittest.TestCase):
...@@ -225,12 +319,10 @@ class TestDragAndDropPlainData(BaseDragAndDropAjaxFixture, unittest.TestCase): ...@@ -225,12 +319,10 @@ class TestDragAndDropPlainData(BaseDragAndDropAjaxFixture, unittest.TestCase):
FINAL_FEEDBACK = "Final Feed" FINAL_FEEDBACK = "Final Feed"
def initial_data(self): def initial_data(self):
with open('tests/data/test_data.json') as f: return json.loads(load_resource('data/test_data.json'))
return json.load(f)
def get_data_response(self): def get_data_response(self):
with open('tests/data/test_get_data.json') as f: return json.loads(load_resource('data/test_get_data.json'))
return json.load(f)
def test_ajax_solve_and_reset(): def test_ajax_solve_and_reset():
block = make_block() block = make_block()
...@@ -244,7 +336,8 @@ def test_ajax_solve_and_reset(): ...@@ -244,7 +336,8 @@ def test_ajax_solve_and_reset():
block.handle('do_attempt', make_request(data)) block.handle('do_attempt', make_request(data))
assert_true(block.completed) assert_true(block.completed)
assert_equals(block.item_state, {0: ("11px", "111px"), 1: ("22px", "222px")}) assert_equals(block.item_state, {'0': {"top": "11px", "left": "111px"},
'1': {"top": "22px", "left": "222px"}})
block.handle('reset', make_request("{}")) block.handle('reset', make_request("{}"))
......
from django.conf.urls import include, url
urlpatterns = [
url(r'^', include('workbench.urls')),
]
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