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.
Testing
-------
1. In a virtualenv, run
In a virtualenv, run
```bash
$ (cd .../xblock-sdk/; pip install -r requirements.txt)
$ (cd .../xblock-drag-and-drop-v2/; pip install -r tests/requirements.txt)
$ cd .../xblock-drag-and-drop-v2/
$ pip install -r tests/requirements.txt
```
2. In the xblock-sdk repository, create the following configuration
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):
To run the tests, from the xblock-drag-and-drop-v2 repository root:
```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
$ 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):
# Strip answers
del item['feedback']
del item['zone']
item['inputOptions'] = item.has_key('inputOptions')
if not self._is_finished():
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'] = {
'items': self.item_state,
'items': item_state,
'finished': self._is_finished()
}
......@@ -171,13 +177,37 @@ class DragAndDropBlock(XBlock):
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')
state = None
feedback = item['feedback']['incorrect']
final_feedback = None
is_correct = False
if item['zone'] == attempt['zone']:
self.item_state[item['id']] = (attempt['top'], attempt['left'])
is_correct_location = False
if 'input' in attempt:
state = self._get_item_state().get(str(item['id']))
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']
......@@ -188,7 +218,7 @@ class DragAndDropBlock(XBlock):
self.completed = True
try:
self.runtime.publish(self, 'grade', {
'value': len(self.item_state) / float(tot_items) * self.weight,
'value': self._get_grade(),
'max_value': self.weight,
})
except NotImplementedError:
......@@ -200,15 +230,18 @@ class DragAndDropBlock(XBlock):
'user_id': self.scope_ids.user_id,
'component_id': self._get_unique_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,
})
return {
'correct': is_correct,
'correct_location': is_correct_location,
'finished': self._is_finished(),
'final_feedback': final_feedback,
'feedback': item['feedback']['correct'] if is_correct else item['feedback']['incorrect']
'feedback': feedback
}
@XBlock.json_handler
......@@ -216,10 +249,77 @@ class DragAndDropBlock(XBlock):
self.item_state = {}
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):
"""All items are at their correct place"""
tot_items = sum(1 for i in self.data['items'] if i['zone'] != 'none')
return len(self.item_state) == tot_items
"""
All items are at their correct place and a value has been
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
def publish_event(self, data, suffix=''):
......@@ -244,5 +344,7 @@ class DragAndDropBlock(XBlock):
@staticmethod
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>")]
......@@ -65,13 +65,56 @@
z-index: 10 !important;
margin-bottom: 5px;
padding: 10px;
opacity: 1;
}
.xblock--drag-and-drop .drag-container .items .option img {
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 ***/
......
......@@ -207,6 +207,11 @@
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 {
width: 97%;
margin: 0 1%;
......
......@@ -9,6 +9,7 @@ function DragAndDropBlock(runtime, element) {
var dragAndDrop = (function($) {
var _fn = {
pupup_ts: Date.now(),
// DOM Elements
$ul: $('.xblock--drag-and-drop .items', element),
......@@ -59,7 +60,7 @@ function DragAndDropBlock(runtime, element) {
_fn.$zones.droppable(_fn.options.drop);
// Init click handlers
_fn.clickHandlers.init(_fn.$items, _fn.$zones);
_fn.eventHandlers.init(_fn.$items, _fn.$zones);
// Position the already correct items
_fn.items.init();
......@@ -94,26 +95,38 @@ function DragAndDropBlock(runtime, element) {
reset: function() {
_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.clickHandlers.drag.reset($(element));
_fn.eventHandlers.drag.reset($(element));
});
_fn.$popup.hide();
_fn.$reset_button.hide();
_fn.feedback.set(_fn.data.feedback.start);
},
clickHandlers: {
eventHandlers: {
init: function($drag, $dropzone) {
var clk = _fn.clickHandlers;
var handlers = _fn.eventHandlers;
$drag.on('dragstart', clk.drag.start);
$drag.on('dragstop', clk.drag.stop);
$drag.on('dragstart', handlers.drag.start);
$drag.on('dragstop', handlers.drag.stop);
$dropzone.on('drop', clk.drop.success);
$dropzone.on('dropover', clk.drop.hover);
$dropzone.on('drop', handlers.drop.success);
$dropzone.on('dropover', handlers.drop.hover);
$(document).on('click', clk.popup.close);
_fn.$reset_button.on('click', clk.problem.reset);
$(element).on('click', '.submit-input', handlers.drag.submitInput);
$(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: {
reset: function(event, ui) {
......@@ -147,7 +160,7 @@ function DragAndDropBlock(runtime, element) {
},
drag: {
start: function(event, ui) {
_fn.clickHandlers.popup.close(event, ui);
_fn.eventHandlers.popup.close(event, ui);
target = $(event.currentTarget);
target.removeClass('within-dropzone fade');
......@@ -157,15 +170,19 @@ function DragAndDropBlock(runtime, element) {
},
stop: function(event, ui) {
var $el = $(event.currentTarget),
val = $el.data('value'),
zone = $el.data('zone') || null;
var $el = $(event.currentTarget);
if (!$el.hasClass('within-dropzone')) {
// Return to original position
_fn.clickHandlers.drag.reset($el);
return;
_fn.eventHandlers.drag.reset($el);
} 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'),
JSON.stringify({
......@@ -174,7 +191,7 @@ function DragAndDropBlock(runtime, element) {
top: $el.css('top'),
left: $el.css('left')
}), 'json').done(function(data){
if (data.correct) {
if (data.correct_location) {
$el.draggable('disable');
if (data.finished) {
......@@ -182,7 +199,42 @@ function DragAndDropBlock(runtime, element) {
}
} else {
// 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) {
......@@ -192,7 +244,7 @@ function DragAndDropBlock(runtime, element) {
},
set: function($el, top, left) {
$el.addClass('within-dropzone fade')
$el.addClass('within-dropzone')
.css({
top: top,
left: left
......@@ -215,6 +267,10 @@ function DragAndDropBlock(runtime, element) {
},
success: function(event, ui) {
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) {
var $el = $(this),
saved_entry = _fn.data.state.items[$el.data('value')];
if (saved_entry) {
_fn.clickHandlers.drag.set($el,
saved_entry[0], saved_entry[1]);
var $input_div = $el.find('.numerical-input')
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) {
});
_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) {
oldItem.size.height.length - 2);
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++;
$form.append(tpl(ctx));
_fn.build.form.item.enableDelete();
......@@ -361,7 +366,7 @@ function DragAndDropEditBlock(runtime, element) {
if (width === '0') width = 'auto';
else width = width + 'px';
items.push({
var data = {
displayName: name,
zone: $el.find('.zone-select').val(),
id: i,
......@@ -374,7 +379,18 @@ function DragAndDropEditBlock(runtime, element) {
height: height
},
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 @@
<li class="option" data-value="{{ id }}"
style="width: {{ size.width }}; height: {{ size.height }}">
{{{ displayName }}}
{{#if inputOptions}}
<div class="numerical-input">
<input class="input" type="text" />
<button class="submit-input">ok</button>
</div>
{{/if}}
</li>
</script>
......@@ -9,6 +15,10 @@
<li class="option" data-value="{{ id }}"
style="width: {{ size.width }}; height: {{ size.height }}">
<img src="{{ backgroundImage }}" />
{{#if inputOptions}}
<input class="input" type="text" />
<button class="submit-input">ok</button>
{{/if}}
</li>
</script>
......@@ -76,5 +86,11 @@
<label>Height (px - 0 for auto)</label>
<input type="text" class="item-height" value="{{ height }}"></input>
</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>
</script>
......@@ -46,6 +46,10 @@
"size": {
"width": "190px",
"height": "auto"
},
"inputOptions": {
"value": 100,
"margin": 5
}
},
{
......
......@@ -27,7 +27,8 @@
"size": {
"width": "190px",
"height": "auto"
}
},
"inputOptions": false
},
{
"displayName": "B",
......@@ -36,7 +37,8 @@
"size": {
"width": "190px",
"height": "auto"
}
},
"inputOptions": true
},
{
"displayName": "X",
......@@ -45,7 +47,8 @@
"size": {
"width": "100px",
"height": "100px"
}
},
"inputOptions": false
},
{
"displayName": "",
......@@ -54,7 +57,8 @@
"size": {
"width": "190px",
"height": "auto"
}
},
"inputOptions": false
}
],
"state": {
......
......@@ -27,7 +27,8 @@
"size": {
"width": "190px",
"height": "auto"
}
},
"inputOptions": false
},
{
"displayName": "<i>B</i>",
......@@ -36,7 +37,8 @@
"size": {
"width": "190px",
"height": "auto"
}
},
"inputOptions": true
},
{
"displayName": "X",
......@@ -45,7 +47,8 @@
"size": {
"width": "100px",
"height": "100px"
}
},
"inputOptions": false
},
{
"displayName": "",
......@@ -54,7 +57,8 @@
"size": {
"width": "190px",
"height": "auto"
}
},
"inputOptions": false
}
],
"state": {
......
......@@ -46,6 +46,10 @@
"size": {
"width": "190px",
"height": "auto"
},
"inputOptions": {
"value": 100,
"margin": 5
}
},
{
......
......@@ -46,6 +46,10 @@
"size": {
"width": "190px",
"height": "auto"
},
"inputOptions": {
"value": 100,
"margin": 5
}
},
{
......
......@@ -46,6 +46,10 @@
"size": {
"width": "190px",
"height": "auto"
},
"inputOptions": {
"value": 100,
"margin": 5
}
},
{
......
# Imports ###########################################################
from xml.sax.saxutils import escape
from selenium.webdriver.support.ui import WebDriverWait
from tests.utils import load_resource
from workbench import scenarios
......@@ -73,3 +74,14 @@ class BaseIntegrationTest(SeleniumTest):
def scroll_to(self, y):
self.browser.execute_script('window.scrollTo(0, {0})'.format(y))
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):
items = self._get_items()
self.assertEqual(len(items), 3)
self.assertEqual(self.get_element_html(items[0]), "<b>A</b>")
self.assertEqual(self.get_element_html(items[1]), "<i>B</i>")
self.assertEqual(self.get_element_html(items[2]), '<span style="color:red">X</span>')
\ No newline at end of file
self.assertIn('<b>A</b>', self.get_element_html(items[0]))
self.assertIn('<i>B</i>', self.get_element_html(items[1]))
self.assertIn('<input class="input" type="text">', self.get_element_html(items[1]))
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
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_positive = feedback_positive
self.zone_id = zone_id
self.item_id = item_id
self.input = input
class InteractionTestFixture(BaseIntegrationTest):
......@@ -58,21 +59,40 @@ class InteractionTestFixture(BaseIntegrationTest):
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]
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):
element = self._get_item_by_value(item_value)
target = self._get_zone_by_id(zone_id)
action_chains = ActionChains(self.browser)
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")
for definition in self._get_correct_item_for_zone().values():
if not definition.input:
self.drag_item_to_zone(definition.item_id, definition.zone_id)
self.assertEqual(self.get_element_html(feedback_popup), definition.feedback_positive)
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")
for definition in self.items_map.values():
......@@ -80,7 +100,17 @@ class InteractionTestFixture(BaseIntegrationTest):
if zone == definition.zone_id:
continue
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):
feedback_message = self._get_feedback_message()
......@@ -93,8 +123,12 @@ class InteractionTestFixture(BaseIntegrationTest):
for item_key, definition in items.items():
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
# 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):
reset = self._page.find_element_by_css_selector(".reset-button")
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()
for item_key in items.keys():
......@@ -113,7 +147,7 @@ class InteractionTestFixture(BaseIntegrationTest):
class CustomDataInteractionTest(InteractionTestFixture):
items_map = {
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")
}
......@@ -131,7 +165,7 @@ class CustomDataInteractionTest(InteractionTestFixture):
class CustomHtmlDataInteractionTest(InteractionTestFixture):
items_map = {
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>")
}
......
#!/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
nose
coverage
rednose
nose-parameterized
bok_choy==0.3.2
cookiecutter==0.7.1
coverage==3.7.1
diff-cover==0.7.2
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 .
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 json
import unittest
......@@ -13,9 +14,12 @@ from nose.tools import (
assert_in
)
from tests.utils import load_resource
import drag_and_drop_v2
# Silence too verbose Django logging
logging.disable(logging.DEBUG)
......@@ -130,6 +134,7 @@ class BaseDragAndDropAjaxFixture(object):
"final_feedback": None,
"finished": False,
"correct": False,
"correct_location": False,
"feedback": self.FEEDBACK[item_id]["incorrect"]
})
......@@ -141,6 +146,7 @@ class BaseDragAndDropAjaxFixture(object):
"final_feedback": None,
"finished": False,
"correct": False,
"correct_location": False,
"feedback": self.FEEDBACK[item_id]["incorrect"]
})
......@@ -152,9 +158,96 @@ class BaseDragAndDropAjaxFixture(object):
"final_feedback": None,
"finished": False,
"correct": True,
"correct_location": True,
"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):
data = json.dumps({"val": 0, "zone": self.ZONE_A, "top": "11px", "left": "111px"})
self._block.handle('do_attempt', make_request(data))
......@@ -162,7 +255,7 @@ class BaseDragAndDropAjaxFixture(object):
expected = self.get_data_response()
expected["state"] = {
"items": {
"0": ["11px", "111px"]
"0": {"top": "11px", "left": "111px", "correct_input": True}
},
"finished": False
}
......@@ -171,18 +264,21 @@ class BaseDragAndDropAjaxFixture(object):
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)
data = json.dumps({"val": 1, "input": "99"})
res = json.loads(self._block.handle('do_attempt', make_request(data)).body)
assert_equals(res, {
"final_feedback": self.FINAL_FEEDBACK,
"finished": True,
"correct": True,
"correct_location": True,
"feedback": self.FEEDBACK[1]["correct"]
})
expected = self.get_data_response()
expected["state"] = {
"items": {
"0": ["11px", "111px"],
"1": ["22px", "222px"]
"0": {"top": "11px", "left": "111px", "correct_input": True},
"1": {"top": "22px", "left": "222px", "input": "99", "correct_input": True}
},
"finished": True
}
......@@ -204,12 +300,10 @@ class TestDragAndDropHtmlData(BaseDragAndDropAjaxFixture, unittest.TestCase):
FINAL_FEEDBACK = "Final <b>Feed</b>"
def initial_data(self):
with open('tests/data/test_html_data.json') as f:
return json.load(f)
return json.loads(load_resource('data/test_html_data.json'))
def get_data_response(self):
with open('tests/data/test_get_html_data.json') as f:
return json.load(f)
return json.loads(load_resource('data/test_get_html_data.json'))
class TestDragAndDropPlainData(BaseDragAndDropAjaxFixture, unittest.TestCase):
......@@ -225,12 +319,10 @@ class TestDragAndDropPlainData(BaseDragAndDropAjaxFixture, unittest.TestCase):
FINAL_FEEDBACK = "Final Feed"
def initial_data(self):
with open('tests/data/test_data.json') as f:
return json.load(f)
return json.loads(load_resource('data/test_data.json'))
def get_data_response(self):
with open('tests/data/test_get_data.json') as f:
return json.load(f)
return json.loads(load_resource('data/test_get_data.json'))
def test_ajax_solve_and_reset():
block = make_block()
......@@ -244,7 +336,8 @@ def test_ajax_solve_and_reset():
block.handle('do_attempt', make_request(data))
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("{}"))
......
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