Commit d2ac471c by Xavier Antoviaque

Merge pull request #26 from open-craft/stgallen

Add ability to ask for numerical value on drop.
parents 4482aa60 1c392662
...@@ -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