Commit 1786a2c8 by Tim Krones

Merge pull request #1 from open-craft/initial-implementation

Initial implementation
parents e5487a6b 5125445e
__pycache__/
*.py[cod]
activetable_xblock.egg-info/**
.coverage
tests.integration.*.log
tests.integration.*.png
var/
language: python
python:
- 2.7
before_install:
- export DISPLAY=:99
- sh -e /etc/init.d/xvfb start
install:
- pip install -r test-requirements.txt
- pip install -r $VIRTUAL_ENV/src/xblock-sdk/requirements/base.txt
- pip install -r $VIRTUAL_ENV/src/xblock-sdk/requirements/test.txt
- pip install -r $VIRTUAL_ENV/src/xblock/requirements.txt
script:
- pep8 --max-line-length=100 activetable
- pylint activetable
- ./run_tests.py --with-coverage --cover-package=activetable
notifications:
email: false
addons:
firefox: 36.0
ActiveTable XBlock
==================
This XBlock provides a tabular problem type, where students have to fill in some of the cells of a
table.
Running the tests
-----------------
Install the test prerequisites:
pip install -r test-requirements
Run pep8:
pep8 --max-line-length=100 activetable
Run pylint:
pylint activetable
Run the unit and integration tests:
./run-tests.sh --with-coverage --cover-package=activetable
The table definition
--------------------
The table definition is entered in a Python-like syntax (actually in a strict subset of Python). It
must be a list of lists, with all inner lists having the same lengths. The elements of the inner
lists correspond to the cells of the table. The first line contains the column headers and can only
contain string literals. All further lines represent the table body. Cells can be either string
literals, e.g. `'a string'`, numbers, e.g. `6.23`, or response cell declarations. There are two
types of response cells:
Numeric(answer=<correct_answer>, tolerance=<tolerance in percent>,
min_significant_digits=<number>, max_significant_digits=<number>)
A cell that expects a numeric answer. The tolerance is optional, and will default to the default
tolerance specified above. The restrictions for the number of significant digits are optional as
well. Significant digits are counted started from the first non-zero digit specified by the
student, and include trailing zeros.
Text(answer='<correct answer>')
A cell that expects a string answer.
An example of a table definition:
[
['Event', 'Year'],
['French Revolution', Numeric(answer=1789)],
['Krakatoa volcano explosion', Numeric(answer=1883)],
["Proof of Fermat's last theorem", Numeric(answer=1994)],
]
"""ActiveTable XBlock top-level package.
See activetable.activetable for more information.
"""
from .activetable import ActiveTableXBlock
# -*- coding: utf-8 -*-
"""An XBlock with a tabular problem type that requires students to fill in some cells."""
from __future__ import absolute_import, division, unicode_literals
import textwrap
from xblock.core import XBlock
from xblock.fields import Dict, Float, Integer, Scope, String
from xblock.fragment import Fragment
from xblock.validation import ValidationMessage
from xblockutils.resources import ResourceLoader
from xblockutils.studio_editable import StudioEditableXBlockMixin
from .cells import NumericCell
from .parsers import ParseError, parse_table, parse_number_list
loader = ResourceLoader(__name__) # pylint: disable=invalid-name
class ActiveTableXBlock(StudioEditableXBlockMixin, XBlock):
"""An XBlock with a tabular problem type that requires students to fill in some cells."""
display_name = String(
display_name='Display Name',
help='The title Studio uses for the component.',
scope=Scope.settings,
default='ActiveTable problem'
)
content = String(
display_name='Table definition',
help='The definition of the table in Python-like syntax. Note that changing the table '
'definition of a live problem will invalidate all student answers.',
scope=Scope.content,
multiline_editor=True,
resettable_editor=False,
default=textwrap.dedent("""\
[
['Column header 1', 'Column header 2'],
['Enter "answer" here:', Text(answer='answer')],
[42, Numeric(answer=42, tolerance=0.0)],
]
""")
)
help_text = String(
display_name='Help text',
help='The text that gets displayed when clicking the "+help" button. If you remove the '
'help text, the help feature is disabled.',
scope=Scope.content,
multiline_editor=True,
resettable_editor=False,
default='Fill in the cells highlighted in yellow with the correct answers. '
'When you are done, you can check your answers using the button below the table.',
)
column_widths = String(
display_name='Column widths',
help='Set the width of the columns in pixels. The value should be a Python-like list of '
'numerical values. The total width of the table should not be more than 800. Omitting '
'this value will result in equal-width columns with a total width of 800 pixels.',
scope=Scope.content,
resettable_editor=False,
)
row_heights = String(
display_name='Row heights',
help='Set the heights of the rows in pixels. The value should be a Python-like list of '
'numerical values. Rows may grow higher than the specified value if the text in some cells '
'in the row is long enough to get wrapped in more than one line.',
scope=Scope.content,
resettable_editor=False,
)
default_tolerance = Float(
display_name='Default tolerance',
help='The tolerance in percent that is used for numerical response cells you did not '
'specify an explicit tolerance for.',
scope=Scope.content,
default=1.0,
)
max_score = Float(
display_name='Maximum score',
help='The number of points students will be awarded when solving all fields correctly. '
'For partially correct attempts, the score will be pro-rated.',
scope=Scope.settings,
default=1.0,
)
max_attempts = Integer(
display_name='Maximum attempts',
help='Defines the number of times a student can try to answer this problem. If the value '
'is not set, infinite attempts are allowed.',
scope=Scope.settings,
)
editable_fields = [
'display_name',
'content',
'help_text',
'column_widths',
'row_heights',
'default_tolerance',
'max_score',
'max_attempts',
]
# Dictionary mapping cell ids to the student answers.
answers = Dict(scope=Scope.user_state)
# Dictionary mapping cell ids to Boolean values indicating whether the cell was answered
# correctly at the last check.
answers_correct = Dict(scope=Scope.user_state, default=None)
# The number of points awarded.
score = Float(scope=Scope.user_state)
# The number of attempts used.
attempts = Integer(scope=Scope.user_state, default=0)
has_score = True
@property
def num_correct_answers(self):
"""The number of correct answers during the last check."""
if self.answers_correct is None:
return None
return sum(self.answers_correct.itervalues())
@property
def num_total_answers(self):
"""The total number of answers during the last check."""
if self.answers_correct is None:
return None
return len(self.answers_correct)
def parse_fields(self):
"""Parse the user-provided fields into more processing-friendly structured data."""
if self.content:
self.thead, self.tbody = parse_table(self.content)
else:
self.thead = self.tbody = None
return
if self.column_widths:
self._column_widths = parse_number_list(self.column_widths)
else:
self._column_widths = [800 / len(self.thead)] * len(self.thead)
if self.row_heights:
self._row_heights = parse_number_list(self.row_heights)
else:
self._row_heights = [36] * (len(self.tbody) + 1)
def postprocess_table(self):
"""Augment the parsed table definition with further information.
The additional information is taken from other content and student state fields.
"""
self.response_cells = {}
for row, height in zip(self.tbody, self._row_heights[1:]):
row['height'] = height
if row['index'] % 2:
row['class'] = 'even'
else:
row['class'] = 'odd'
for cell, cell.col_label in zip(row['cells'], self.thead):
cell.id = 'cell_{}_{}'.format(row['index'], cell.index)
cell.classes = ''
if not cell.is_static:
self.response_cells[cell.id] = cell
cell.classes = 'active'
cell.value = self.answers.get(cell.id)
cell.height = height - 2
if isinstance(cell, NumericCell) and cell.abs_tolerance is None:
cell.set_tolerance(self.default_tolerance)
def get_status(self):
"""Status dictionary passed to the frontend code."""
return dict(
answers_correct=self.answers_correct,
num_correct_answers=self.num_correct_answers,
num_total_answers=self.num_total_answers,
score=self.score,
max_score=self.max_score,
attempts=self.attempts,
max_attempts=self.max_attempts,
)
def student_view(self, context=None):
"""Render the table."""
self.parse_fields()
self.postprocess_table()
context = dict(
help_text=self.help_text,
total_width=sum(self._column_widths) if self._column_widths else None,
column_widths=self._column_widths,
head_height=self._row_heights[0] if self._row_heights else None,
thead=self.thead,
tbody=self.tbody,
max_attempts=self.max_attempts,
)
html = loader.render_template('templates/html/activetable.html', context)
css_context = dict(
correct_icon=self.runtime.local_resource_url(self, 'public/img/correct-icon.png'),
incorrect_icon=self.runtime.local_resource_url(self, 'public/img/incorrect-icon.png'),
unanswered_icon=self.runtime.local_resource_url(self, 'public/img/unanswered-icon.png'),
)
css = loader.render_template('templates/css/activetable.css', css_context)
frag = Fragment(html)
frag.add_css(css)
frag.add_javascript(loader.load_unicode('static/js/src/activetable.js'))
frag.initialize_js('ActiveTableXBlock', self.get_status())
return frag
def check_and_save_answers(self, data):
"""Common implementation for the check and save handlers."""
if self.max_attempts and self.attempts >= self.max_attempts:
# The "Check" button is hidden when the maximum number of attempts has been reached, so
# we can only get here by manually crafted requests. We simply return the current
# status without rechecking or storing the answers in that case.
return self.get_status()
self.parse_fields()
self.postprocess_table()
answers_correct = {
cell_id: self.response_cells[cell_id].check_response(value)
for cell_id, value in data.iteritems()
}
# Since the previous statement executed without error, the data is well-formed enough to be
# stored. We now know it's a dictionary and all the keys are valid cell ids.
self.answers = data
return answers_correct
@XBlock.json_handler
def check_answers(self, data, unused_suffix=''):
"""Check the answers given by the student.
This handler is called when the "Check" button is clicked.
"""
self.answers_correct = self.check_and_save_answers(data)
self.attempts += 1
self.score = self.num_correct_answers * self.max_score / len(self.answers_correct)
self.runtime.publish(self, 'grade', dict(value=self.score, max_value=self.max_score))
return self.get_status()
@XBlock.json_handler
def save_answers(self, data, unused_suffix=''):
"""Save the answers given by the student without checking them."""
self.check_and_save_answers(data)
self.answers_correct = None
return self.get_status()
def validate_field_data(self, validation, data):
"""Validate the data entered by the user.
This handler is called when the "Save" button is clicked in Studio after editing the
properties of this XBlock.
"""
def add_error(msg):
"""Add a validation error."""
validation.add(ValidationMessage(ValidationMessage.ERROR, msg))
try:
thead, tbody = parse_table(data.content)
except ParseError as exc:
add_error('Problem with table definition: ' + exc.message)
thead = tbody = None
if data.column_widths:
try:
column_widths = parse_number_list(data.column_widths)
except ParseError as exc:
add_error('Problem with column widths: ' + exc.message)
else:
if thead is not None and len(column_widths) != len(thead):
add_error(
'The number of list entries in the Column widths field must match the '
'number of columns in the table.'
)
if data.row_heights:
try:
row_heights = parse_number_list(data.row_heights)
except ParseError as exc:
add_error('Problem with row heights: ' + exc.message)
else:
if tbody is not None and len(row_heights) != len(tbody) + 1:
add_error(
'The number of list entries in the Row heights field must match the number '
'of rows in the table.'
)
@staticmethod
def workbench_scenarios():
"""A canned scenario for display in the workbench."""
return [
("ActiveTableXBlock",
"""<vertical_demo>
<activetable url_name="basic">
[
['Event', 'Year'],
['French Revolution', Numeric(answer=1789)],
['Krakatoa volcano explosion', Numeric(answer=1883)],
["Proof of Fermat's last theorem", Numeric(answer=1994)],
]
</activetable>
</vertical_demo>
"""),
]
# -*- coding: utf-8 -*-
"""Classes representing table cells.
These classes are used mainly as namespaces to dump all data associated with a cell. There is no
expectation that all attributes are set in __init__() or that attributes are controlled byt the
classes themselves.
"""
from __future__ import absolute_import, division, unicode_literals
import decimal
class Cell(object):
"""Abstract base class for all cells."""
is_static = False
def __eq__(self, other):
"""Test for equality based on type and attribute values."""
return type(self) is type(other) and vars(self) == vars(other)
class StaticCell(Cell):
"""A static cell with a fixed value in the table body."""
is_static = True
def __init__(self, value):
self.value = value
class NumericCell(Cell):
"""A numeric response cell."""
placeholder = 'numeric response'
def __init__(self, answer, tolerance=None,
min_significant_digits=None, max_significant_digits=None):
"""Set the correct answer and the allowed relative tolerance in percent."""
self.answer = answer
self.abs_tolerance = None
self.set_tolerance(tolerance)
self.min_significant_digits = min_significant_digits
self.max_significant_digits = max_significant_digits
def set_tolerance(self, tolerance):
"""Set the tolerance to the specified value, if it is not None."""
if tolerance is not None:
self.abs_tolerance = abs(self.answer) * tolerance / 100.0
def check_response(self, student_response):
"""Return a Boolean value indicating whether the student response is correct."""
try:
value = float(student_response)
except ValueError:
return False
if self.min_significant_digits or self.max_significant_digits:
digits = len(decimal.Decimal(student_response).as_tuple().digits)
if self.min_significant_digits and digits < self.min_significant_digits:
return False
if self.max_significant_digits and digits > self.max_significant_digits:
return False
return abs(value - self.answer) <= self.abs_tolerance
class TextCell(Cell):
"""A string response cell."""
placeholder = 'text response'
def __init__(self, answer):
"""Set the correct answer."""
self.answer = answer
def check_response(self, student_response):
"""Return a Boolean value indicating whether the student response is correct."""
return student_response.strip() == self.answer.strip()
# -*- coding: utf-8 -*-
"""Parsers for structured text data entered by the user."""
from __future__ import absolute_import, division, unicode_literals
import ast
import numbers
from .cells import NumericCell, StaticCell, TextCell
class ParseError(Exception):
"""The table definition could not be parsed."""
def _ensure_type(node, expected_type):
"""Internal helper function for parse_table."""
if isinstance(node, expected_type):
return node
raise ParseError('the structure of the table definition is invalid')
def parse_table(table_definition):
"""Parse the table definition given by the user.
The string table_defintion is parsed as Python source code. The data is extracted from the
parse tree without executing it. The structure is rigidly validated; on error, ParseError is
thrown.
"""
try:
expr = ast.parse(table_definition.strip(), mode='eval')
except SyntaxError as exc:
raise ParseError(exc.msg)
row_iter = iter(_ensure_type(expr.body, ast.List).elts)
thead = []
for cell in _ensure_type(next(row_iter), ast.List).elts:
thead.append(_ensure_type(cell, ast.Str).s)
tbody = []
for i, row_node in enumerate(row_iter, 1):
cells = []
for j, cell_node in enumerate(_ensure_type(row_node, ast.List).elts):
if isinstance(cell_node, ast.Str):
cell = StaticCell(cell_node.s)
elif isinstance(cell_node, ast.Num):
cell = StaticCell(cell_node.n)
elif isinstance(cell_node, ast.Call):
cell = _parse_response_cell(cell_node)
else:
raise ParseError(
'invalid node in row {}, cell {}: {}'.format(i, j, type(cell_node).__name__)
)
cell.index = j
cells.append(cell)
if len(cells) != len(thead):
raise ParseError(
'row {} has a different number of columns than the previous rows ({} vs. {})'
.format(i, len(cells), len(thead))
)
tbody.append(dict(index=i, cells=cells))
return thead, tbody
def _parse_response_cell(cell_node):
"""Parse a single student response cell definition.
Response cells are written in function call syntax, either Text(...) or Numeric(...). All
arguments must be keyword arguments.
"""
cell_type = _ensure_type(cell_node.func, ast.Name).id
if any((cell_node.args, cell_node.starargs, cell_node.kwargs)):
raise ParseError(
'all arguments to {} must be keyword arguments of the form name=value'.format(cell_type)
)
if cell_type == 'Text':
cell_class = TextCell
kwargs = {kw.arg: _ensure_type(kw.value, ast.Str).s for kw in cell_node.keywords}
elif cell_type == 'Numeric':
cell_class = NumericCell
kwargs = {kw.arg: _ensure_type(kw.value, ast.Num).n for kw in cell_node.keywords}
else:
raise ParseError('invalid cell input type: {}'.format(cell_type))
try:
return cell_class(**kwargs)
except Exception as exc:
raise ParseError(exc.message)
def parse_number_list(source):
"""Parse the given string as a Python list of numbers.
This is used to parse the column_widths and row_heights lists entered by the user.
"""
try:
lst = ast.literal_eval(source)
except (SyntaxError, ValueError) as exc:
msg = getattr(exc, 'msg', getattr(exc, 'message', None))
raise ParseError(msg)
if not isinstance(lst, list):
raise ParseError('not a list')
if not all(isinstance(x, numbers.Real) for x in lst):
raise ParseError('all entries must be numbers')
return lst
/* Javascript for ActiveTableXBlock. */
function ActiveTableXBlock(runtime, element, init_args) {
var checkHandlerUrl = runtime.handlerUrl(element, 'check_answers');
var saveHandlerUrl = runtime.handlerUrl(element, 'save_answers');
function markResponseCells(data) {
if (data.answers_correct) {
$.each(data.answers_correct, function(cell_id, correct) {
var $cell = $('#' + cell_id, element);
$cell.removeClass('right-answer wrong-answer unchecked');
if (correct) {
$cell.addClass('right-answer');
$cell.prop('title', 'correct');
} else {
$cell.addClass('wrong-answer');
$cell.prop('title', 'incorrect');
}
});
} else {
$('td.active', element).removeClass('right-answer wrong-answer').addClass('unchecked');
}
}
function updateStatusMessage(data) {
var $status = $('.status', element);
var $status_message = $('.status-message', element);
if (!data.answers_correct) {
$status.removeClass('incorrect correct');
$status.text('unanswered');
$status_message.text('');
}
else if (data.num_total_answers == data.num_correct_answers) {
$status.removeClass('incorrect').addClass('correct');
$status.text('correct');
$status_message.text('Great job!');
} else {
$status.removeClass('correct').addClass('incorrect');
$status.text('incorrect');
$status_message.text(
'You have ' + data.num_correct_answers + ' out of ' + data.num_total_answers +
' cells correct.'
);
}
}
function updateFeedback(data) {
var feedback_msg;
if (data.score === null) {
feedback_msg = '(' + data.max_score + ' points possible)';
} else {
feedback_msg = '(' + data.score + '/' + data.max_score + ' points)';
}
if (data.max_attempts) {
feedback_msg = 'You have used ' + data.attempts + ' of ' + data.max_attempts +
' submissions ' + feedback_msg;
if (data.attempts == data.max_attempts - 1) {
$('.action .check .check-label', element).text('Final check');
}
else if (data.attempts >= data.max_attempts) {
$('.action .check, .action .save', element).hide();
}
}
$('.submission-feedback', element).text(feedback_msg);
}
function updateStatus(data) {
markResponseCells(data);
updateStatusMessage(data);
updateFeedback(data);
}
function callHandler(url) {
var answers = {};
$('td.active', element).each(function() {
answers[this.id] = $('input', this).val();
});
$.ajax({
type: "POST",
url: url,
data: JSON.stringify(answers),
success: updateStatus,
});
}
function toggleHelp(e) {
var $help_text = $('#activetable-help-text', element), visible;
$help_text.toggle();
visible = $help_text.is(':visible');
$(this).text(visible ? '-help' : '+help');
$(this).attr('aria-expanded', visible);
}
$('#activetable-help-button', element).click(toggleHelp);
$('.action .check', element).click(function (e) { callHandler(checkHandlerUrl); });
$('.action .save', element).click(function (e) { callHandler(saveHandlerUrl); });
updateStatus(init_args);
}
.activetable_block table {
clear: both;
padding: 0;
margin: 0;
border-collapse: collapse;
}
.activetable_block tr {
border-width: 0;
}
.activetable_block thead tr {
background: linear-gradient(#f0f0f0, #d0d0d0);
}
.activetable_block tr.odd {
background-color: #f0f0f0;
}
.activetable_block th, .activetable_block td {
line-height: 1.5em;
padding: 0 10px;
border: 1px solid #c0c0c0;
vertical-align: middle;
}
.activetable_block th {
text-align: left;
text-shadow: 0 1px 0 #ffffff;
font-weight: bold;
}
/* cells that allow user input */
.activetable_block td.active {
padding: 0;
}
/* Cells that haven't been checked yet */
.activetable_block td.unchecked {
background-color: #ffffe0;
}
.activetable_block tr.odd td.unchecked {
background-color: #f0f0d0;
}
/* cells containing wrong answers after clicking "Check" */
.activetable_block td.wrong-answer {
background-color: #ffe0e0;
}
.activetable_block tr.odd td.wrong-answer {
background-color: #f0d0d0;
}
/* cells containing right answers after clicking "Check" */
.activetable_block td.right-answer {
background-color: #e0ffe0;
}
.activetable_block tr.odd td.right-answer {
background-color: #d0f0d0;
}
.activetable_block input[type="text"] {
font-size: 1em;
line-height: 1.5em;
border: 0;
padding: 0 10px;
margin: 0;
box-sizing: border-box;
display: block;
width: 100%;
background-color: transparent;
border-radius: initial;
background-image: initial;
box-shadow: initial;
}
.activetable_block input[type="text"]:hover {
outline: 3px solid #a0a0ff;
}
.activetable_block input[type="text"]:focus {
outline: 3px solid #8080ff;
}
.activetable_block #activetable-help-text {
display: none;
padding: 5px 10px;
margin: 0 0 10px;
border: 1px solid #c0c0c0;
background-color: #f0f0f0;
}
.activetable_block #activetable-help-button {
float: right;
padding: 6px 10px 0;
color: #009fe6;
cursor: pointer;
line-height: 2em;
box-shadow: none;
text-shadow: none;
background: none;
border: none;
outline: none;
}
.activetable_block #activetable-help-button:hover {
color: #bd9730;
}
.activetable_block .status {
display: inline-block;
margin: 1.5em 0 0.5em !important;
width: 20px;
height: 20px;
text-indent: 100%;
white-space: nowrap;
overflow: hidden;
background: url("{{ unanswered_icon }}") center no-repeat;
}
.activetable_block .status.correct {
background-image: url("{{ correct_icon }}");
}
.activetable_block .status.incorrect {
background-image: url("{{ incorrect_icon }}");
}
.activetable_block .status-message {
margin: 0.5em 0 1.5em;
}
.activetable_block .action button {
height: 40px;
margin-right: 10px;
font-weight: 600;
text-transform: uppercase;
}
.activetable_block .submission-feedback {
display: inline-block;
font-style: italic;
margin-left: 10px;
}
<div class="activetable_block">
{% if help_text %}
<div class="activetable-help" style="width: {{ total_width }}px;">
<button id="activetable-help-button" aria-controls="activetable-help-text" aria-haspopup="true"
aria-expanded="false">+help</button>
<p id="activetable-help-text">{{ help_text }}</p>
</div>
{% endif %}
{% if thead %}
<table id="activetable">
<colgroup>
{% for width in column_widths %}<col style="width: {{ width }}px;">{% endfor %}
</colgroup>
<thead>
<tr style="height: {{ head_height }}px;">
{% for cell in thead %}<th scope="col">{{ cell }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for row in tbody %}
<tr class="{{ row.class }}" style="height: {{ row.height }}px;">
{% for cell in row.cells %}
<td class="{{ cell.classes }}" id="{{ cell.id }}">
{% if cell.is_static %}
{{ cell.value }}
{% else %}
<label class="sr" for="input_{{ cell.id }}">{{ cell.col_label }}</label>
<input id="input_{{ cell.id }}" type="text" style="height: {{ cell.height }}px;" size=1
value="{{ cell.value|default_if_none:'' }}" placeholder="{{ cell.placeholder }}">
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>This component isn't configured properly and can't be displayed.</p>
{% endif %}
<p class="status" aria-live="polite"></p>
<div class="status-message" aria-live="polite"></div>
<div class="action">
<button class="check"><span class="check-label">Check</span><span class="sr"> your answer</span></button>
{% if max_attempts %}
<button class="save">Save<span class="sr"> your answer</span></button>
<div class="submission-feedback" aria-live="polite"></div>
{% endif %}
</div>
</div>
[REPORTS]
reports=no
[FORMAT]
max-line-length=100
[MESSAGES CONTROL]
disable=
I,
attribute-defined-outside-init,
maybe-no-member,
star-args,
too-few-public-methods,
too-many-ancestors,
too-many-instance-attributes,
too-many-public-methods
[VARIABLES]
dummy-variables-rgx=_$|dummy|unused
django>=1.8, <1.9
pyyaml
git+https://github.com/edx/XBlock.git@xblock-0.4.2#egg=XBlock
git+https://github.com/edx/xblock-utils.git@v1.0.0#egg=xblock-utils
.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Run tests for the ActiveTableXBlock.
This script is required to run the Selenium tests inside the xblock-sdk workbench
because the workbench SDK's settings file is not inside any python module.
"""
import os
import logging
import sys
from django.conf import settings
from django.core.management import execute_from_command_line
logging_level_overrides = {
'workbench.views': logging.ERROR,
'django.request': logging.ERROR,
'workbench.runtime': logging.ERROR,
}
if __name__ == '__main__':
# Use the workbench settings file:
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'workbench.settings')
# Configure a range of ports in case the default port of 8081 is in use
os.environ.setdefault('DJANGO_LIVE_TEST_SERVER_ADDRESS', 'localhost:8081-8099')
try:
os.mkdir('var')
except OSError:
# May already exist.
pass
settings.INSTALLED_APPS += ('activetable', )
for noisy_logger, log_level in logging_level_overrides.iteritems():
logging.getLogger(noisy_logger).setLevel(log_level)
args_iter = iter(sys.argv[1:])
options = []
paths = []
for arg in args_iter:
if arg == '--':
break
if arg.startswith('-'):
options.append(arg)
else:
paths.append(arg)
paths.extend(args_iter)
if not paths:
paths = ['tests/']
execute_from_command_line([sys.argv[0], 'test'] + options + ['--'] + paths)
"""Setup for activetable XBlock."""
import os
from setuptools import setup
def package_data(pkg, roots):
"""Generic function to find package_data.
All of the files under each of the `roots` will be declared as package
data for package `pkg`.
"""
data = []
for root in roots:
for dirname, _, files in os.walk(os.path.join(pkg, root)):
for fname in files:
data.append(os.path.relpath(os.path.join(dirname, fname), pkg))
return {pkg: data}
setup(
name='activetable-xblock',
version='0.1',
description='activetable XBlock', # TODO: write a better description.
packages=[
'activetable',
],
install_requires=[
'XBlock',
'xblock-utils',
],
entry_points={
'xblock.v1': [
'activetable = activetable:ActiveTableXBlock',
]
},
package_data=package_data("activetable", ["static", "public"]),
)
-r requirements.txt
-e git+https://github.com/edx/xblock-sdk.git@4e8e713e7dd886b8d2eb66b5001216b66b9af81a#egg=xblock-sdk
ddt
selenium==2.48.0
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from selenium.webdriver.support.ui import WebDriverWait
from xblockutils.resources import ResourceLoader
from xblockutils.studio_editable_test import StudioEditableBaseTest
loader = ResourceLoader(__name__) # pylint: disable=invalid-name
class TestActiveTable(StudioEditableBaseTest):
"""Test the Student View of ActiveTableXBlock."""
def load_scenario(self, path):
self.set_scenario_xml(loader.load_unicode(path))
self.element = self.go_to_view("student_view")
def enter_answers(self, answers):
for cell_id, value in answers.iteritems():
text_input = self.element.find_element_by_css_selector('#{id} input'.format(id=cell_id))
text_input.clear()
text_input.send_keys(value)
def get_current_answers(self):
return {
cell.get_attribute('id'): cell.get_attribute('value')
for cell in self.element.find_elements_by_css_selector('td.active')
}
def check(self, expected_message, expected_status_class):
"""Click the check button and verify the status message and icon."""
status_message_div = self.element.find_element_by_class_name('status-message')
old_message = status_message_div.text
check_button = self.element.find_element_by_css_selector('.action button.check')
check_button.click()
wait = WebDriverWait(status_message_div, self.timeout)
wait.until(
lambda e: e.text != old_message,
'Timeout while waiting for status message to change.'
)
self.assertEqual(status_message_div.text, expected_message)
self.wait_until_exists('.status.' + expected_status_class)
def verify_cell_classes(self, answers_correct):
for cell in self.element.find_elements_by_css_selector('td.active'):
cell_id = cell.get_attribute('id')
text_input = cell.find_element_by_tag_name('input')
cell_classes = cell.get_attribute('class')
if answers_correct[cell_id]:
self.assertIn('right-answer', cell_classes)
else:
self.assertIn('wrong-answer', cell_classes)
def answer_and_check(self, answers, answers_correct, expected_message):
self.enter_answers(answers)
expected_status_class = 'correct' if all(answers_correct.values()) else 'incorrect'
self.check(expected_message, expected_status_class)
self.verify_cell_classes(answers_correct)
def test_basic_answering(self):
def cell_dict(*values):
return dict(zip(['cell_1_1', 'cell_2_1', 'cell_3_1'], values))
self.load_scenario('xml/basic.xml')
self.answer_and_check(
answers=cell_dict('', '', ''),
answers_correct=cell_dict(False, False, False),
expected_message='You have 0 out of 3 cells correct.',
)
self.answer_and_check(
answers=cell_dict('1789', '1984', '1994'),
answers_correct=cell_dict(True, False, True),
expected_message='You have 2 out of 3 cells correct.',
)
self.answer_and_check(
answers=cell_dict('1789', '1883', '1994'),
answers_correct=cell_dict(True, True, True),
expected_message='Great job!',
)
def test_save_and_reload(self):
answers = dict(cell_1_1='1', cell_2_1='2', cell_3_1='3')
self.load_scenario('xml/max_attempts.xml')
self.enter_answers(answers)
self.element.find_element_by_css_selector('.action button.save').click()
vertical = self.load_root_xblock()
activetable_block = vertical.runtime.get_block(vertical.children[0])
self.assertEqual(activetable_block.answers, answers)
# I tried to implement this as reloading the page and testing whether the old values are
# loaded again. I can't make that work (while it works perfectly fine when doing it
# manually).
<vertical_demo>
<activetable url_name="basic">
[
['Event', 'Year'],
['French Revolution', Numeric(answer=1789)],
['Krakatoa volcano explosion', Numeric(answer=1883)],
["Proof of Fermat's last theorem", Numeric(answer=1994)],
]
</activetable>
</vertical_demo>
<vertical_demo>
<activetable url_name="max_attempts" max_attempts="2">
[
['Event', 'Year'],
['French Revolution', Numeric(answer=1789)],
['Krakatoa volcano explosion', Numeric(answer=1883)],
["Proof of Fermat's last theorem", Numeric(answer=1994)],
]
</activetable>
</vertical_demo>
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
import unittest
import mock
from xblock.field_data import DictFieldData
from xblock.runtime import Runtime
from xblock.validation import Validation
from activetable.activetable import ActiveTableXBlock
class ActiveTableTest(unittest.TestCase):
def setUp(self):
self.runtime_mock = mock.Mock(spec=Runtime)
self.block = ActiveTableXBlock(self.runtime_mock, DictFieldData({}), mock.Mock())
def verify_validation(self, data, expect_success):
validation = Validation('xblock_id')
self.block.validate_field_data(validation, data)
self.assertEqual(bool(validation), expect_success)
def test_validate_field_data(self):
data = mock.Mock()
data.content = 'invalid'
data.column_widths = ''
data.row_heights = ''
self.verify_validation(data, False)
data.content = '[["header"], [6.283]]'
self.verify_validation(data, True)
data.column_widths = 'invalid'
self.verify_validation(data, False)
data.column_widths = '[1, 2]'
self.verify_validation(data, False)
data.column_widths = '[1]'
self.verify_validation(data, True)
data.row_heights = 'invalid'
self.verify_validation(data, False)
data.row_heights = '[1, 2, 3]'
self.verify_validation(data, False)
data.row_heights = '[1, 2]'
self.verify_validation(data, True)
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
import unittest
from activetable.cells import NumericCell, TextCell
class CellTest(unittest.TestCase):
def test_numeric_cell(self):
cell = NumericCell(answer=42, tolerance=1.0)
self.assertTrue(cell.check_response('42'))
self.assertTrue(cell.check_response('42.4'))
self.assertTrue(cell.check_response('41.6'))
self.assertFalse(cell.check_response('41.5'))
self.assertFalse(cell.check_response('43'))
self.assertFalse(cell.check_response('Hurz!'))
cell.set_tolerance(10.0)
self.assertTrue(cell.check_response('42'))
self.assertTrue(cell.check_response('46'))
self.assertFalse(cell.check_response('37'))
def test_significant_digits(self):
cell = NumericCell(
answer=6.238, tolerance=10.0, min_significant_digits=3, max_significant_digits=4
)
self.assertTrue(cell.check_response('6.24'))
self.assertTrue(cell.check_response('6.238'))
self.assertFalse(cell.check_response('6.2'))
self.assertFalse(cell.check_response('6.2382'))
def test_string_cell(self):
cell = TextCell('OpenCraft')
self.assertTrue(cell.check_response('OpenCraft'))
self.assertTrue(cell.check_response(' OpenCraft \t\r\n'))
self.assertFalse(cell.check_response('giraffe'))
cell = TextCell('ÖpenCräft')
self.assertTrue(cell.check_response('ÖpenCräft'))
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
import ddt
import unittest
from activetable.cells import Cell, NumericCell, StaticCell, TextCell
from activetable.parsers import ParseError, parse_table, parse_number_list
@ddt.ddt
class ParserTest(unittest.TestCase):
def test_parse_table(self):
table_definition = """
[
['Event', 'Year'],
['French Revolution', Numeric(answer=1789)],
['Volcano exploded in 1883', Text(answer='Krakatoa')],
[6.283, 123],
]
"""
thead, tbody = parse_table(table_definition)
expected = eval(table_definition.strip(), dict(Numeric=NumericCell, Text=TextCell))
expected_body = []
for i, row in enumerate(expected[1:], 1):
cells = []
for j, cell in enumerate(row):
if not isinstance(cell, Cell):
cell = StaticCell(cell)
cell.index = j
cells.append(cell)
expected_body.append(dict(index=i, cells=cells))
self.assertEqual(thead, expected[0])
self.assertEqual(tbody, expected_body)
@ddt.data(
'syntax error',
'"wrong type"',
'["wrong type"]',
'[["wrong type in header", Numeric(answer=3)]],',
'[["header", "header"], "wrong type in body"]',
'[["header", "header"], ["illegal expression", 1 + 1]]',
'[["header", "header"], ["inconsistent", "row", "length"]]',
'[["header", "header"], ["wrong function name", Numerical(answer=3)]]',
'[["header", "header"], ["wrong argument class", Numeric(3)]]',
'[["header", "header"], ["wrong argument name", Numeric(giraffe=3)]]',
'[["header", "header"], ["wrong argument value", Numeric(giraffe="3")]]',
)
def test_parse_table_errors(self, table_definition):
with self.assertRaises(ParseError):
parse_table(table_definition)
def test_parse_number_list(self):
self.assertEquals(parse_number_list('[1, 2.3]'), [1, 2.3])
for string in [']', '123', '["123"]', '[1j]', 'malformed']:
with self.assertRaises(ParseError):
parse_number_list(string)
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