Commit 77f30696 by Nick Parlante

Extended Feedback and Hints for Problems

Extends the common capa response types (string, numeric, multiple
choice, checkbox, dropdown) with feedback and hint
capabilities. "Feedback" refers to feedback shown to the student when
they check the problem, looking at their specific answer. "Hints"
refers to a Hint button in LMS which the student can click at any time
to see hints for that problem. The implementation extends the markdown
syntax to include feedback and hints. There are new Feedback-and-Hint
specific templates in Studio when the author clicks to add a new
problem.
parent a57c780a
...@@ -47,6 +47,8 @@ LMS: Support adding students to a cohort via the instructor dashboard. TNL-163 ...@@ -47,6 +47,8 @@ LMS: Support adding students to a cohort via the instructor dashboard. TNL-163
LMS: Show cohorts on the new instructor dashboard. TNL-161 LMS: Show cohorts on the new instructor dashboard. TNL-161
LMS: Extended hints feature
LMS: Mobile API available for courses that opt in using the Course Advanced LMS: Mobile API available for courses that opt in using the Course Advanced
Setting "Mobile Course Available" (only used in limited closed beta). Setting "Mobile Course Available" (only used in limited closed beta).
......
...@@ -59,12 +59,12 @@ def click_new_component_button(step, component_button_css): ...@@ -59,12 +59,12 @@ def click_new_component_button(step, component_button_css):
def _click_advanced(): def _click_advanced():
css = 'ul.problem-type-tabs a[href="#tab2"]' css = 'ul.problem-type-tabs a[href="#tab3"]'
world.css_click(css) world.css_click(css)
# Wait for the advanced tab items to be displayed # Wait for the advanced tab items to be displayed
tab2_css = 'div.ui-tabs-panel#tab2' tab3_css = 'div.ui-tabs-panel#tab3'
world.wait_for_visible(tab2_css) world.wait_for_visible(tab3_css)
def _find_matching_link(category, component_type): def _find_matching_link(category, component_type):
......
...@@ -227,7 +227,7 @@ def get_component_templates(courselike, library=False): ...@@ -227,7 +227,7 @@ def get_component_templates(courselike, library=False):
""" """
Returns the applicable component templates that can be used by the specified course or library. Returns the applicable component templates that can be used by the specified course or library.
""" """
def create_template_dict(name, cat, boilerplate_name=None, is_common=False): def create_template_dict(name, cat, boilerplate_name=None, tab="common"):
""" """
Creates a component template dict. Creates a component template dict.
...@@ -235,14 +235,14 @@ def get_component_templates(courselike, library=False): ...@@ -235,14 +235,14 @@ def get_component_templates(courselike, library=False):
display_name: the user-visible name of the component display_name: the user-visible name of the component
category: the type of component (problem, html, etc.) category: the type of component (problem, html, etc.)
boilerplate_name: name of boilerplate for filling in default values. May be None. boilerplate_name: name of boilerplate for filling in default values. May be None.
is_common: True if "common" problem, False if "advanced". May be None, as it is only used for problems. tab: common(default)/advanced/hint, which tab it goes in
""" """
return { return {
"display_name": name, "display_name": name,
"category": cat, "category": cat,
"boilerplate_name": boilerplate_name, "boilerplate_name": boilerplate_name,
"is_common": is_common "tab": tab
} }
component_display_names = { component_display_names = {
...@@ -268,8 +268,8 @@ def get_component_templates(courselike, library=False): ...@@ -268,8 +268,8 @@ def get_component_templates(courselike, library=False):
# add the default template with localized display name # add the default template with localized display name
# TODO: Once mixins are defined per-application, rather than per-runtime, # TODO: Once mixins are defined per-application, rather than per-runtime,
# this should use a cms mixed-in class. (cpennington) # this should use a cms mixed-in class. (cpennington)
display_name = xblock_type_display_name(category, _('Blank')) display_name = xblock_type_display_name(category, _('Blank')) # this is the Blank Advanced problem
templates_for_category.append(create_template_dict(display_name, category)) templates_for_category.append(create_template_dict(display_name, category, None, 'advanced'))
categories.add(category) categories.add(category)
# add boilerplates # add boilerplates
...@@ -277,12 +277,20 @@ def get_component_templates(courselike, library=False): ...@@ -277,12 +277,20 @@ def get_component_templates(courselike, library=False):
for template in component_class.templates(): for template in component_class.templates():
filter_templates = getattr(component_class, 'filter_templates', None) filter_templates = getattr(component_class, 'filter_templates', None)
if not filter_templates or filter_templates(template, courselike): if not filter_templates or filter_templates(template, courselike):
# Tab can be 'common' 'advanced' 'hint'
# Default setting is common/advanced depending on the presence of markdown
tab = 'common'
if template['metadata'].get('markdown') is None:
tab = 'advanced'
# Then the problem can override that with a tab: setting
tab = template['metadata'].get('tab', tab)
templates_for_category.append( templates_for_category.append(
create_template_dict( create_template_dict(
_(template['metadata'].get('display_name')), # pylint: disable=translation-of-non-string _(template['metadata'].get('display_name')), # pylint: disable=translation-of-non-string
category, category,
template.get('template_id'), template.get('template_id'),
template['metadata'].get('markdown') is not None tab
) )
) )
...@@ -297,7 +305,7 @@ def get_component_templates(courselike, library=False): ...@@ -297,7 +305,7 @@ def get_component_templates(courselike, library=False):
log.warning('Unable to load xblock type %s to read display_name', component, exc_info=True) log.warning('Unable to load xblock type %s to read display_name', component, exc_info=True)
else: else:
templates_for_category.append( templates_for_category.append(
create_template_dict(component_display_name, component, boilerplate_name) create_template_dict(component_display_name, component, boilerplate_name, 'advanced')
) )
categories.add(component) categories.add(component)
......
...@@ -4,13 +4,16 @@ ...@@ -4,13 +4,16 @@
<a class="link-tab" href="#tab1"><%= gettext("Common Problem Types") %></a> <a class="link-tab" href="#tab1"><%= gettext("Common Problem Types") %></a>
</li> </li>
<li> <li>
<a class="link-tab" href="#tab2"><%= gettext("Advanced") %></a> <a class="link-tab" href="#tab2"><%= gettext("Common Problems with Hints and Feedback") %></a>
</li>
<li>
<a class="link-tab" href="#tab3"><%= gettext("Advanced") %></a>
</li> </li>
</ul> </ul>
<div class="tab current" id="tab1"> <div class="tab current" id="tab1">
<ul class="new-component-template"> <ul class="new-component-template">
<% for (var i = 0; i < templates.length; i++) { %> <% for (var i = 0; i < templates.length; i++) { %>
<% if (templates[i].is_common) { %> <% if (templates[i].tab == "common") { %>
<% if (!templates[i].boilerplate_name) { %> <% if (!templates[i].boilerplate_name) { %>
<li class="editor-md empty"> <li class="editor-md empty">
<a href="#" data-category="<%= templates[i].category %>"> <a href="#" data-category="<%= templates[i].category %>">
...@@ -32,7 +35,21 @@ ...@@ -32,7 +35,21 @@
<div class="tab" id="tab2"> <div class="tab" id="tab2">
<ul class="new-component-template"> <ul class="new-component-template">
<% for (var i = 0; i < templates.length; i++) { %> <% for (var i = 0; i < templates.length; i++) { %>
<% if (!templates[i].is_common) { %> <% if (templates[i].tab == "hint") { %>
<li class="editor-manual">
<a href="#" data-category="<%= templates[i].category %>"
data-boilerplate="<%= templates[i].boilerplate_name %>">
<span class="name"><%= templates[i].display_name %></span>
</a>
</li>
<% } %>
<% } %>
</ul>
</div>
<div class="tab" id="tab3">
<ul class="new-component-template">
<% for (var i = 0; i < templates.length; i++) { %>
<% if (templates[i].tab == "advanced") { %>
<li class="editor-manual"> <li class="editor-manual">
<a href="#" data-category="<%= templates[i].category %>" <a href="#" data-category="<%= templates[i].category %>"
data-boilerplate="<%= templates[i].boilerplate_name %>"> data-boilerplate="<%= templates[i].boilerplate_name %>">
......
...@@ -114,8 +114,8 @@ class LoncapaProblem(object): ...@@ -114,8 +114,8 @@ class LoncapaProblem(object):
""" """
Main class for capa Problems. Main class for capa Problems.
""" """
def __init__(self, problem_text, id, capa_system, capa_module, # pylint: disable=redefined-builtin
def __init__(self, problem_text, id, capa_system, state=None, seed=None): state=None, seed=None):
""" """
Initializes capa Problem. Initializes capa Problem.
...@@ -125,6 +125,7 @@ class LoncapaProblem(object): ...@@ -125,6 +125,7 @@ class LoncapaProblem(object):
id (string): identifier for this problem, often a filename (no spaces). id (string): identifier for this problem, often a filename (no spaces).
capa_system (LoncapaSystem): LoncapaSystem instance which provides OS, capa_system (LoncapaSystem): LoncapaSystem instance which provides OS,
rendering, user context, and other resources. rendering, user context, and other resources.
capa_module: instance needed to access runtime/logging
state (dict): containing the following keys: state (dict): containing the following keys:
- `seed` (int) random number generator seed - `seed` (int) random number generator seed
- `student_answers` (dict) maps input id to the stored answer for that input - `student_answers` (dict) maps input id to the stored answer for that input
...@@ -139,6 +140,7 @@ class LoncapaProblem(object): ...@@ -139,6 +140,7 @@ class LoncapaProblem(object):
self.do_reset() self.do_reset()
self.problem_id = id self.problem_id = id
self.capa_system = capa_system self.capa_system = capa_system
self.capa_module = capa_module
state = state or {} state = state or {}
...@@ -162,6 +164,8 @@ class LoncapaProblem(object): ...@@ -162,6 +164,8 @@ class LoncapaProblem(object):
# parse problem XML file into an element tree # parse problem XML file into an element tree
self.tree = etree.XML(problem_text) self.tree = etree.XML(problem_text)
self.make_xml_compatible(self.tree)
# handle any <include file="foo"> tags # handle any <include file="foo"> tags
self._process_includes() self._process_includes()
...@@ -191,6 +195,49 @@ class LoncapaProblem(object): ...@@ -191,6 +195,49 @@ class LoncapaProblem(object):
self.extracted_tree = self._extract_html(self.tree) self.extracted_tree = self._extract_html(self.tree)
def make_xml_compatible(self, tree):
"""
Adjust tree xml in-place for compatibility before creating
a problem from it.
The idea here is to provide a central point for XML translation,
for example, supporting an old XML format. At present, there just two translations.
1. <additional_answer> compatibility translation:
old: <additional_answer>ANSWER</additional_answer>
convert to
new: <additional_answer answer="ANSWER">OPTIONAL-HINT</addional_answer>
2. <optioninput> compatibility translation:
optioninput works like this internally:
<optioninput options="('yellow','blue','green')" correct="blue" />
With extended hints there is a new <option> tag, like this
<option correct="True">blue <optionhint>sky color</optionhint> </option>
This translation takes in the new format and synthesizes the old option= attribute
so all downstream logic works unchanged with the new <option> tag format.
"""
additionals = tree.xpath('//stringresponse/additional_answer')
for additional in additionals:
answer = additional.get('answer')
text = additional.text
if not answer and text: # trigger of old->new conversion
additional.set('answer', text)
additional.text = ''
for optioninput in tree.xpath('//optioninput'):
correct_option = None
child_options = []
for option_element in optioninput.findall('./option'):
option_name = option_element.text.strip()
if option_element.get('correct').upper() == 'TRUE':
correct_option = option_name
child_options.append("'" + option_name + "'")
if len(child_options) > 0:
options_string = '(' + ','.join(child_options) + ')'
optioninput.attrib.update({'options': options_string})
if correct_option:
optioninput.attrib.update({'correct': correct_option})
def do_reset(self): def do_reset(self):
""" """
Reset internal state to unfinished, with no answers Reset internal state to unfinished, with no answers
...@@ -819,7 +866,7 @@ class LoncapaProblem(object): ...@@ -819,7 +866,7 @@ class LoncapaProblem(object):
# instantiate capa Response # instantiate capa Response
responsetype_cls = responsetypes.registry.get_class_for_tag(response.tag) responsetype_cls = responsetypes.registry.get_class_for_tag(response.tag)
responder = responsetype_cls(response, inputfields, self.context, self.capa_system) responder = responsetype_cls(response, inputfields, self.context, self.capa_system, self.capa_module)
# save in list in self # save in list in self
self.responders[response] = responder self.responders[response] = responder
......
...@@ -486,15 +486,17 @@ class ChoiceGroup(InputTypeBase): ...@@ -486,15 +486,17 @@ class ChoiceGroup(InputTypeBase):
_ = i18n.ugettext _ = i18n.ugettext
for choice in element: for choice in element:
if choice.tag != 'choice': if choice.tag == 'choice':
msg = u"[capa.inputtypes.extract_choices] {error_message}".format( choices.append((choice.get("name"), stringify_children(choice)))
# Translators: '<choice>' is a tag name and should not be translated. else:
error_message=_("Expected a <choice> tag; got {given_tag} instead").format( if choice.tag != 'compoundhint':
given_tag=choice.tag msg = u'[capa.inputtypes.extract_choices] {error_message}'.format(
# Translators: '<choice>' and '<compoundhint>' are tag names and should not be translated.
error_message=_('Expected a <choice> or <compoundhint> tag; got {given_tag} instead').format(
given_tag=choice.tag
)
) )
) raise Exception(msg)
raise Exception(msg)
choices.append((choice.get("name"), stringify_children(choice)))
return choices return choices
def get_user_visible_answer(self, internal_answer): def get_user_visible_answer(self, internal_answer):
......
...@@ -61,6 +61,12 @@ CORRECTMAP_PY = None ...@@ -61,6 +61,12 @@ CORRECTMAP_PY = None
# Make '_' a no-op so we can scrape strings # Make '_' a no-op so we can scrape strings
_ = lambda text: text _ = lambda text: text
QUESTION_HINT_CORRECT_STYLE = 'feedback-hint-correct'
QUESTION_HINT_INCORRECT_STYLE = 'feedback-hint-incorrect'
QUESTION_HINT_LABEL_STYLE = 'hint-label'
QUESTION_HINT_TEXT_STYLE = 'hint-text'
QUESTION_HINT_MULTILINE = 'feedback-hint-multi'
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
# Exceptions # Exceptions
...@@ -143,7 +149,7 @@ class LoncapaResponse(object): ...@@ -143,7 +149,7 @@ class LoncapaResponse(object):
# By default, we set this to False, allowing subclasses to override as appropriate. # By default, we set this to False, allowing subclasses to override as appropriate.
has_responsive_ui = False has_responsive_ui = False
def __init__(self, xml, inputfields, context, system): def __init__(self, xml, inputfields, context, system, capa_module):
""" """
Init is passed the following arguments: Init is passed the following arguments:
...@@ -151,12 +157,13 @@ class LoncapaResponse(object): ...@@ -151,12 +157,13 @@ class LoncapaResponse(object):
- inputfields : ordered list of ElementTrees for each input entry field in this Response - inputfields : ordered list of ElementTrees for each input entry field in this Response
- context : script processor context - context : script processor context
- system : LoncapaSystem instance which provides OS, rendering, and user context - system : LoncapaSystem instance which provides OS, rendering, and user context
- capa_module : Capa module, to access runtime
""" """
self.xml = xml self.xml = xml
self.inputfields = inputfields self.inputfields = inputfields
self.context = context self.context = context
self.capa_system = system self.capa_system = system
self.capa_module = capa_module # njp, note None
self.id = xml.get('id') self.id = xml.get('id')
...@@ -257,6 +264,103 @@ class LoncapaResponse(object): ...@@ -257,6 +264,103 @@ class LoncapaResponse(object):
# log.debug('new_cmap = %s' % new_cmap) # log.debug('new_cmap = %s' % new_cmap)
return new_cmap return new_cmap
def make_hint_div(self, hint_node, correct, student_answer, question_tag,
label=None, hint_log=None, multiline_mode=False, log_extra=None):
"""
Returns the extended hint div based on the student_answer
or the empty string if, after processing all the arguments, there is no hint.
As a side effect, logs a tracking log event detailing the hint.
Keyword args:
* hint_node: xml node such as <optionhint>, holding extended hint text. May be passed in as None.
* correct: bool indication if the student answer is correct
* student_answer: list length 1 or more of string answers
(only checkboxes make multiple answers)
* question_tag: string name of enclosing question, e.g. 'choiceresponse'
* label: (optional) if None (the default), extracts the label from the node,
otherwise using this value. The value '' inhibits labeling of the hint.
* hint_log: (optional) hints to be used, passed in as list-of-dict format (below)
* multiline_mode: (optional) bool, default False, hints should be shown one-per line
* log_extra: (optional) dict items to be injected in the tracking log
There are many parameters to this method because a variety of extended hint contexts
all bottleneck through here. In addition, the caller must provide detailed background
information about the hint-trigger to go in the tracking log.
hint_log format: list of dicts with each hint as a 'text' key. Each dict has extra
information for logging, essentially recording the logic which triggered the feedback.
Case 1: records which choices triggered
e.g. [{'text': 'feedback 1', 'trigger': [{'choice': 'choice_0', 'selected': True}]},...
Case 2: a compound hint, the trigger list has 1 or more choices
e.g. [{'text': 'a hint', 'trigger':[{'choice': 'choice_0', 'selected': True},
{'choice': 'choice_1', 'selected':True}]}]
"""
_ = self.capa_system.i18n.ugettext
# 1. Establish the hint_texts
# This can lead to early-exit if the hint is blank.
if not hint_log:
if hint_node is None or hint_node.text is None: # .text can be None, maybe just in testing
return ''
hint_text = hint_node.text.strip()
if not hint_text:
return ''
hint_log = [{'text': hint_text}]
# invariant: xxxx
# 2. Establish the label:
# Passed in, or from the node, or the default
if not label and hint_node is not None:
label = hint_node.get('label', None)
# Tricky: label None means output defaults, while '' means output empty label
if label is None:
if correct:
label = _(u'Correct')
else:
label = _(u'Incorrect')
# self.runtime.track_function('get_demand_hint', event_info)
# This this "feedback hint" event
event_info = dict()
event_info['module_id'] = self.capa_module.location.to_deprecated_string()
event_info['problem_part_id'] = self.id
event_info['trigger_type'] = 'single' # maybe be overwritten by log_extra
event_info['hint_label'] = label
event_info['hints'] = hint_log
event_info['correctness'] = correct
event_info['student_answer'] = student_answer
event_info['question_type'] = question_tag
if log_extra:
event_info.update(log_extra)
self.capa_module.runtime.track_function('edx.problem.hint.feedback_displayed', event_info)
# Form the div-wrapped hint texts
hints_wrap = u''.join(
[u'<div class="{0}">{1}</div>'.format(QUESTION_HINT_TEXT_STYLE, dct.get('text'))
for dct in hint_log]
)
if multiline_mode:
hints_wrap = u'<div class="{0}">{1}</div>'.format(QUESTION_HINT_MULTILINE, hints_wrap)
label_wrap = ''
if label:
label_wrap = u'<div class="{0}">{1}: </div>'.format(QUESTION_HINT_LABEL_STYLE, label)
# Establish the outer style
if correct:
style = QUESTION_HINT_CORRECT_STYLE
else:
style = QUESTION_HINT_INCORRECT_STYLE
# Ready to go
return u'<div class="{0}">{1}{2}</div>'.format(style, label_wrap, hints_wrap)
def get_extended_hints(self, student_answers, new_cmap):
"""
Pull "extended hint" information out the xml based on the student answers,
installing it in the new_map for display.
Implemented by subclasses that have extended hints.
"""
pass
def get_hints(self, student_answers, new_cmap, old_cmap): def get_hints(self, student_answers, new_cmap, old_cmap):
""" """
Generate adaptive hints for this problem based on student answers, the old CorrectMap, Generate adaptive hints for this problem based on student answers, the old CorrectMap,
...@@ -266,13 +370,17 @@ class LoncapaResponse(object): ...@@ -266,13 +370,17 @@ class LoncapaResponse(object):
Modifies new_cmap, by adding hints to answer_id entries as appropriate. Modifies new_cmap, by adding hints to answer_id entries as appropriate.
""" """
hintfn = None
hint_function_provided = False
hintgroup = self.xml.find('hintgroup') hintgroup = self.xml.find('hintgroup')
if hintgroup is None: if hintgroup is not None:
return hintfn = hintgroup.get('hintfn')
if hintfn is not None:
hint_function_provided = True
# hint specified by function? if hint_function_provided:
hintfn = hintgroup.get('hintfn') # if a hint function has been supplied, it will take precedence
if hintfn:
# Hint is determined by a function defined in the <script> context; evaluate # Hint is determined by a function defined in the <script> context; evaluate
# that function to obtain list of hint, hintmode for each answer_id. # that function to obtain list of hint, hintmode for each answer_id.
...@@ -327,6 +435,7 @@ class LoncapaResponse(object): ...@@ -327,6 +435,7 @@ class LoncapaResponse(object):
new_cmap.set_dict(globals_dict['new_cmap_dict']) new_cmap.set_dict(globals_dict['new_cmap_dict'])
return return
# no hint function provided
# hint specified by conditions and text dependent on conditions (a-la Loncapa design) # hint specified by conditions and text dependent on conditions (a-la Loncapa design)
# see http://help.loncapa.org/cgi-bin/fom?file=291 # see http://help.loncapa.org/cgi-bin/fom?file=291
# #
...@@ -344,7 +453,8 @@ class LoncapaResponse(object): ...@@ -344,7 +453,8 @@ class LoncapaResponse(object):
# </formularesponse> # </formularesponse>
if (self.hint_tag is not None if (self.hint_tag is not None
and hintgroup.find(self.hint_tag) is not None and hintgroup is not None
and hintgroup.find(self.hint_tag) is not None
and hasattr(self, 'check_hint_condition')): and hasattr(self, 'check_hint_condition')):
rephints = hintgroup.findall(self.hint_tag) rephints = hintgroup.findall(self.hint_tag)
...@@ -361,6 +471,9 @@ class LoncapaResponse(object): ...@@ -361,6 +471,9 @@ class LoncapaResponse(object):
aid = self.answer_ids[-1] aid = self.answer_ids[-1]
new_cmap.set_hint_and_mode(aid, hint_text, hintmode) new_cmap.set_hint_and_mode(aid, hint_text, hintmode)
log.debug('after hint: new_cmap = %s', new_cmap) log.debug('after hint: new_cmap = %s', new_cmap)
else:
# If no other hint form matches, try extended hints.
self.get_extended_hints(student_answers, new_cmap)
@abc.abstractmethod @abc.abstractmethod
def get_score(self, student_answers): def get_score(self, student_answers):
...@@ -452,7 +565,6 @@ class JavascriptResponse(LoncapaResponse): ...@@ -452,7 +565,6 @@ class JavascriptResponse(LoncapaResponse):
allowed_inputfields = ['javascriptinput'] allowed_inputfields = ['javascriptinput']
def setup_response(self): def setup_response(self):
# Sets up generator, grader, display, and their dependencies. # Sets up generator, grader, display, and their dependencies.
self.parse_xml() self.parse_xml()
...@@ -691,7 +803,6 @@ class ChoiceResponse(LoncapaResponse): ...@@ -691,7 +803,6 @@ class ChoiceResponse(LoncapaResponse):
and it'd be nice to change this at some point. and it'd be nice to change this at some point.
""" """
human_name = _('Checkboxes') human_name = _('Checkboxes')
tags = ['choiceresponse'] tags = ['choiceresponse']
max_inputfields = 1 max_inputfields = 1
...@@ -700,7 +811,6 @@ class ChoiceResponse(LoncapaResponse): ...@@ -700,7 +811,6 @@ class ChoiceResponse(LoncapaResponse):
has_responsive_ui = True has_responsive_ui = True
def setup_response(self): def setup_response(self):
self.assign_choice_names() self.assign_choice_names()
correct_xml = self.xml.xpath('//*[@id=$id]//choice[@correct="true"]', correct_xml = self.xml.xpath('//*[@id=$id]//choice[@correct="true"]',
...@@ -717,6 +827,9 @@ class ChoiceResponse(LoncapaResponse): ...@@ -717,6 +827,9 @@ class ChoiceResponse(LoncapaResponse):
for index, choice in enumerate(self.xml.xpath('//*[@id=$id]//choice', for index, choice in enumerate(self.xml.xpath('//*[@id=$id]//choice',
id=self.xml.get('id'))): id=self.xml.get('id'))):
choice.set("name", "choice_" + str(index)) choice.set("name", "choice_" + str(index))
# If a choice does not have an id, assign 'A' 'B', .. used by CompoundHint
if not choice.get('id'):
choice.set("id", chr(ord("A") + index))
def get_score(self, student_answers): def get_score(self, student_answers):
...@@ -741,6 +854,117 @@ class ChoiceResponse(LoncapaResponse): ...@@ -741,6 +854,117 @@ class ChoiceResponse(LoncapaResponse):
def get_answers(self): def get_answers(self):
return {self.answer_id: list(self.correct_choices)} return {self.answer_id: list(self.correct_choices)}
def get_extended_hints(self, student_answers, new_cmap):
"""
Extract compound and extended hint information from the xml based on the student_answers.
The hint information goes into the msg= in new_cmap for display.
Each choice in the checkboxgroup can have 2 extended hints, matching the
case that the student has or has not selected that choice:
<checkboxgroup label="Select the best snack" direction="vertical">
<choice correct="true">Donut
<choicehint selected="tRuE">A Hint!</choicehint>
<choicehint selected="false">Another hint!</choicehint>
</choice>
"""
# Tricky: student_answers may be *empty* here. That is the representation that
# no checkboxes were selected. For typical responsetypes, you look at
# student_answers[self.answer_id], but that does not work here.
# Compound hints are a special thing just for checkboxgroup, trying
# them first before the regular extended hints.
if self.get_compound_hints(new_cmap, student_answers):
return
# Look at all the choices - each can generate some hint text
choices = self.xml.xpath('//checkboxgroup[@id=$id]/choice', id=self.answer_id)
hint_log = []
label = None
label_count = 0
choice_all = []
# Tricky: in the case that the student selects nothing, there is simply
# no entry in student_answers, rather than an entry with the empty list value.
# That explains the following line.
student_choice_list = student_answers.get(self.answer_id, [])
# We build up several hints in hint_divs, then wrap it once at the end.
for choice in choices:
name = choice.get('name') # generated name, e.g. choice_2
choice_all.append(name)
selected = name in student_choice_list # looking for 'true' vs. 'false'
if selected:
selector = 'true'
else:
selector = 'false'
# We find the matching <choicehint> in python vs xpath so we can be case-insensitive
hint_nodes = choice.findall('./choicehint')
for hint_node in hint_nodes:
if hint_node.get('selected', '').lower() == selector:
text = hint_node.text.strip()
if hint_node.get('label') is not None: # tricky: label '' vs None is significant
label = hint_node.get('label')
label_count += 1
if text:
hint_log.append({'text': text, 'trigger': [{'choice': name, 'selected': selected}]})
if hint_log:
# Complication: if there is only a single label specified, we use it.
# However if there are multiple, we use none.
if label_count > 1:
label = None
new_cmap[self.answer_id]['msg'] += self.make_hint_div(
None,
new_cmap[self.answer_id]['correctness'] == 'correct',
student_choice_list,
self.tags[0],
label,
hint_log,
multiline_mode=True, # the one case where we do this
log_extra={'choice_all': choice_all} # checkbox specific logging
)
def get_compound_hints(self, new_cmap, student_answers):
"""
Compound hints are a type of extended hint specific to checkboxgroup with the
<compoundhint value="A C"> meaning choices A and C were selected.
Checks for a matching compound hint, installing it in new_cmap.
Returns True if compound condition hints were matched.
"""
compound_hint_matched = False
if self.answer_id in student_answers:
# First create a set of the student's selected ids
student_set = set()
names = []
for student_answer in student_answers[self.answer_id]:
choice_list = self.xml.xpath('//checkboxgroup[@id=$id]/choice[@name=$name]',
id=self.answer_id, name=student_answer)
if choice_list:
choice = choice_list[0]
student_set.add(choice.get('id').upper())
names.append(student_answer)
for compound_hint in self.xml.xpath('//checkboxgroup[@id=$id]/compoundhint', id=self.answer_id):
# Selector words are space separated and not case-sensitive
selectors = compound_hint.get('value').upper().split()
selector_set = set(selectors)
if selector_set == student_set:
# This is the atypical case where the hint text is in an inner div with its own style.
hint_text = compound_hint.text.strip()
# Compute the choice names just for logging
choices = self.xml.xpath('//checkboxgroup[@id=$id]/choice', id=self.answer_id)
choice_all = [choice.get('name') for choice in choices]
hint_log = [{'text': hint_text, 'trigger': [{'choice': name, 'selected': True} for name in names]}]
new_cmap[self.answer_id]['msg'] += self.make_hint_div(
compound_hint,
new_cmap[self.answer_id]['correctness'] == 'correct',
student_answers[self.answer_id],
self.tags[0],
hint_log=hint_log,
log_extra={'trigger_type': 'compound', 'choice_all': choice_all}
)
compound_hint_matched = True
break
return compound_hint_matched
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
...@@ -785,9 +1009,40 @@ class MultipleChoiceResponse(LoncapaResponse): ...@@ -785,9 +1009,40 @@ class MultipleChoiceResponse(LoncapaResponse):
self.correct_choices = [ self.correct_choices = [
contextualize_text(choice.get('name'), self.context) contextualize_text(choice.get('name'), self.context)
for choice in cxml for choice in cxml
if contextualize_text(choice.get('correct'), self.context) == "true" if contextualize_text(choice.get('correct'), self.context).upper() == "TRUE"
] ]
def get_extended_hints(self, student_answer_dict, new_cmap):
"""
Extract any hints in a <choicegroup> matching the student's answers
<choicegroup label="What is your favorite color?" type="MultipleChoice">
<choice correct="false">Red
<choicehint>No, Blue!</choicehint>
</choice>
...
Any hint text is installed in the new_cmap.
"""
if self.answer_id in student_answer_dict:
student_answer = student_answer_dict[self.answer_id]
# Warning: mostly student_answer is a string, but sometimes it is a list of strings.
if isinstance(student_answer, list):
student_answer = student_answer[0]
# Find the named choice used by the student. Silently ignore a non-matching
# choice name.
choice = self.xml.find('./choicegroup[@id="{0}"]/choice[@name="{1}"]'.format(self.answer_id,
student_answer))
if choice is not None:
hint_node = choice.find('./choicehint')
new_cmap[self.answer_id]['msg'] += self.make_hint_div(
hint_node,
choice.get('correct').upper() == 'TRUE',
[student_answer],
self.tags[0]
)
def mc_setup_response(self): def mc_setup_response(self):
""" """
Initialize name attributes in <choice> stanzas in the <choicegroup> in this response. Initialize name attributes in <choice> stanzas in the <choicegroup> in this response.
...@@ -831,8 +1086,6 @@ class MultipleChoiceResponse(LoncapaResponse): ...@@ -831,8 +1086,6 @@ class MultipleChoiceResponse(LoncapaResponse):
""" """
grade student response. grade student response.
""" """
# log.debug('%s: student_answers=%s, correct_choices=%s' % (
# unicode(self), student_answers, self.correct_choices))
if (self.answer_id in student_answers if (self.answer_id in student_answers
and student_answers[self.answer_id] in self.correct_choices): and student_answers[self.answer_id] in self.correct_choices):
return CorrectMap(self.answer_id, 'correct') return CorrectMap(self.answer_id, 'correct')
...@@ -1014,7 +1267,7 @@ class MultipleChoiceResponse(LoncapaResponse): ...@@ -1014,7 +1267,7 @@ class MultipleChoiceResponse(LoncapaResponse):
incorrect_choices = [] incorrect_choices = []
for choice in choices: for choice in choices:
if choice.get('correct') == 'true': if choice.get('correct').upper() == 'TRUE':
correct_choices.append(choice) correct_choices.append(choice)
else: else:
incorrect_choices.append(choice) incorrect_choices.append(choice)
...@@ -1097,7 +1350,6 @@ class OptionResponse(LoncapaResponse): ...@@ -1097,7 +1350,6 @@ class OptionResponse(LoncapaResponse):
self.answer_fields = self.inputfields self.answer_fields = self.inputfields
def get_score(self, student_answers): def get_score(self, student_answers):
# log.debug('%s: student_answers=%s' % (unicode(self),student_answers))
cmap = CorrectMap() cmap = CorrectMap()
amap = self.get_answers() amap = self.get_answers()
for aid in amap: for aid in amap:
...@@ -1113,7 +1365,6 @@ class OptionResponse(LoncapaResponse): ...@@ -1113,7 +1365,6 @@ class OptionResponse(LoncapaResponse):
def get_answers(self): def get_answers(self):
amap = dict([(af.get('id'), contextualize_text(af.get( amap = dict([(af.get('id'), contextualize_text(af.get(
'correct'), self.context)) for af in self.answer_fields]) 'correct'), self.context)) for af in self.answer_fields])
# log.debug('%s: expected answers=%s' % (unicode(self),amap))
return amap return amap
def get_student_answer_variable_name(self, student_answers, aid): def get_student_answer_variable_name(self, student_answers, aid):
...@@ -1128,6 +1379,30 @@ class OptionResponse(LoncapaResponse): ...@@ -1128,6 +1379,30 @@ class OptionResponse(LoncapaResponse):
return '$' + key return '$' + key
return None return None
def get_extended_hints(self, student_answers, new_cmap):
"""
Extract optioninput extended hint, e.g.
<optioninput>
<option correct="True">Donut <optionhint>Of course</optionhint> </option>
"""
answer_id = self.answer_ids[0] # Note *not* self.answer_id
if answer_id in student_answers:
student_answer = student_answers[answer_id]
# If we run into an old-style optioninput, there is no <option> tag, so this safely does nothing
options = self.xml.xpath('//optioninput[@id=$id]/option', id=answer_id)
# Extra pass here to ignore whitespace around the answer in the matching
options = [option for option in options if option.text.strip() == student_answer]
if options:
option = options[0]
hint_node = option.find('./optionhint')
if hint_node is not None:
new_cmap[answer_id]['msg'] += self.make_hint_div(
hint_node,
option.get('correct').upper() == 'TRUE',
[student_answer],
self.tags[0]
)
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
...@@ -1211,6 +1486,9 @@ class NumericalResponse(LoncapaResponse): ...@@ -1211,6 +1486,9 @@ class NumericalResponse(LoncapaResponse):
""" """
Grade a numeric response. Grade a numeric response.
""" """
if self.answer_id not in student_answers:
return CorrectMap(self.answer_id, 'incorrect')
student_answer = student_answers[self.answer_id] student_answer = student_answers[self.answer_id]
_ = self.capa_system.i18n.ugettext _ = self.capa_system.i18n.ugettext
...@@ -1304,6 +1582,24 @@ class NumericalResponse(LoncapaResponse): ...@@ -1304,6 +1582,24 @@ class NumericalResponse(LoncapaResponse):
def get_answers(self): def get_answers(self):
return {self.answer_id: self.correct_answer} return {self.answer_id: self.correct_answer}
def get_extended_hints(self, student_answers, new_cmap):
"""
Extract numericalresponse extended hint, e.g.
<correcthint>Yes, 1+1 IS 2<correcthint>
"""
if self.answer_id in student_answers:
if new_cmap.cmap[self.answer_id]['correctness'] == 'correct': # if the grader liked the student's answer
# Note: using self.id here, not the more typical self.answer_id
hints = self.xml.xpath('//numericalresponse[@id=$id]/correcthint', id=self.id)
if hints:
hint_node = hints[0]
new_cmap[self.answer_id]['msg'] += self.make_hint_div(
hint_node,
True,
[student_answers[self.answer_id]],
self.tags[0]
)
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
...@@ -1321,8 +1617,8 @@ class StringResponse(LoncapaResponse): ...@@ -1321,8 +1617,8 @@ class StringResponse(LoncapaResponse):
</stringresponse > </stringresponse >
<stringresponse answer="a1" type="ci regexp"> <stringresponse answer="a1" type="ci regexp">
<additional_answer>\d5</additional_answer> <additional_answer>d5</additional_answer>
<additional_answer>a3</additional_answer> <additional_answer answer="a3"><correcthint>a hint - new format</correcthint></additional_answer>
<textline size="20"/> <textline size="20"/>
<hintgroup> <hintgroup>
<stringhint answer="a0" type="ci" name="ha0" /> <stringhint answer="a0" type="ci" name="ha0" />
...@@ -1355,7 +1651,6 @@ class StringResponse(LoncapaResponse): ...@@ -1355,7 +1651,6 @@ class StringResponse(LoncapaResponse):
] ]
def setup_response(self): def setup_response(self):
self.backward = '_or_' in self.xml.get('answer').lower() self.backward = '_or_' in self.xml.get('answer').lower()
self.regexp = False self.regexp = False
self.case_insensitive = False self.case_insensitive = False
...@@ -1369,17 +1664,21 @@ class StringResponse(LoncapaResponse): ...@@ -1369,17 +1664,21 @@ class StringResponse(LoncapaResponse):
return return
# end of backward compatibility # end of backward compatibility
correct_answers = [self.xml.get('answer')] + [el.text for el in self.xml.findall('additional_answer')] # XML compatibility note: in 2015, additional_answer switched to having a 'answer' attribute.
# See make_xml_compatible in capa_problem which translates the old format.
correct_answers = (
[self.xml.get('answer')] +
[element.get('answer') for element in self.xml.findall('additional_answer')]
)
self.correct_answer = [contextualize_text(answer, self.context).strip() for answer in correct_answers] self.correct_answer = [contextualize_text(answer, self.context).strip() for answer in correct_answers]
# remove additional_answer from xml, otherwise they will be displayed
for el in self.xml.findall('additional_answer'):
self.xml.remove(el)
def get_score(self, student_answers): def get_score(self, student_answers):
"""Grade a string response """ """Grade a string response """
student_answer = student_answers[self.answer_id].strip() if self.answer_id not in student_answers:
correct = self.check_string(self.correct_answer, student_answer) correct = False
else:
student_answer = student_answers[self.answer_id].strip()
correct = self.check_string(self.correct_answer, student_answer)
return CorrectMap(self.answer_id, 'correct' if correct else 'incorrect') return CorrectMap(self.answer_id, 'correct' if correct else 'incorrect')
def check_string_backward(self, expected, given): def check_string_backward(self, expected, given):
...@@ -1387,6 +1686,101 @@ class StringResponse(LoncapaResponse): ...@@ -1387,6 +1686,101 @@ class StringResponse(LoncapaResponse):
return given.lower() in [i.lower() for i in expected] return given.lower() in [i.lower() for i in expected]
return given in expected return given in expected
def get_extended_hints(self, student_answers, new_cmap):
"""
Find and install extended hints in new_cmap depending on the student answers.
StringResponse is probably the most complicated form we have.
The forms show below match in the order given, and the first matching one stops the matching.
<stringresponse answer="A" type="ci">
<correcthint>hint1</correcthint> <!-- hint for correct answer -->
<additional_answer answer="B">hint2</additional_answer> <!-- additional_answer with its own hint -->
<stringequalhint answer="C">hint3</stringequalhint> <!-- string matcher/hint for an incorrect answer -->
<regexphint answer="FG+">hint4</regexphint> <!-- regex matcher/hint for an incorrect answer -->
<textline size="20"/>
</stringresponse>
The "ci" and "regexp" options are inherited from the parent stringresponse as appropriate.
"""
if self.answer_id in student_answers:
student_answer = student_answers[self.answer_id]
# Note the atypical case of using self.id instead of self.answer_id
responses = self.xml.xpath('//stringresponse[@id=$id]', id=self.id)
if responses:
response = responses[0]
# First call the existing check_string to see if this is a right answer by that test.
# It handles the various "ci" "regexp" cases internally.
expected = response.get('answer').strip()
if self.check_string([expected], student_answer):
hint_node = response.find('./correcthint')
if hint_node is not None:
new_cmap[self.answer_id]['msg'] += self.make_hint_div(
hint_node,
True,
[student_answer],
self.tags[0]
)
return
# Then look for additional answer with an answer= attribute
for node in response.findall('./additional_answer'):
if self.match_hint_node(node, student_answer, self.regexp, self.case_insensitive):
hint_node = node.find('./correcthint')
new_cmap[self.answer_id]['msg'] += self.make_hint_div(
hint_node,
True,
[student_answer],
self.tags[0]
)
return
# stringequalhint and regexphint represent wrong answers
for hint_node in response.findall('./stringequalhint'):
if self.match_hint_node(hint_node, student_answer, False, self.case_insensitive):
new_cmap[self.answer_id]['msg'] += self.make_hint_div(
hint_node,
False,
[student_answer],
self.tags[0]
)
return
for hint_node in response.findall('./regexphint'):
if self.match_hint_node(hint_node, student_answer, True, self.case_insensitive):
new_cmap[self.answer_id]['msg'] += self.make_hint_div(
hint_node,
False,
[student_answer],
self.tags[0]
)
return
def match_hint_node(self, node, given, regex_mode, ci_mode):
"""
Given an xml extended hint node such as additional_answer or regexphint,
which contain an answer= attribute, returns True if the given student answer is a match.
The boolean arguments regex_mode and ci_mode control how the answer stored in
the question is treated for the comparison (analogously to check_string).
"""
answer = node.get('answer', '').strip()
if not answer:
return False
if regex_mode:
flags = 0
if ci_mode:
flags = re.IGNORECASE
try:
# We follow the check_string convention/exception, adding ^ and $
regex = re.compile('^' + answer + '$', flags=flags | re.UNICODE)
return re.search(regex, given)
except Exception: # pylint: disable=broad-except
return False
if ci_mode:
return answer.lower() == given.lower()
else:
return answer == given
def check_string(self, expected, given): def check_string(self, expected, given):
""" """
Find given in expected. Find given in expected.
...@@ -2572,7 +2966,6 @@ class SchematicResponse(LoncapaResponse): ...@@ -2572,7 +2966,6 @@ class SchematicResponse(LoncapaResponse):
self.code = answer.text self.code = answer.text
def get_score(self, student_answers): def get_score(self, student_answers):
#from capa_problem import global_context
submission = [ submission = [
json.loads(student_answers[k]) for k in sorted(self.answer_ids) json.loads(student_answers[k]) for k in sorted(self.answer_ids)
] ]
......
...@@ -55,9 +55,21 @@ def test_capa_system(): ...@@ -55,9 +55,21 @@ def test_capa_system():
return the_system return the_system
def mock_capa_module():
"""
capa response types needs just two things from the capa_module: location and track_function.
"""
capa_module = Mock()
capa_module.location.to_deprecated_string.return_value = 'i4x://Foo/bar/mock/abc'
# The following comes into existence by virtue of being called
# capa_module.runtime.track_function
return capa_module
def new_loncapa_problem(xml, capa_system=None, seed=723): def new_loncapa_problem(xml, capa_system=None, seed=723):
"""Construct a `LoncapaProblem` suitable for unit tests.""" """Construct a `LoncapaProblem` suitable for unit tests."""
return LoncapaProblem(xml, id='1', seed=seed, capa_system=capa_system or test_capa_system()) return LoncapaProblem(xml, id='1', seed=seed, capa_system=capa_system or test_capa_system(),
capa_module=mock_capa_module())
def load_fixture(relpath): def load_fixture(relpath):
......
...@@ -678,6 +678,9 @@ class StringResponseXMLFactory(ResponseXMLFactory): ...@@ -678,6 +678,9 @@ class StringResponseXMLFactory(ResponseXMLFactory):
*additional_answers*: list of additional asnwers. *additional_answers*: list of additional asnwers.
*non_attribute_answers*: list of additional answers to be coded in the
non-attribute format
""" """
# Retrieve the **kwargs # Retrieve the **kwargs
answer = kwargs.get("answer", None) answer = kwargs.get("answer", None)
...@@ -686,6 +689,7 @@ class StringResponseXMLFactory(ResponseXMLFactory): ...@@ -686,6 +689,7 @@ class StringResponseXMLFactory(ResponseXMLFactory):
hint_fn = kwargs.get('hintfn', None) hint_fn = kwargs.get('hintfn', None)
regexp = kwargs.get('regexp', None) regexp = kwargs.get('regexp', None)
additional_answers = kwargs.get('additional_answers', []) additional_answers = kwargs.get('additional_answers', [])
non_attribute_answers = kwargs.get('non_attribute_answers', [])
assert answer assert answer
# Create the <stringresponse> element # Create the <stringresponse> element
...@@ -723,7 +727,12 @@ class StringResponseXMLFactory(ResponseXMLFactory): ...@@ -723,7 +727,12 @@ class StringResponseXMLFactory(ResponseXMLFactory):
hintgroup_element.set("hintfn", hint_fn) hintgroup_element.set("hintfn", hint_fn)
for additional_answer in additional_answers: for additional_answer in additional_answers:
etree.SubElement(response_element, "additional_answer").text = additional_answer additional_node = etree.SubElement(response_element, "additional_answer") # pylint: disable=no-member
additional_node.set("answer", additional_answer)
for answer in non_attribute_answers:
additional_node = etree.SubElement(response_element, "additional_answer") # pylint: disable=no-member
additional_node.text = answer
return response_element return response_element
......
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse targeted-feedback="">
<choicegroup type="MultipleChoice">
<choice correct="false" explanation-id="feedback1">wrong-1</choice>
<choice correct="false" explanation-id="feedback2">wrong-2</choice>
<choice correct="true" explanation-id="feedbackC">correct-1</choice>
<choice correct="false" explanation-id="feedback3">wrong-3</choice>
</choicegroup>
</multiplechoiceresponse>
<targetedfeedbackset>
<targetedfeedback explanation-id="feedback1">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 1st WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedback2">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 2nd WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedback3">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 3rd WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedbackC">
<div class="detailed-targeted-feedback-correct">
<p>Targeted Feedback</p>
<p>Feedback on your correct solution...</p>
</div>
</targetedfeedback>
</targetedfeedbackset>
<solution explanation-id="feedbackC">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the solution explanation</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
</problem>
\ No newline at end of file
<problem>
<p>Select all the fruits from the list. In retrospect, the wordiness of these tests increases the dizziness!</p>
<choiceresponse>
<checkboxgroup label="Select all the fruits from the list" direction="vertical">
<choice correct="true" id="alpha">Apple
<choicehint selected="TrUe">You are right that apple is a fruit.
</choicehint>
<choicehint selected="false">Remember that apple is also a fruit.
</choicehint>
</choice>
<choice correct="false">Mushroom
<choicehint selected="true">Mushroom is a fungus, not a fruit.
</choicehint>
<choicehint selected="false">You are right that mushrooms are not fruit
</choicehint>
</choice>
<choice correct="true">Grape
<choicehint selected="true">You are right that grape is a fruit
</choicehint>
<choicehint selected="false">Remember that grape is also a fruit.
</choicehint>
</choice>
<choice correct="false">Mustang</choice>
<choice correct="false">Camero
<choicehint selected="true">I do not know what a Camero is but it is not a fruit.
</choicehint>
<choicehint selected="false">What is a camero anyway?
</choicehint>
</choice>
<compoundhint value="alpha B" label="Almost right"> You are right that apple is a fruit, but there is one you are missing. Also, mushroom is not a fruit.
</compoundhint>
<compoundhint value=" c b "> You are right that grape is a fruit, but there is one you are missing. Also, mushroom is not a fruit.
</compoundhint>
</checkboxgroup>
</choiceresponse>
<p>Select all the vegetables from the list</p>
<choiceresponse>
<checkboxgroup label="Select all the vegetables from the list" direction="vertical">
<choice correct="false">Banana
<choicehint selected="true">No, sorry, a banana is a fruit.
</choicehint>
<choicehint selected="false">poor banana.
</choicehint>
</choice>
<choice correct="false">Ice Cream</choice>
<choice correct="false">Mushroom
<choicehint selected="true">Mushroom is a fungus, not a vegetable.
</choicehint>
<choicehint selected="false">You are right that mushrooms are not vegatbles
</choicehint>
</choice>
<choice correct="true">
Brussel Sprout
<choicehint selected="true">
Brussel sprouts are vegetables.
</choicehint>
<choicehint selected="false">
Brussel sprout is the only vegetable in this list.
</choicehint>
</choice>
<compoundhint value="A B" label="Very funny"> Making a banana split?
</compoundhint>
<compoundhint value="B D"> That will make a horrible dessert: a brussel sprout split?
</compoundhint>
</checkboxgroup>
</choiceresponse>
<p>Compoundhint vs. correctness</p>
<choiceresponse>
<checkboxgroup direction="vertical">
<choice correct="true">A</choice>
<choice correct="false">B</choice>
<choice correct="true">C</choice>
<compoundhint value="A B">AB</compoundhint>
<compoundhint value="A C">AC</compoundhint>
</checkboxgroup>
</choiceresponse>
<p>If one label matches we use it, otherwise go with the default, and whitespace scattered around.</p>
<choiceresponse>
<checkboxgroup direction="vertical">
<choice correct="true">
A
<choicehint selected="true" label="AA">
aa
</choicehint></choice>
<choice correct="true">
B <choicehint selected="false" label="BB">
bb
</choicehint></choice>
</checkboxgroup>
</choiceresponse>
<p>Blank labels</p>
<choiceresponse>
<checkboxgroup direction="vertical">
<choice correct="true">A <choicehint selected="true" label="">aa</choicehint></choice>
<choice correct="true">B <choicehint selected="true">bb</choicehint></choice>
<compoundhint value="A B" label="">compoundo</compoundhint>
</checkboxgroup>
</choiceresponse>
<p>Case where the student selects nothing, but there's feedback</p>
<choiceresponse>
<checkboxgroup direction="vertical">
<choice correct="true">A <choicehint selected="true">aa</choicehint></choice>
<choice correct="false">B <choicehint selected="false">bb</choicehint></choice>
</checkboxgroup>
</choiceresponse>
</problem>
<problem>
<p>Translation between Dropdown and ________ is straightforward.
And not confused by whitespace around the answer.</p>
<optionresponse>
<optioninput>
<option correct="True"> Multiple Choice
<optionhint label="Good Job">Yes, multiple choice is the right answer.
</optionhint> </option>
<option correct="False"> Text Input
<optionhint>No, text input problems do not present options.
</optionhint> </option>
<option correct="False"> Numerical Input
<optionhint>No, numerical input problems do not present options.
</optionhint> </option>
</optioninput>
</optionresponse>
<p>Clowns have funny _________ to make people laugh.</p>
<optionresponse>
<optioninput>
<option correct="False">dogs
<optionhint label="NOPE">Not dogs, not cats, not toads
</optionhint> </option>
<option correct="True">FACES
<optionhint>With lots of makeup, doncha know?
</optionhint> </option>
<option correct="False">money
<optionhint>Clowns do not have any money, of course
</optionhint> </option>
</optioninput>
</optionresponse>
<p>Regression case where feedback includes answer substring, confusing the match logic</p>
<optionresponse>
<optioninput>
<option correct="False">AAA
<optionhint>AAABBB1
</optionhint> </option>
<option correct="True">BBB
<optionhint>AAABBB2
</optionhint> </option>
</optioninput>
</optionresponse>
</problem>
<problem>
<p>(note the blank line before mushroom -- be sure to include this test case)</p>
<p>Select the fruit from the list</p>
<multiplechoiceresponse>
<choicegroup label="Select the fruit from the list" type="MultipleChoice">
<choice correct="false">Mushroom
<choicehint label="">Mushroom is a fungus, not a fruit.
</choicehint>
</choice>
<choice correct="false">Potato</choice>
<choice correct="true">Apple
<choicehint label="OUTSTANDING">Apple is indeed a fruit.
</choicehint>
</choice>
</choicegroup>
</multiplechoiceresponse>
<p>Select the vegetables from the list</p>
<multiplechoiceresponse>
<choicegroup label="Select the vegetables from the list" type="MultipleChoice">
<choice correct="false">Mushroom
<choicehint>Mushroom is a fungus, not a vegetable.
</choicehint>
</choice>
<choice correct="true">Potato
<choicehint>Potato is a root vegetable.
</choicehint>
</choice>
<choice correct="false">Apple
<choicehint label="OOPS">Apple is a fruit.
</choicehint>
</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
<problem>
<numericalresponse answer="1.141">
<responseparam default=".01" type="tolerance"/>
<formulaequationinput label="What value when squared is approximately equal to 2 (give your answer to 2 decimal places)?"/>
<correcthint label="Nice">
The square root of two turns up in the strangest places.
</correcthint>
</numericalresponse>
<numericalresponse answer="4">
<responseparam default=".01" type="tolerance"/>
<formulaequationinput label="What is 2 + 2?"/>
<correcthint>
Pretty easy, uh?.
</correcthint>
</numericalresponse>
<!-- I don't think we're supporting these yet
also not multiple correcthint
<numerichint answer="-1.141" tolerance="0.01">
Yes, squaring a negative number yields a positive
</numerichint>
<numerichint answer="7">
7 x 7 = 49 which is much too high.
</numerichint>
<lehint answer="1">
Much too low. You may be rounding down.
</lehint>
-->
</problem>
<problem>
<p>In which country would you find the city of Paris?</p>
<stringresponse answer="FranceΩ" type="ci" >
<textline label="In which country would you find the city of Paris?" size="20"/>
<correcthint>
Viva la France!Ω
</correcthint>
<additional_answer answer="USAΩ">
<correcthint>Less well known, but yes, there is a Paris, Texas.Ω</correcthint>
</additional_answer>
<stringequalhint answer="GermanyΩ">
I do not think so.Ω
</stringequalhint>
<regexphint answer=".*landΩ">
The country name does not end in LANDΩ
</regexphint>
</stringresponse>
<p>What color is the sky? A minimal example, case sensitive, not regex.</p>
<stringresponse answer="Blue">
<correcthint >The red light is scattered by water molecules leaving only blue light.
</correcthint>
<textline label="What color is the sky?" size="20"/>
</stringresponse>
<p>(This question will cause an illegal regular expression exception)</p>
<stringresponse answer="Bonk">
<correcthint >This hint should never appear.
</correcthint>
<textline label="Why not?" size="20"/>
<regexphint answer="[">
This hint should never appear either because the regex is illegal.
</regexphint>
</stringresponse>
<!-- string response with extended hints + case_insensitive + blank labels -->
<p>Meh</p>
<stringresponse answer="A" type="ci">
<correcthint label="Woo Hoo">hint1</correcthint>
<additional_answer answer="B"> <correcthint label=""> hint2</correcthint> </additional_answer>
<stringequalhint answer="C" label=""> hint4</stringequalhint>
<regexphint answer="FG+" label=""> hint6 </regexphint>
<regexphint answer="(abc"> erroneous regex don't match anything </regexphint>
<textline size="20"/>
</stringresponse>
<!-- string response with extended hints + case_insensitive = False -->
<stringresponse answer="A">
<correcthint>hint1</correcthint>
<additional_answer answer="B"> <correcthint> hint2 </correcthint> </additional_answer>
<stringequalhint answer="C"> hint4 </stringequalhint>
<regexphint answer="FG+"> hint6 </regexphint>
<textline size="20"/>
</stringresponse>
<!-- backward compatibility for additional_answer: old and new format together in
a problem, scored correclty and new style has a hint -->
<stringresponse answer="A">
<correcthint>hint1</correcthint>
<additional_answer>B</additional_answer>
<additional_answer answer="C"><correcthint> hint2 </correcthint> </additional_answer>
<additional_answer>&lt;&amp;"'&gt;</additional_answer>
<textline size="20"/>
</stringresponse>
<!-- type regexp with extended hints -->
<stringresponse answer="AB+C" type="ci regexp">
<correcthint>hint1</correcthint>
<additional_answer answer="B+"><correcthint> hint2 </correcthint> </additional_answer>
<stringequalhint answer="C"> hint4 </stringequalhint>
<regexphint answer="D"> hint6 </regexphint>
<textline size="20"/>
</stringresponse>
</problem>
<problem>
<choiceresponse>
<checkboxgroup label="Select all the vegetables from the list" direction="vertical">
<choice correct="false">Banana
<choicehint selected="true">No, sorry, a banana is a fruit.
</choicehint>
<choicehint selected="false">poor banana.
</choicehint>
</choice>
<badElement> this element is not a legal sibling of 'choice' and 'booleanhint' </badElement>
</checkboxgroup>
</choiceresponse>
</problem>
# -*- coding: utf-8 -*-
"""
Tests of extended hints
"""
import unittest
from ddt import ddt, data, unpack
# With the use of ddt, some of the data expected_string cases below are naturally long stretches
# of text text without whitespace. I think it's best to leave such lines intact
# in the test code. Therefore:
# pylint: disable=line-too-long
# For out many ddt data cases, prefer a compact form of { .. }
# pylint: disable=bad-continuation
from . import new_loncapa_problem, load_fixture
class HintTest(unittest.TestCase):
"""Base class for tests of extended hinting functionality."""
def correctness(self, problem_id, choice):
"""Grades the problem and returns the 'correctness' string from cmap."""
student_answers = {problem_id: choice}
cmap = self.problem.grade_answers(answers=student_answers) # pylint: disable=no-member
return cmap[problem_id]['correctness']
def get_hint(self, problem_id, choice):
"""Grades the problem and returns its hint from cmap or the empty string."""
student_answers = {problem_id: choice}
cmap = self.problem.grade_answers(answers=student_answers) # pylint: disable=no-member
adict = cmap.cmap.get(problem_id)
if adict:
return adict['msg']
else:
return ''
# It is a little surprising how much more complicated TextInput is than all the other cases.
@ddt
class TextInputHintsTest(HintTest):
"""
Test Text Input Hints Test
"""
xml = load_fixture('extended_hints_text_input.xml')
problem = new_loncapa_problem(xml)
def test_tracking_log(self):
"""Test that the tracking log comes out right."""
self.problem.capa_module.reset_mock()
self.get_hint(u'1_3_1', u'Blue')
self.problem.capa_module.runtime.track_function.assert_called_with(
'edx.problem.hint.feedback_displayed',
{'module_id': 'i4x://Foo/bar/mock/abc',
'problem_part_id': '1_2',
'trigger_type': 'single',
'hint_label': u'Correct',
'correctness': True,
'student_answer': [u'Blue'],
'question_type': 'stringresponse',
'hints': [{'text': 'The red light is scattered by water molecules leaving only blue light.'}]}
)
@data(
{'problem_id': u'1_2_1', u'choice': u'GermanyΩ',
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="hint-text">I do not think so.Ω</div></div>'},
{'problem_id': u'1_2_1', u'choice': u'franceΩ',
'expected_string': u'<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">Viva la France!Ω</div></div>'},
{'problem_id': u'1_2_1', u'choice': u'FranceΩ',
'expected_string': u'<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">Viva la France!Ω</div></div>'},
{'problem_id': u'1_2_1', u'choice': u'Mexico',
'expected_string': ''},
{'problem_id': u'1_2_1', u'choice': u'USAΩ',
'expected_string': u'<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">Less well known, but yes, there is a Paris, Texas.Ω</div></div>'},
{'problem_id': u'1_2_1', u'choice': u'usaΩ',
'expected_string': u'<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">Less well known, but yes, there is a Paris, Texas.Ω</div></div>'},
{'problem_id': u'1_2_1', u'choice': u'uSAxΩ',
'expected_string': u''},
{'problem_id': u'1_2_1', u'choice': u'NICKLANDΩ',
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="hint-text">The country name does not end in LANDΩ</div></div>'},
{'problem_id': u'1_3_1', u'choice': u'Blue',
'expected_string': u'<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">The red light is scattered by water molecules leaving only blue light.</div></div>'},
{'problem_id': u'1_3_1', u'choice': u'blue',
'expected_string': u''},
{'problem_id': u'1_3_1', u'choice': u'b',
'expected_string': u''},
)
@unpack
def test_text_input_hints(self, problem_id, choice, expected_string):
hint = self.get_hint(problem_id, choice)
self.assertEqual(hint, expected_string)
@ddt
class TextInputExtendedHintsCaseInsensitive(HintTest):
"""Test Text Input Extended hints Case Insensitive"""
xml = load_fixture('extended_hints_text_input.xml')
problem = new_loncapa_problem(xml)
@data(
{'problem_id': u'1_5_1', 'choice': 'abc', 'expected_string': ''}, # wrong answer yielding no hint
{'problem_id': u'1_5_1', 'choice': 'A', 'expected_string':
u'<div class="feedback-hint-correct"><div class="hint-label">Woo Hoo: </div><div class="hint-text">hint1</div></div>'},
{'problem_id': u'1_5_1', 'choice': 'a', 'expected_string':
u'<div class="feedback-hint-correct"><div class="hint-label">Woo Hoo: </div><div class="hint-text">hint1</div></div>'},
{'problem_id': u'1_5_1', 'choice': 'B', 'expected_string':
u'<div class="feedback-hint-correct"><div class="hint-text">hint2</div></div>'},
{'problem_id': u'1_5_1', 'choice': 'b', 'expected_string':
u'<div class="feedback-hint-correct"><div class="hint-text">hint2</div></div>'},
{'problem_id': u'1_5_1', 'choice': 'C', 'expected_string':
u'<div class="feedback-hint-incorrect"><div class="hint-text">hint4</div></div>'},
{'problem_id': u'1_5_1', 'choice': 'c', 'expected_string':
u'<div class="feedback-hint-incorrect"><div class="hint-text">hint4</div></div>'},
# regexp cases
{'problem_id': u'1_5_1', 'choice': 'FGGG', 'expected_string':
u'<div class="feedback-hint-incorrect"><div class="hint-text">hint6</div></div>'},
{'problem_id': u'1_5_1', 'choice': 'fgG', 'expected_string':
u'<div class="feedback-hint-incorrect"><div class="hint-text">hint6</div></div>'},
)
@unpack
def test_text_input_hints(self, problem_id, choice, expected_string):
hint = self.get_hint(problem_id, choice)
self.assertEqual(hint, expected_string)
@ddt
class TextInputExtendedHintsCaseSensitive(HintTest):
"""Sometimes the semantics can be encoded in the class name."""
xml = load_fixture('extended_hints_text_input.xml')
problem = new_loncapa_problem(xml)
@data(
{'problem_id': u'1_6_1', 'choice': 'abc', 'expected_string': ''},
{'problem_id': u'1_6_1', 'choice': 'A', 'expected_string':
u'<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">hint1</div></div>'},
{'problem_id': u'1_6_1', 'choice': 'a', 'expected_string': u''},
{'problem_id': u'1_6_1', 'choice': 'B', 'expected_string':
u'<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">hint2</div></div>'},
{'problem_id': u'1_6_1', 'choice': 'b', 'expected_string': u''},
{'problem_id': u'1_6_1', 'choice': 'C', 'expected_string':
u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="hint-text">hint4</div></div>'},
{'problem_id': u'1_6_1', 'choice': 'c', 'expected_string': u''},
# regexp cases
{'problem_id': u'1_6_1', 'choice': 'FGG', 'expected_string':
u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="hint-text">hint6</div></div>'},
{'problem_id': u'1_6_1', 'choice': 'fgG', 'expected_string': u''},
)
@unpack
def test_text_input_hints(self, problem_id, choice, expected_string):
message_text = self.get_hint(problem_id, choice)
self.assertEqual(message_text, expected_string)
@ddt
class TextInputExtendedHintsCompatible(HintTest):
"""
Compatibility test with mixed old and new style additional_answer tags.
"""
xml = load_fixture('extended_hints_text_input.xml')
problem = new_loncapa_problem(xml)
@data(
{'problem_id': u'1_7_1', 'choice': 'A', 'correct': 'correct',
'expected_string': '<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">hint1</div></div>'},
{'problem_id': u'1_7_1', 'choice': 'B', 'correct': 'correct', 'expected_string': ''},
{'problem_id': u'1_7_1', 'choice': 'C', 'correct': 'correct',
'expected_string': '<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">hint2</div></div>'},
{'problem_id': u'1_7_1', 'choice': 'D', 'correct': 'incorrect', 'expected_string': ''},
# check going through conversion with difficult chars
{'problem_id': u'1_7_1', 'choice': """<&"'>""", 'correct': 'correct', 'expected_string': ''},
)
@unpack
def test_text_input_hints(self, problem_id, choice, correct, expected_string):
message_text = self.get_hint(problem_id, choice)
self.assertEqual(message_text, expected_string)
self.assertEqual(self.correctness(problem_id, choice), correct)
@ddt
class TextInputExtendedHintsRegex(HintTest):
"""
Extended hints where the answer is regex mode.
"""
xml = load_fixture('extended_hints_text_input.xml')
problem = new_loncapa_problem(xml)
@data(
{'problem_id': u'1_8_1', 'choice': 'ABwrong', 'correct': 'incorrect', 'expected_string': ''},
{'problem_id': u'1_8_1', 'choice': 'ABC', 'correct': 'correct',
'expected_string': '<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">hint1</div></div>'},
{'problem_id': u'1_8_1', 'choice': 'ABBBBC', 'correct': 'correct',
'expected_string': '<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">hint1</div></div>'},
{'problem_id': u'1_8_1', 'choice': 'aBc', 'correct': 'correct',
'expected_string': '<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">hint1</div></div>'},
{'problem_id': u'1_8_1', 'choice': 'BBBB', 'correct': 'correct',
'expected_string': '<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">hint2</div></div>'},
{'problem_id': u'1_8_1', 'choice': 'bbb', 'correct': 'correct',
'expected_string': '<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">hint2</div></div>'},
{'problem_id': u'1_8_1', 'choice': 'C', 'correct': 'incorrect',
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="hint-text">hint4</div></div>'},
{'problem_id': u'1_8_1', 'choice': 'c', 'correct': 'incorrect',
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="hint-text">hint4</div></div>'},
{'problem_id': u'1_8_1', 'choice': 'D', 'correct': 'incorrect',
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="hint-text">hint6</div></div>'},
{'problem_id': u'1_8_1', 'choice': 'd', 'correct': 'incorrect',
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="hint-text">hint6</div></div>'},
)
@unpack
def test_text_input_hints(self, problem_id, choice, correct, expected_string):
message_text = self.get_hint(problem_id, choice)
self.assertEqual(message_text, expected_string)
self.assertEqual(self.correctness(problem_id, choice), correct)
@ddt
class NumericInputHintsTest(HintTest):
"""
This class consists of a suite of test cases to be run on the numeric input problem represented by the XML below.
"""
xml = load_fixture('extended_hints_numeric_input.xml')
problem = new_loncapa_problem(xml) # this problem is properly constructed
def test_tracking_log(self):
self.get_hint(u'1_2_1', u'1.141')
self.problem.capa_module.runtime.track_function.assert_called_with(
'edx.problem.hint.feedback_displayed',
{'module_id': 'i4x://Foo/bar/mock/abc', 'problem_part_id': '1_1', 'trigger_type': 'single',
'hint_label': u'Nice',
'correctness': True,
'student_answer': [u'1.141'],
'question_type': 'numericalresponse',
'hints': [{'text': 'The square root of two turns up in the strangest places.'}]}
)
@data(
{'problem_id': u'1_2_1', 'choice': '1.141',
'expected_string': u'<div class="feedback-hint-correct"><div class="hint-label">Nice: </div><div class="hint-text">The square root of two turns up in the strangest places.</div></div>'},
{'problem_id': u'1_3_1', 'choice': '4',
'expected_string': u'<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">Pretty easy, uh?.</div></div>'},
# should get hint, when correct via numeric-tolerance
{'problem_id': u'1_2_1', 'choice': '1.15',
'expected_string': u'<div class="feedback-hint-correct"><div class="hint-label">Nice: </div><div class="hint-text">The square root of two turns up in the strangest places.</div></div>'},
# when they answer wrong, nothing
{'problem_id': u'1_2_1', 'choice': '2', 'expected_string': ''},
)
@unpack
def test_numeric_input_hints(self, problem_id, choice, expected_string):
hint = self.get_hint(problem_id, choice)
self.assertEqual(hint, expected_string)
@ddt
class CheckboxHintsTest(HintTest):
"""
This class consists of a suite of test cases to be run on the checkbox problem represented by the XML below.
"""
xml = load_fixture('extended_hints_checkbox.xml')
problem = new_loncapa_problem(xml) # this problem is properly constructed
@data(
{'problem_id': u'1_2_1', 'choice': [u'choice_0'],
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="feedback-hint-multi"><div class="hint-text">You are right that apple is a fruit.</div><div class="hint-text">You are right that mushrooms are not fruit</div><div class="hint-text">Remember that grape is also a fruit.</div><div class="hint-text">What is a camero anyway?</div></div></div>'},
{'problem_id': u'1_2_1', 'choice': [u'choice_1'],
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="feedback-hint-multi"><div class="hint-text">Remember that apple is also a fruit.</div><div class="hint-text">Mushroom is a fungus, not a fruit.</div><div class="hint-text">Remember that grape is also a fruit.</div><div class="hint-text">What is a camero anyway?</div></div></div>'},
{'problem_id': u'1_2_1', 'choice': [u'choice_2'],
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="feedback-hint-multi"><div class="hint-text">Remember that apple is also a fruit.</div><div class="hint-text">You are right that mushrooms are not fruit</div><div class="hint-text">You are right that grape is a fruit</div><div class="hint-text">What is a camero anyway?</div></div></div>'},
{'problem_id': u'1_2_1', 'choice': [u'choice_3'],
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="feedback-hint-multi"><div class="hint-text">Remember that apple is also a fruit.</div><div class="hint-text">You are right that mushrooms are not fruit</div><div class="hint-text">Remember that grape is also a fruit.</div><div class="hint-text">What is a camero anyway?</div></div></div>'},
{'problem_id': u'1_2_1', 'choice': [u'choice_4'],
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="feedback-hint-multi"><div class="hint-text">Remember that apple is also a fruit.</div><div class="hint-text">You are right that mushrooms are not fruit</div><div class="hint-text">Remember that grape is also a fruit.</div><div class="hint-text">I do not know what a Camero is but it is not a fruit.</div></div></div>'},
{'problem_id': u'1_2_1', 'choice': [u'choice_0', u'choice_1'], # compound
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Almost right: </div><div class="hint-text">You are right that apple is a fruit, but there is one you are missing. Also, mushroom is not a fruit.</div></div>'},
{'problem_id': u'1_2_1', 'choice': [u'choice_1', u'choice_2'], # compound
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="hint-text">You are right that grape is a fruit, but there is one you are missing. Also, mushroom is not a fruit.</div></div>'},
{'problem_id': u'1_2_1', 'choice': [u'choice_0', u'choice_2'],
'expected_string': u'<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="feedback-hint-multi"><div class="hint-text">You are right that apple is a fruit.</div><div class="hint-text">You are right that mushrooms are not fruit</div><div class="hint-text">You are right that grape is a fruit</div><div class="hint-text">What is a camero anyway?</div></div></div>'},
{'problem_id': u'1_3_1', 'choice': [u'choice_0'],
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="feedback-hint-multi"><div class="hint-text">No, sorry, a banana is a fruit.</div><div class="hint-text">You are right that mushrooms are not vegatbles</div><div class="hint-text">Brussel sprout is the only vegetable in this list.</div></div></div>'},
{'problem_id': u'1_3_1', 'choice': [u'choice_1'],
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="feedback-hint-multi"><div class="hint-text">poor banana.</div><div class="hint-text">You are right that mushrooms are not vegatbles</div><div class="hint-text">Brussel sprout is the only vegetable in this list.</div></div></div>'},
{'problem_id': u'1_3_1', 'choice': [u'choice_2'],
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="feedback-hint-multi"><div class="hint-text">poor banana.</div><div class="hint-text">Mushroom is a fungus, not a vegetable.</div><div class="hint-text">Brussel sprout is the only vegetable in this list.</div></div></div>'},
{'problem_id': u'1_3_1', 'choice': [u'choice_3'],
'expected_string': u'<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="feedback-hint-multi"><div class="hint-text">poor banana.</div><div class="hint-text">You are right that mushrooms are not vegatbles</div><div class="hint-text">Brussel sprouts are vegetables.</div></div></div>'},
{'problem_id': u'1_3_1', 'choice': [u'choice_0', u'choice_1'], # compound
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Very funny: </div><div class="hint-text">Making a banana split?</div></div>'},
{'problem_id': u'1_3_1', 'choice': [u'choice_1', u'choice_2'],
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="feedback-hint-multi"><div class="hint-text">poor banana.</div><div class="hint-text">Mushroom is a fungus, not a vegetable.</div><div class="hint-text">Brussel sprout is the only vegetable in this list.</div></div></div>'},
{'problem_id': u'1_3_1', 'choice': [u'choice_0', u'choice_2'],
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="feedback-hint-multi"><div class="hint-text">No, sorry, a banana is a fruit.</div><div class="hint-text">Mushroom is a fungus, not a vegetable.</div><div class="hint-text">Brussel sprout is the only vegetable in this list.</div></div></div>'},
# check for interaction between compoundhint and correct/incorrect
{'problem_id': u'1_4_1', 'choice': [u'choice_0', u'choice_1'], # compound
'expected_string': u'<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="hint-text">AB</div></div>'},
{'problem_id': u'1_4_1', 'choice': [u'choice_0', u'choice_2'], # compound
'expected_string': u'<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">AC</div></div>'},
# check for labeling where multiple child hints have labels
# These are some tricky cases
{'problem_id': '1_5_1', 'choice': ['choice_0', 'choice_1'],
'expected_string': '<div class="feedback-hint-correct"><div class="hint-label">AA: </div><div class="feedback-hint-multi"><div class="hint-text">aa</div></div></div>'},
{'problem_id': '1_5_1', 'choice': ['choice_0'],
'expected_string': '<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="feedback-hint-multi"><div class="hint-text">aa</div><div class="hint-text">bb</div></div></div>'},
{'problem_id': '1_5_1', 'choice': ['choice_1'],
'expected_string': ''},
{'problem_id': '1_5_1', 'choice': [],
'expected_string': '<div class="feedback-hint-incorrect"><div class="hint-label">BB: </div><div class="feedback-hint-multi"><div class="hint-text">bb</div></div></div>'},
{'problem_id': '1_6_1', 'choice': ['choice_0'],
'expected_string': '<div class="feedback-hint-incorrect"><div class="feedback-hint-multi"><div class="hint-text">aa</div></div></div>'},
{'problem_id': '1_6_1', 'choice': ['choice_0', 'choice_1'],
'expected_string': '<div class="feedback-hint-correct"><div class="hint-text">compoundo</div></div>'},
# The user selects *nothing*, but can still get "unselected" feedback
{'problem_id': '1_7_1', 'choice': [],
'expected_string': '<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="feedback-hint-multi"><div class="hint-text">bb</div></div></div>'},
# 100% not match of sel/unsel feedback
{'problem_id': '1_7_1', 'choice': ['choice_1'],
'expected_string': ''},
# Here we have the correct combination, and that makes feedback too
{'problem_id': '1_7_1', 'choice': ['choice_0'],
'expected_string': '<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="feedback-hint-multi"><div class="hint-text">aa</div><div class="hint-text">bb</div></div></div>'},
)
@unpack
def test_checkbox_hints(self, problem_id, choice, expected_string):
self.maxDiff = None # pylint: disable=invalid-name
hint = self.get_hint(problem_id, choice)
self.assertEqual(hint, expected_string)
class CheckboxHintsTestTracking(HintTest):
"""
Test the rather complicated tracking log output for checkbox cases.
"""
xml = """
<problem>
<p>question</p>
<choiceresponse>
<checkboxgroup direction="vertical">
<choice correct="true">Apple
<choicehint selected="true">A true</choicehint>
<choicehint selected="false">A false</choicehint>
</choice>
<choice correct="false">Banana
</choice>
<choice correct="true">Cronut
<choicehint selected="true">C true</choicehint>
</choice>
<compoundhint value="A C">A C Compound</compoundhint>
</checkboxgroup>
</choiceresponse>
</problem>
"""
problem = new_loncapa_problem(xml)
def test_tracking_log(self):
"""Test checkbox tracking log - by far the most complicated case"""
# A -> 1 hint
self.get_hint(u'1_2_1', [u'choice_0'])
self.problem.capa_module.runtime.track_function.assert_called_with(
'edx.problem.hint.feedback_displayed',
{'hint_label': u'Incorrect',
'module_id': 'i4x://Foo/bar/mock/abc',
'problem_part_id': '1_1',
'choice_all': ['choice_0', 'choice_1', 'choice_2'],
'correctness': False,
'trigger_type': 'single',
'student_answer': [u'choice_0'],
'hints': [{'text': 'A true', 'trigger': [{'choice': 'choice_0', 'selected': True}]}],
'question_type': 'choiceresponse'}
)
# B C -> 2 hints
self.problem.capa_module.runtime.track_function.reset_mock()
self.get_hint(u'1_2_1', [u'choice_1', u'choice_2'])
self.problem.capa_module.runtime.track_function.assert_called_with(
'edx.problem.hint.feedback_displayed',
{'hint_label': u'Incorrect',
'module_id': 'i4x://Foo/bar/mock/abc',
'problem_part_id': '1_1',
'choice_all': ['choice_0', 'choice_1', 'choice_2'],
'correctness': False,
'trigger_type': 'single',
'student_answer': [u'choice_1', u'choice_2'],
'hints': [
{'text': 'A false', 'trigger': [{'choice': 'choice_0', 'selected': False}]},
{'text': 'C true', 'trigger': [{'choice': 'choice_2', 'selected': True}]}
],
'question_type': 'choiceresponse'}
)
# A C -> 1 Compound hint
self.problem.capa_module.runtime.track_function.reset_mock()
self.get_hint(u'1_2_1', [u'choice_0', u'choice_2'])
self.problem.capa_module.runtime.track_function.assert_called_with(
'edx.problem.hint.feedback_displayed',
{'hint_label': u'Correct',
'module_id': 'i4x://Foo/bar/mock/abc',
'problem_part_id': '1_1',
'choice_all': ['choice_0', 'choice_1', 'choice_2'],
'correctness': True,
'trigger_type': 'compound',
'student_answer': [u'choice_0', u'choice_2'],
'hints': [
{'text': 'A C Compound',
'trigger': [{'choice': 'choice_0', 'selected': True}, {'choice': 'choice_2', 'selected': True}]}
],
'question_type': 'choiceresponse'}
)
@ddt
class MultpleChoiceHintsTest(HintTest):
"""
This class consists of a suite of test cases to be run on the multiple choice problem represented by the XML below.
"""
xml = load_fixture('extended_hints_multiple_choice.xml')
problem = new_loncapa_problem(xml)
def test_tracking_log(self):
"""Test that the tracking log comes out right."""
self.problem.capa_module.reset_mock()
self.get_hint(u'1_3_1', u'choice_2')
self.problem.capa_module.runtime.track_function.assert_called_with(
'edx.problem.hint.feedback_displayed',
{'module_id': 'i4x://Foo/bar/mock/abc', 'problem_part_id': '1_2', 'trigger_type': 'single',
'student_answer': [u'choice_2'], 'correctness': False, 'question_type': 'multiplechoiceresponse',
'hint_label': 'OOPS', 'hints': [{'text': 'Apple is a fruit.'}]}
)
@data(
{'problem_id': u'1_2_1', 'choice': u'choice_0',
'expected_string': '<div class="feedback-hint-incorrect"><div class="hint-text">Mushroom is a fungus, not a fruit.</div></div>'},
{'problem_id': u'1_2_1', 'choice': u'choice_1',
'expected_string': ''},
{'problem_id': u'1_3_1', 'choice': u'choice_1',
'expected_string': '<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">Potato is a root vegetable.</div></div>'},
{'problem_id': u'1_2_1', 'choice': u'choice_2',
'expected_string': '<div class="feedback-hint-correct"><div class="hint-label">OUTSTANDING: </div><div class="hint-text">Apple is indeed a fruit.</div></div>'},
{'problem_id': u'1_3_1', 'choice': u'choice_2',
'expected_string': '<div class="feedback-hint-incorrect"><div class="hint-label">OOPS: </div><div class="hint-text">Apple is a fruit.</div></div>'},
{'problem_id': u'1_3_1', 'choice': u'choice_9',
'expected_string': ''},
)
@unpack
def test_multiplechoice_hints(self, problem_id, choice, expected_string):
hint = self.get_hint(problem_id, choice)
self.assertEqual(hint, expected_string)
@ddt
class DropdownHintsTest(HintTest):
"""
This class consists of a suite of test cases to be run on the drop down problem represented by the XML below.
"""
xml = load_fixture('extended_hints_dropdown.xml')
problem = new_loncapa_problem(xml)
def test_tracking_log(self):
"""Test that the tracking log comes out right."""
self.problem.capa_module.reset_mock()
self.get_hint(u'1_3_1', u'FACES')
self.problem.capa_module.runtime.track_function.assert_called_with(
'edx.problem.hint.feedback_displayed',
{'module_id': 'i4x://Foo/bar/mock/abc', 'problem_part_id': '1_2', 'trigger_type': 'single',
'student_answer': [u'FACES'], 'correctness': True, 'question_type': 'optionresponse',
'hint_label': 'Correct', 'hints': [{'text': 'With lots of makeup, doncha know?'}]}
)
@data(
{'problem_id': u'1_2_1', 'choice': 'Multiple Choice',
'expected_string': '<div class="feedback-hint-correct"><div class="hint-label">Good Job: </div><div class="hint-text">Yes, multiple choice is the right answer.</div></div>'},
{'problem_id': u'1_2_1', 'choice': 'Text Input',
'expected_string': '<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="hint-text">No, text input problems do not present options.</div></div>'},
{'problem_id': u'1_2_1', 'choice': 'Numerical Input',
'expected_string': '<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="hint-text">No, numerical input problems do not present options.</div></div>'},
{'problem_id': u'1_3_1', 'choice': 'FACES',
'expected_string': '<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">With lots of makeup, doncha know?</div></div>'},
{'problem_id': u'1_3_1', 'choice': 'dogs',
'expected_string': '<div class="feedback-hint-incorrect"><div class="hint-label">NOPE: </div><div class="hint-text">Not dogs, not cats, not toads</div></div>'},
{'problem_id': u'1_3_1', 'choice': 'wrongo',
'expected_string': ''},
# Regression case where feedback includes answer substring
{'problem_id': u'1_4_1', 'choice': 'AAA',
'expected_string': '<div class="feedback-hint-incorrect"><div class="hint-label">Incorrect: </div><div class="hint-text">AAABBB1</div></div>'},
{'problem_id': u'1_4_1', 'choice': 'BBB',
'expected_string': '<div class="feedback-hint-correct"><div class="hint-label">Correct: </div><div class="hint-text">AAABBB2</div></div>'},
{'problem_id': u'1_4_1', 'choice': 'not going to match',
'expected_string': ''},
)
@unpack
def test_dropdown_hints(self, problem_id, choice, expected_string):
hint = self.get_hint(problem_id, choice)
self.assertEqual(hint, expected_string)
class ErrorConditionsTest(HintTest):
"""
Erroneous xml should raise exception.
"""
def test_error_conditions_illegal_element(self):
xml_with_errors = load_fixture('extended_hints_with_errors.xml')
with self.assertRaises(Exception):
new_loncapa_problem(xml_with_errors) # this problem is improperly constructed
...@@ -738,6 +738,12 @@ class StringResponseTest(ResponseTest): ...@@ -738,6 +738,12 @@ class StringResponseTest(ResponseTest):
# Other strings and the lowercase version of the string are incorrect # Other strings and the lowercase version of the string are incorrect
self.assert_grade(problem, "Other String", "incorrect") self.assert_grade(problem, "Other String", "incorrect")
def test_compatible_non_attribute_additional_answer_xml(self):
problem = self.build_problem(answer="Donut", non_attribute_answers=["Sprinkles"])
self.assert_grade(problem, "Donut", "correct")
self.assert_grade(problem, "Sprinkles", "correct")
self.assert_grade(problem, "Meh", "incorrect")
def test_partial_matching(self): def test_partial_matching(self):
problem = self.build_problem(answer="a2", case_sensitive=False, regexp=True, additional_answers=['.?\\d.?']) problem = self.build_problem(answer="a2", case_sensitive=False, regexp=True, additional_answers=['.?\\d.?'])
self.assert_grade(problem, "a3", "correct") self.assert_grade(problem, "a3", "correct")
......
...@@ -9,6 +9,7 @@ import os ...@@ -9,6 +9,7 @@ import os
import traceback import traceback
import struct import struct
import sys import sys
import re
# We don't want to force a dependency on datadog, so make the import conditional # We don't want to force a dependency on datadog, so make the import conditional
try: try:
...@@ -196,7 +197,7 @@ class CapaFields(object): ...@@ -196,7 +197,7 @@ class CapaFields(object):
"This key is granted for exclusive use by this course for the specified duration. " "This key is granted for exclusive use by this course for the specified duration. "
"Please do not share the API key with other courses and notify MathWorks immediately " "Please do not share the API key with other courses and notify MathWorks immediately "
"if you believe the key is exposed or compromised. To obtain a key for your course, " "if you believe the key is exposed or compromised. To obtain a key for your course, "
"or to report and issue, please contact moocsupport@mathworks.com", "or to report an issue, please contact moocsupport@mathworks.com",
scope=Scope.settings scope=Scope.settings
) )
...@@ -205,7 +206,6 @@ class CapaMixin(CapaFields): ...@@ -205,7 +206,6 @@ class CapaMixin(CapaFields):
""" """
Core logic for Capa Problem, which can be used by XModules or XBlocks. Core logic for Capa Problem, which can be used by XModules or XBlocks.
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(CapaMixin, self).__init__(*args, **kwargs) super(CapaMixin, self).__init__(*args, **kwargs)
...@@ -319,6 +319,7 @@ class CapaMixin(CapaFields): ...@@ -319,6 +319,7 @@ class CapaMixin(CapaFields):
state=state, state=state,
seed=self.seed, seed=self.seed,
capa_system=capa_system, capa_system=capa_system,
capa_module=self, # njp
) )
def get_state_for_lcp(self): def get_state_for_lcp(self):
...@@ -589,13 +590,52 @@ class CapaMixin(CapaFields): ...@@ -589,13 +590,52 @@ class CapaMixin(CapaFields):
return html return html
def get_problem_html(self, encapsulate=True): def get_demand_hint(self, hint_index):
""" """
Return html for the problem. Return html for the problem.
Adds check, reset, save buttons as necessary based on the problem config and state. Adds check, reset, save, and hint buttons as necessary based on the problem config
and state.
encapsulate: if True (the default) embed the html in a problem <div>
hint_index: (None is the default) if not None, this is the index of the next demand
hint to show.
"""
demand_hints = self.lcp.tree.xpath("//problem/demandhint/hint")
hint_index = hint_index % len(demand_hints)
_ = self.runtime.service(self, "i18n").ugettext # pylint: disable=redefined-outer-name
hint_element = demand_hints[hint_index]
hint_text = hint_element.text.strip()
if len(demand_hints) == 1:
prefix = _('Hint: ')
else:
# Translators: e.g. "Hint 1 of 3" meaning we are showing the first of three hints.
prefix = _('Hint ({hint_num} of {hints_count}): ').format(hint_num=hint_index + 1,
hints_count=len(demand_hints))
# Log this demand-hint request
event_info = dict()
event_info['module_id'] = self.location.to_deprecated_string()
event_info['hint_index'] = hint_index
event_info['hint_len'] = len(demand_hints)
event_info['hint_text'] = hint_text
self.runtime.track_function('edx.problem.hint.demandhint_displayed', event_info)
# We report the index of this hint, the client works out what index to use to get the next hint
return {
'success': True,
'contents': prefix + hint_text,
'hint_index': hint_index
}
def get_problem_html(self, encapsulate=True):
""" """
Return html for the problem.
Adds check, reset, save, and hint buttons as necessary based on the problem config
and state.
encapsulate: if True (the default) embed the html in a problem <div>
"""
try: try:
html = self.lcp.get_html() html = self.lcp.get_html()
...@@ -604,6 +644,8 @@ class CapaMixin(CapaFields): ...@@ -604,6 +644,8 @@ class CapaMixin(CapaFields):
except Exception as err: # pylint: disable=broad-except except Exception as err: # pylint: disable=broad-except
html = self.handle_problem_html_error(err) html = self.handle_problem_html_error(err)
html = self.remove_tags_from_html(html)
# The convention is to pass the name of the check button if we want # The convention is to pass the name of the check button if we want
# to show a check button, and False otherwise This works because # to show a check button, and False otherwise This works because
# non-empty strings evaluate to True. We use the same convention # non-empty strings evaluate to True. We use the same convention
...@@ -621,6 +663,10 @@ class CapaMixin(CapaFields): ...@@ -621,6 +663,10 @@ class CapaMixin(CapaFields):
'weight': self.weight, 'weight': self.weight,
} }
# If demand hints are available, emit hint button and div.
demand_hints = self.lcp.tree.xpath("//problem/demandhint/hint")
demand_hint_possible = len(demand_hints) > 0
context = { context = {
'problem': content, 'problem': content,
'id': self.location.to_deprecated_string(), 'id': self.location.to_deprecated_string(),
...@@ -631,6 +677,7 @@ class CapaMixin(CapaFields): ...@@ -631,6 +677,7 @@ class CapaMixin(CapaFields):
'answer_available': self.answer_available(), 'answer_available': self.answer_available(),
'attempts_used': self.attempts, 'attempts_used': self.attempts,
'attempts_allowed': self.max_attempts, 'attempts_allowed': self.max_attempts,
'demand_hint_possible': demand_hint_possible
} }
html = self.runtime.render_template('problem.html', context) html = self.runtime.render_template('problem.html', context)
...@@ -651,6 +698,28 @@ class CapaMixin(CapaFields): ...@@ -651,6 +698,28 @@ class CapaMixin(CapaFields):
return html return html
def remove_tags_from_html(self, html):
"""
The capa xml includes many tags such as <additional_answer> or <demandhint> which are not
meant to be part of the client html. We strip them all and return the resulting html.
"""
tags = ['demandhint', 'choicehint', 'optionhint', 'stringhint', 'numerichint', 'optionhint',
'correcthint', 'regexphint', 'additional_answer', 'stringequalhint', 'compoundhint',
'stringequalhint']
for tag in tags:
html = re.sub(r'<%s.*?>.*?</%s>' % (tag, tag), '', html, flags=re.DOTALL)
# Some of these tags span multiple lines
# Note: could probably speed this up by calling sub() once with a big regex
# vs. simply calling sub() many times as we have here.
return html
def hint_button(self, data):
"""
Hint button handler, returns new html using hint_index from the client.
"""
hint_index = int(data['hint_index'])
return self.get_demand_hint(hint_index)
def is_past_due(self): def is_past_due(self):
""" """
Is it now past this problem's due date, including grace period? Is it now past this problem's due date, including grace period?
......
...@@ -59,6 +59,7 @@ class CapaModule(CapaMixin, XModule): ...@@ -59,6 +59,7 @@ class CapaModule(CapaMixin, XModule):
<other request-specific values here > } <other request-specific values here > }
""" """
handlers = { handlers = {
'hint_button': self.hint_button,
'problem_get': self.get_problem, 'problem_get': self.get_problem,
'problem_check': self.check_problem, 'problem_check': self.check_problem,
'problem_reset': self.reset_problem, 'problem_reset': self.reset_problem,
...@@ -221,6 +222,7 @@ class CapaDescriptor(CapaFields, RawDescriptor): ...@@ -221,6 +222,7 @@ class CapaDescriptor(CapaFields, RawDescriptor):
get_problem_html = module_attr('get_problem_html') get_problem_html = module_attr('get_problem_html')
get_state_for_lcp = module_attr('get_state_for_lcp') get_state_for_lcp = module_attr('get_state_for_lcp')
handle_input_ajax = module_attr('handle_input_ajax') handle_input_ajax = module_attr('handle_input_ajax')
hint_button = module_attr('hint_button')
handle_problem_html_error = module_attr('handle_problem_html_error') handle_problem_html_error = module_attr('handle_problem_html_error')
handle_ungraded_response = module_attr('handle_ungraded_response') handle_ungraded_response = module_attr('handle_ungraded_response')
is_attempted = module_attr('is_attempted') is_attempted = module_attr('is_attempted')
......
$annotation-yellow: rgba(255,255,10,0.3); $annotation-yellow: rgba(255,255,10,0.3);
$color-copy-tip: rgb(100,100,100);
$color-success: rgb(0, 136, 1);
$color-fail: rgb(212, 64, 64);
h2 { h2 {
margin-top: 0; margin-top: 0;
...@@ -19,6 +22,39 @@ h2 { ...@@ -19,6 +22,39 @@ h2 {
} }
} }
.feedback-hint-correct {
margin-top: ($baseline/2);
color: $color-success;
}
.feedback-hint-incorrect {
margin-top: ($baseline/2);
color: $color-fail;
}
.feedback-hint-text {
color: $color-copy-tip;
}
.problem-hint {
color: $color-copy-tip;
margin-bottom: 20px;
}
.hint-label {
font-weight: bold;
display: inline-block;
padding-right: 0.5em;
}
.hint-text {
display: inline-block;
}
.feedback-hint-multi .hint-text {
display: block;
}
iframe[seamless]{ iframe[seamless]{
overflow: hidden; overflow: hidden;
...@@ -631,7 +667,7 @@ div.problem { ...@@ -631,7 +667,7 @@ div.problem {
div.action { div.action {
margin-top: $baseline; margin-top: $baseline;
.save, .check, .show, .reset { .save, .check, .show, .reset, .hint-button {
height: ($baseline*2); height: ($baseline*2);
vertical-align: middle; vertical-align: middle;
font-weight: 600; font-weight: 600;
......
...@@ -455,9 +455,9 @@ describe 'MarkdownEditingDescriptor', -> ...@@ -455,9 +455,9 @@ describe 'MarkdownEditingDescriptor', ->
expect(data).toEqual("""<problem> expect(data).toEqual("""<problem>
<p>Who lead the civil right movement in the United States of America?</p> <p>Who lead the civil right movement in the United States of America?</p>
<stringresponse answer="Dr. Martin Luther King Jr." type="ci" > <stringresponse answer="Dr. Martin Luther King Jr." type="ci" >
<additional_answer>Doctor Martin Luther King Junior</additional_answer> <additional_answer answer="Doctor Martin Luther King Junior"></additional_answer>
<additional_answer>Martin Luther King</additional_answer> <additional_answer answer="Martin Luther King"></additional_answer>
<additional_answer>Martin Luther King Junior</additional_answer> <additional_answer answer="Martin Luther King Junior"></additional_answer>
<textline size="20"/> <textline size="20"/>
</stringresponse> </stringresponse>
...@@ -484,9 +484,9 @@ describe 'MarkdownEditingDescriptor', -> ...@@ -484,9 +484,9 @@ describe 'MarkdownEditingDescriptor', ->
expect(data).toEqual("""<problem> expect(data).toEqual("""<problem>
<p>Write a number from 1 to 4.</p> <p>Write a number from 1 to 4.</p>
<stringresponse answer="^One$" type="ci regexp" > <stringresponse answer="^One$" type="ci regexp" >
<additional_answer>two</additional_answer> <additional_answer answer="two"></additional_answer>
<additional_answer>^thre+</additional_answer> <additional_answer answer="^thre+"></additional_answer>
<additional_answer>^4|Four$</additional_answer> <additional_answer answer="^4|Four$"></additional_answer>
<textline size="20"/> <textline size="20"/>
</stringresponse> </stringresponse>
......
# This file tests the parsing of extended-hints, double bracket sections {{ .. }}
# for all sorts of markdown.
describe 'Markdown to xml extended hint dropdown', ->
it 'produces xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""
Translation between Dropdown and ________ is straightforward.
[[
(Multiple Choice) {{ Good Job::Yes, multiple choice is the right answer. }}
Text Input {{ No, text input problems don't present options. }}
Numerical Input {{ No, numerical input problems don't present options. }}
]]
Clowns have funny _________ to make people laugh.
[[
dogs {{ NOPE::Not dogs, not cats, not toads }}
(FACES) {{ With lots of makeup, doncha know?}}
money {{ Clowns don't have any money, of course }}
donkeys {{don't be an ass.}}
-no hint-
]]
""")
expect(data).toEqual("""
<problem>
<p>Translation between Dropdown and ________ is straightforward.</p>
<optionresponse>
<optioninput>
<option correct="True">Multiple Choice <optionhint label="Good Job">Yes, multiple choice is the right answer.</optionhint></option>
<option correct="False">Text Input <optionhint>No, text input problems don't present options.</optionhint></option>
<option correct="False">Numerical Input <optionhint>No, numerical input problems don't present options.</optionhint></option>
</optioninput>
</optionresponse>
<p>Clowns have funny _________ to make people laugh.</p>
<optionresponse>
<optioninput>
<option correct="False">dogs <optionhint label="NOPE">Not dogs, not cats, not toads</optionhint></option>
<option correct="True">FACES <optionhint>With lots of makeup, doncha know?</optionhint></option>
<option correct="False">money <optionhint>Clowns don't have any money, of course</optionhint></option>
<option correct="False">donkeys <optionhint>don't be an ass.</optionhint></option>
<option correct="False">-no hint-</option>
</optioninput>
</optionresponse>
</problem>
""")
it 'produces xml with demand hint', ->
data = MarkdownEditingDescriptor.markdownToXml("""
Translation between Dropdown and ________ is straightforward.
[[
(Right) {{ Good Job::yes }}
Wrong 1 {{no}}
Wrong 2 {{ Label::no }}
]]
|| 0) zero ||
|| 1) one ||
|| 2) two ||
""")
expect(data).toEqual("""
<problem>
<p>Translation between Dropdown and ________ is straightforward.</p>
<optionresponse>
<optioninput>
<option correct="True">Right <optionhint label="Good Job">yes</optionhint></option>
<option correct="False">Wrong 1 <optionhint>no</optionhint></option>
<option correct="False">Wrong 2 <optionhint label="Label">no</optionhint></option>
</optioninput>
</optionresponse>
<demandhint>
<hint>0) zero</hint>
<hint>1) one</hint>
<hint>2) two</hint>
</demandhint>
</problem>
""")
it 'produces xml with single-line markdown syntax', ->
data = MarkdownEditingDescriptor.markdownToXml("""
A Question ________ is answered.
[[(Right), Wrong 1, Wrong 2]]
|| 0) zero ||
|| 1) one ||
""")
expect(data).toEqual("""
<problem>
<p>A Question ________ is answered.</p>
<optionresponse>
<optioninput options="('Right','Wrong 1','Wrong 2')" correct="Right"></optioninput>
</optionresponse>
<demandhint>
<hint>0) zero</hint>
<hint>1) one</hint>
</demandhint>
</problem>
""")
it 'produces xml with fewer newlines', ->
data = MarkdownEditingDescriptor.markdownToXml("""
>>q1<<
[[ (aa) {{ hint1 }}
bb
cc {{ hint2 }} ]]
""")
expect(data).toEqual("""
<problem>
<p>q1</p>
<optionresponse>
<optioninput label="q1">
<option correct="True">aa <optionhint>hint1</optionhint></option>
<option correct="False">bb</option>
<option correct="False">cc <optionhint>hint2</optionhint></option>
</optioninput>
</optionresponse>
</problem>
""")
it 'produces xml even with lots of whitespace', ->
data = MarkdownEditingDescriptor.markdownToXml("""
>>q1<<
[[
aa {{ hint1 }}
bb {{ hint2 }}
(cc)
]]
""")
expect(data).toEqual("""
<problem>
<p>q1</p>
<optionresponse>
<optioninput label="q1">
<option correct="False">aa <optionhint>hint1</optionhint></option>
<option correct="False">bb <optionhint>hint2</optionhint></option>
<option correct="True">cc</option>
</optioninput>
</optionresponse>
</problem>
""")
describe 'Markdown to xml extended hint checkbox', ->
it 'produces xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""
>>Select all the fruits from the list<<
[x] Apple {{ selected: You're right that apple is a fruit. }, {unselected: Remember that apple is also a fruit.}}
[ ] Mushroom {{U: You're right that mushrooms aren't fruit}, { selected: Mushroom is a fungus, not a fruit.}}
[x] Grape {{ selected: You're right that grape is a fruit }, {unselected: Remember that grape is also a fruit.}}
[ ] Mustang
[ ] Camero {{S:I don't know what a Camero is but it isn't a fruit.},{U:What is a camero anyway?}}
{{ ((A*B)) You're right that apple is a fruit, but there's one you're missing. Also, mushroom is not a fruit.}}
{{ ((B*C)) You're right that grape is a fruit, but there's one you're missing. Also, mushroom is not a fruit. }}
>>Select all the vegetables from the list<<
[ ] Banana {{ selected: No, sorry, a banana is a fruit. }, {unselected: poor banana.}}
[ ] Ice Cream
[ ] Mushroom {{U: You're right that mushrooms aren't vegetables.}, { selected: Mushroom is a fungus, not a vegetable.}}
[x] Brussel Sprout {{S: Brussel sprouts are vegetables.}, {u: Brussel sprout is the only vegetable in this list.}}
{{ ((A*B)) Making a banana split? }}
{{ ((B*D)) That will make a horrible dessert: a brussel sprout split? }}
""")
expect(data).toEqual("""
<problem>
<p>Select all the fruits from the list</p>
<choiceresponse>
<checkboxgroup label="Select all the fruits from the list" direction="vertical">
<choice correct="true">Apple
<choicehint selected="true">You're right that apple is a fruit.</choicehint>
<choicehint selected="false">Remember that apple is also a fruit.</choicehint></choice>
<choice correct="false">Mushroom
<choicehint selected="true">Mushroom is a fungus, not a fruit.</choicehint>
<choicehint selected="false">You're right that mushrooms aren't fruit</choicehint></choice>
<choice correct="true">Grape
<choicehint selected="true">You're right that grape is a fruit</choicehint>
<choicehint selected="false">Remember that grape is also a fruit.</choicehint></choice>
<choice correct="false">Mustang</choice>
<choice correct="false">Camero
<choicehint selected="true">I don't know what a Camero is but it isn't a fruit.</choicehint>
<choicehint selected="false">What is a camero anyway?</choicehint></choice>
<compoundhint value="A*B">You're right that apple is a fruit, but there's one you're missing. Also, mushroom is not a fruit.</compoundhint>
<compoundhint value="B*C">You're right that grape is a fruit, but there's one you're missing. Also, mushroom is not a fruit.</compoundhint>
</checkboxgroup>
</choiceresponse>
<p>Select all the vegetables from the list</p>
<choiceresponse>
<checkboxgroup label="Select all the vegetables from the list" direction="vertical">
<choice correct="false">Banana
<choicehint selected="true">No, sorry, a banana is a fruit.</choicehint>
<choicehint selected="false">poor banana.</choicehint></choice>
<choice correct="false">Ice Cream</choice>
<choice correct="false">Mushroom
<choicehint selected="true">Mushroom is a fungus, not a vegetable.</choicehint>
<choicehint selected="false">You're right that mushrooms aren't vegetables.</choicehint></choice>
<choice correct="true">Brussel Sprout
<choicehint selected="true">Brussel sprouts are vegetables.</choicehint>
<choicehint selected="false">Brussel sprout is the only vegetable in this list.</choicehint></choice>
<compoundhint value="A*B">Making a banana split?</compoundhint>
<compoundhint value="B*D">That will make a horrible dessert: a brussel sprout split?</compoundhint>
</checkboxgroup>
</choiceresponse>
</problem>
""")
it 'produces xml also with demand hints', ->
data = MarkdownEditingDescriptor.markdownToXml("""
>>Select all the fruits from the list<<
[x] Apple {{ selected: You're right that apple is a fruit. }, {unselected: Remember that apple is also a fruit.}}
[ ] Mushroom {{U: You're right that mushrooms aren't fruit}, { selected: Mushroom is a fungus, not a fruit.}}
[x] Grape {{ selected: You're right that grape is a fruit }, {unselected: Remember that grape is also a fruit.}}
[ ] Mustang
[ ] Camero {{S:I don't know what a Camero is but it isn't a fruit.},{U:What is a camero anyway?}}
{{ ((A*B)) You're right that apple is a fruit, but there's one you're missing. Also, mushroom is not a fruit.}}
{{ ((B*C)) You're right that grape is a fruit, but there's one you're missing. Also, mushroom is not a fruit.}}
>>Select all the vegetables from the list<<
[ ] Banana {{ selected: No, sorry, a banana is a fruit. }, {unselected: poor banana.}}
[ ] Ice Cream
[ ] Mushroom {{U: You're right that mushrooms aren't vegatbles}, { selected: Mushroom is a fungus, not a vegetable.}}
[x] Brussel Sprout {{S: Brussel sprouts are vegetables.}, {u: Brussel sprout is the only vegetable in this list.}}
{{ ((A*B)) Making a banana split? }}
{{ ((B*D)) That will make a horrible dessert: a brussel sprout split? }}
|| Hint one.||
|| Hint two. ||
|| Hint three. ||
""")
expect(data).toEqual("""
<problem>
<p>Select all the fruits from the list</p>
<choiceresponse>
<checkboxgroup label="Select all the fruits from the list" direction="vertical">
<choice correct="true">Apple
<choicehint selected="true">You're right that apple is a fruit.</choicehint>
<choicehint selected="false">Remember that apple is also a fruit.</choicehint></choice>
<choice correct="false">Mushroom
<choicehint selected="true">Mushroom is a fungus, not a fruit.</choicehint>
<choicehint selected="false">You're right that mushrooms aren't fruit</choicehint></choice>
<choice correct="true">Grape
<choicehint selected="true">You're right that grape is a fruit</choicehint>
<choicehint selected="false">Remember that grape is also a fruit.</choicehint></choice>
<choice correct="false">Mustang</choice>
<choice correct="false">Camero
<choicehint selected="true">I don't know what a Camero is but it isn't a fruit.</choicehint>
<choicehint selected="false">What is a camero anyway?</choicehint></choice>
<compoundhint value="A*B">You're right that apple is a fruit, but there's one you're missing. Also, mushroom is not a fruit.</compoundhint>
<compoundhint value="B*C">You're right that grape is a fruit, but there's one you're missing. Also, mushroom is not a fruit.</compoundhint>
</checkboxgroup>
</choiceresponse>
<p>Select all the vegetables from the list</p>
<choiceresponse>
<checkboxgroup label="Select all the vegetables from the list" direction="vertical">
<choice correct="false">Banana
<choicehint selected="true">No, sorry, a banana is a fruit.</choicehint>
<choicehint selected="false">poor banana.</choicehint></choice>
<choice correct="false">Ice Cream</choice>
<choice correct="false">Mushroom
<choicehint selected="true">Mushroom is a fungus, not a vegetable.</choicehint>
<choicehint selected="false">You're right that mushrooms aren't vegatbles</choicehint></choice>
<choice correct="true">Brussel Sprout
<choicehint selected="true">Brussel sprouts are vegetables.</choicehint>
<choicehint selected="false">Brussel sprout is the only vegetable in this list.</choicehint></choice>
<compoundhint value="A*B">Making a banana split?</compoundhint>
<compoundhint value="B*D">That will make a horrible dessert: a brussel sprout split?</compoundhint>
</checkboxgroup>
</choiceresponse>
<demandhint>
<hint>Hint one.</hint>
<hint>Hint two.</hint>
<hint>Hint three.</hint>
</demandhint>
</problem>
""")
describe 'Markdown to xml extended hint multiple choice', ->
it 'produces xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""
>>Select the fruit from the list<<
() Mushroom {{ Mushroom is a fungus, not a fruit.}}
() Potato
(x) Apple {{ OUTSTANDING::Apple is indeed a fruit.}}
>>Select the vegetables from the list<<
() Mushroom {{ Mushroom is a fungus, not a vegetable.}}
(x) Potato {{ Potato is a root vegetable. }}
() Apple {{ OOPS::Apple is a fruit.}}
""")
expect(data).toEqual("""
<problem>
<p>Select the fruit from the list</p>
<multiplechoiceresponse>
<choicegroup label="Select the fruit from the list" type="MultipleChoice">
<choice correct="false">Mushroom <choicehint>Mushroom is a fungus, not a fruit.</choicehint></choice>
<choice correct="false">Potato</choice>
<choice correct="true">Apple <choicehint label="OUTSTANDING">Apple is indeed a fruit.</choicehint></choice>
</choicegroup>
</multiplechoiceresponse>
<p>Select the vegetables from the list</p>
<multiplechoiceresponse>
<choicegroup label="Select the vegetables from the list" type="MultipleChoice">
<choice correct="false">Mushroom <choicehint>Mushroom is a fungus, not a vegetable.</choicehint></choice>
<choice correct="true">Potato <choicehint>Potato is a root vegetable.</choicehint></choice>
<choice correct="false">Apple <choicehint label="OOPS">Apple is a fruit.</choicehint></choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""")
it 'produces xml with demand hints', ->
data = MarkdownEditingDescriptor.markdownToXml("""
>>Select the fruit from the list<<
() Mushroom {{ Mushroom is a fungus, not a fruit.}}
() Potato
(x) Apple {{ OUTSTANDING::Apple is indeed a fruit.}}
|| 0) spaces on previous line. ||
|| 1) roses are red. ||
>>Select the vegetables from the list<<
() Mushroom {{ Mushroom is a fungus, not a vegetable.}}
(x) Potato {{ Potato is a root vegetable. }}
() Apple {{ OOPS::Apple is a fruit.}}
|| 2) where are the lions? ||
""")
expect(data).toEqual("""
<problem>
<p>Select the fruit from the list</p>
<multiplechoiceresponse>
<choicegroup label="Select the fruit from the list" type="MultipleChoice">
<choice correct="false">Mushroom <choicehint>Mushroom is a fungus, not a fruit.</choicehint></choice>
<choice correct="false">Potato</choice>
<choice correct="true">Apple <choicehint label="OUTSTANDING">Apple is indeed a fruit.</choicehint></choice>
</choicegroup>
</multiplechoiceresponse>
<p>Select the vegetables from the list</p>
<multiplechoiceresponse>
<choicegroup label="Select the vegetables from the list" type="MultipleChoice">
<choice correct="false">Mushroom <choicehint>Mushroom is a fungus, not a vegetable.</choicehint></choice>
<choice correct="true">Potato <choicehint>Potato is a root vegetable.</choicehint></choice>
<choice correct="false">Apple <choicehint label="OOPS">Apple is a fruit.</choicehint></choice>
</choicegroup>
</multiplechoiceresponse>
<demandhint>
<hint>0) spaces on previous line.</hint>
<hint>1) roses are red.</hint>
<hint>2) where are the lions?</hint>
</demandhint>
</problem>
""")
describe 'Markdown to xml extended hint text input', ->
it 'produces xml', ->
data = MarkdownEditingDescriptor.markdownToXml(""">>In which country would you find the city of Paris?<<
= France {{ BRAVO::Viva la France! }}
""")
expect(data).toEqual("""
<problem>
<p>In which country would you find the city of Paris?</p>
<stringresponse answer="France" type="ci" >
<correcthint label="BRAVO">Viva la France!</correcthint>
<textline label="In which country would you find the city of Paris?" size="20"/>
</stringresponse>
</problem>
""")
it 'produces xml with or=', ->
data = MarkdownEditingDescriptor.markdownToXml(""">>Where Paris?<<
= France {{ BRAVO::hint1}}
or= USA {{ meh::hint2 }}
""")
expect(data).toEqual("""
<problem>
<p>Where Paris?</p>
<stringresponse answer="France" type="ci" >
<correcthint label="BRAVO">hint1</correcthint>
<additional_answer answer="USA"><correcthint label="meh">hint2</correcthint></additional_answer>
<textline label="Where Paris?" size="20"/>
</stringresponse>
</problem>
""")
it 'produces xml with not=', ->
data = MarkdownEditingDescriptor.markdownToXml(""">>Revenge is a dish best served<<
= cold {{khaaaaaan!}}
not= warm {{feedback2}}
""")
expect(data).toEqual("""
<problem>
<p>Revenge is a dish best served</p>
<stringresponse answer="cold" type="ci" >
<correcthint>khaaaaaan!</correcthint>
<stringequalhint answer="warm">feedback2</stringequalhint>
<textline label="Revenge is a dish best served" size="20"/>
</stringresponse>
</problem>
""")
it 'produces xml with s=', ->
data = MarkdownEditingDescriptor.markdownToXml(""">>q<<
s= 2 {{feedback1}}
""")
expect(data).toEqual("""
<problem>
<p>q</p>
<stringresponse answer="2" type="ci" >
<correcthint>feedback1</correcthint>
<textline label="q" size="20"/>
</stringresponse>
</problem>
""")
it 'produces xml with = and or= and not=', ->
data = MarkdownEditingDescriptor.markdownToXml(""">>q<<
= aaa
or= bbb {{feedback1}}
not= no {{feedback2}}
or= ccc
""")
expect(data).toEqual("""
<problem>
<p>q</p>
<stringresponse answer="aaa" type="ci" >
<additional_answer answer="bbb"><correcthint>feedback1</correcthint></additional_answer>
<stringequalhint answer="no">feedback2</stringequalhint>
<additional_answer answer="ccc"></additional_answer>
<textline label="q" size="20"/>
</stringresponse>
</problem>
""")
it 'produces xml with s= and or=', ->
data = MarkdownEditingDescriptor.markdownToXml(""">>q<<
s= 2 {{feedback1}}
or= bbb {{feedback2}}
or= ccc
""")
expect(data).toEqual("""
<problem>
<p>q</p>
<stringresponse answer="2" type="ci" >
<correcthint>feedback1</correcthint>
<additional_answer answer="bbb"><correcthint>feedback2</correcthint></additional_answer>
<additional_answer answer="ccc"></additional_answer>
<textline label="q" size="20"/>
</stringresponse>
</problem>
""")
it 'produces xml with each = making a new question', ->
data = MarkdownEditingDescriptor.markdownToXml(""">>q<<
= aaa
or= bbb
s= ccc
""")
expect(data).toEqual("""
<problem>
<p>q</p>
<stringresponse answer="aaa" type="ci" >
<additional_answer answer="bbb"></additional_answer>
<textline label="q" size="20"/>
</stringresponse>
<stringresponse answer="ccc" type="ci" >
<textline size="20"/>
</stringresponse>
</problem>
""")
it 'produces xml with each = making a new question amid blank lines and paragraphs', ->
data = MarkdownEditingDescriptor.markdownToXml("""
paragraph
>>q<<
= aaa
or= bbb
s= ccc
paragraph 2
""")
expect(data).toEqual("""
<problem>
<p>paragraph</p>
<p>q</p>
<stringresponse answer="aaa" type="ci" >
<additional_answer answer="bbb"></additional_answer>
<textline label="q" size="20"/>
</stringresponse>
<stringresponse answer="ccc" type="ci" >
<textline size="20"/>
</stringresponse>
<p>paragraph 2</p>
</problem>
""")
it 'produces xml without a question when or= is just hung out there by itself', ->
data = MarkdownEditingDescriptor.markdownToXml("""
paragraph
>>q<<
or= aaa
paragraph 2
""")
expect(data).toEqual("""
<problem>
<p>paragraph</p>
<p>q</p>
<p>or= aaa</p>
<p>paragraph 2</p>
</problem>
""")
it 'produces xml with each = with feedback making a new question', ->
data = MarkdownEditingDescriptor.markdownToXml(""">>q<<
s= aaa
or= bbb {{feedback1}}
= ccc {{feedback2}}
""")
expect(data).toEqual("""
<problem>
<p>q</p>
<stringresponse answer="aaa" type="ci" >
<additional_answer answer="bbb"><correcthint>feedback1</correcthint></additional_answer>
<textline label="q" size="20"/>
</stringresponse>
<stringresponse answer="ccc" type="ci" >
<correcthint>feedback2</correcthint>
<textline size="20"/>
</stringresponse>
</problem>
""")
it 'produces xml with demand hints', ->
data = MarkdownEditingDescriptor.markdownToXml(""">>Where Paris?<<
= France {{ BRAVO::hint1 }}
|| There are actually two countries with cities named Paris. ||
|| Paris is the capital of one of those countries. ||
""")
expect(data).toEqual("""
<problem>
<p>Where Paris?</p>
<stringresponse answer="France" type="ci" >
<correcthint label="BRAVO">hint1</correcthint>
<textline label="Where Paris?" size="20"/>
</stringresponse>
<demandhint>
<hint>There are actually two countries with cities named Paris.</hint>
<hint>Paris is the capital of one of those countries.</hint>
</demandhint>
</problem>""")
describe 'Markdown to xml extended hint numeric input', ->
it 'produces xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""
>>Enter the numerical value of Pi:<<
= 3.14159 +- .02 {{ Pie for everyone! }}
>>Enter the approximate value of 502*9:<<
= 4518 +- 15% {{PIE:: No pie for you!}}
>>Enter the number of fingers on a human hand<<
= 5
""")
expect(data).toEqual("""
<problem>
<p>Enter the numerical value of Pi:</p>
<numericalresponse answer="3.14159">
<responseparam type="tolerance" default=".02" />
<formulaequationinput label="Enter the numerical value of Pi:" />
<correcthint>Pie for everyone!</correcthint>
</numericalresponse>
<p>Enter the approximate value of 502*9:</p>
<numericalresponse answer="4518">
<responseparam type="tolerance" default="15%" />
<formulaequationinput label="Enter the approximate value of 502*9:" />
<correcthint label="PIE">No pie for you!</correcthint>
</numericalresponse>
<p>Enter the number of fingers on a human hand</p>
<numericalresponse answer="5">
<formulaequationinput label="Enter the number of fingers on a human hand" />
</numericalresponse>
</problem>
""")
# The output xml here shows some of the quirks of how historical markdown parsing does or does not put
# in blank lines.
it 'numeric input with hints and demand hints', ->
data = MarkdownEditingDescriptor.markdownToXml("""
>>text1<<
= 1 {{ hint1 }}
|| hintA ||
>>text2<<
= 2 {{ hint2 }}
|| hintB ||
""")
expect(data).toEqual("""
<problem>
<p>text1</p>
<numericalresponse answer="1">
<formulaequationinput label="text1" />
<correcthint>hint1</correcthint>
</numericalresponse>
<p>text2</p>
<numericalresponse answer="2">
<formulaequationinput label="text2" />
<correcthint>hint2</correcthint>
</numericalresponse>
<demandhint>
<hint>hintA</hint>
<hint>hintB</hint>
</demandhint>
</problem>
""")
describe 'Markdown to xml extended hint with multiline hints', ->
it 'produces xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""
>>Checkboxes<<
[x] A {{
selected: aaa },
{unselected:bbb}}
[ ] B {{U: c}, {
selected: d.}}
{{ ((A*B)) A*B hint}}
>>What is 1 + 1?<<
= 2 {{ part one, and
part two
}}
>>hello?<<
= hello {{
hello
hint
}}
>>multiple choice<<
(x) AA{{hint1}}
() BB {{
hint2
}}
( ) CC {{ hint3
}}
>>dropdown<<
[[
W1 {{
no }}
W2 {{
nope}}
(C1) {{ yes
}}
]]
|| aaa ||
||bbb||
|| ccc ||
""")
expect(data).toEqual("""
<problem>
<p>Checkboxes</p>
<choiceresponse>
<checkboxgroup label="Checkboxes" direction="vertical">
<choice correct="true">A
<choicehint selected="true">aaa</choicehint>
<choicehint selected="false">bbb</choicehint></choice>
<choice correct="false">B
<choicehint selected="true">d.</choicehint>
<choicehint selected="false">c</choicehint></choice>
<compoundhint value="A*B">A*B hint</compoundhint>
</checkboxgroup>
</choiceresponse>
<p>What is 1 + 1?</p>
<numericalresponse answer="2">
<formulaequationinput label="What is 1 + 1?" />
<correcthint>part one, and part two</correcthint>
</numericalresponse>
<p>hello?</p>
<stringresponse answer="hello" type="ci" >
<correcthint>hello hint</correcthint>
<textline label="hello?" size="20"/>
</stringresponse>
<p>multiple choice</p>
<multiplechoiceresponse>
<choicegroup label="multiple choice" type="MultipleChoice">
<choice correct="true">AA <choicehint>hint1</choicehint></choice>
<choice correct="false">BB <choicehint>hint2</choicehint></choice>
<choice correct="false">CC <choicehint>hint3</choicehint></choice>
</choicegroup>
</multiplechoiceresponse>
<p>dropdown</p>
<optionresponse>
<optioninput label="dropdown">
<option correct="False">W1 <optionhint>no</optionhint></option>
<option correct="False">W2 <optionhint>nope</optionhint></option>
<option correct="True">C1 <optionhint>yes</optionhint></option>
</optioninput>
</optionresponse>
<demandhint>
<hint>aaa</hint>
<hint>bbb</hint>
<hint>ccc</hint>
</demandhint>
</problem>
""")
describe 'Markdown to xml extended hint with tricky syntax cases', ->
# I'm entering this as utf-8 in this file.
# I cannot find a way to set the encoding for .coffee files but it seems to work.
it 'produces xml with unicode', ->
data = MarkdownEditingDescriptor.markdownToXml("""
>>á and Ø<<
(x) Ø{{Ø}}
() BB
|| Ø ||
""")
expect(data).toEqual("""
<problem>
<p>á and Ø</p>
<multiplechoiceresponse>
<choicegroup label="á and Ø" type="MultipleChoice">
<choice correct="true">Ø <choicehint>Ø</choicehint></choice>
<choice correct="false">BB</choice>
</choicegroup>
</multiplechoiceresponse>
<demandhint>
<hint>Ø</hint>
</demandhint>
</problem>
""")
it 'produces xml with quote-type characters', ->
data = MarkdownEditingDescriptor.markdownToXml("""
>>"quotes" aren't `fun`<<
() "hello" {{ isn't }}
(x) "isn't" {{ "hello" }}
""")
expect(data).toEqual("""
<problem>
<p>"quotes" aren't `fun`</p>
<multiplechoiceresponse>
<choicegroup label="&quot;quotes&quot; aren&apos;t `fun`" type="MultipleChoice">
<choice correct="false">"hello" <choicehint>isn't</choicehint></choice>
<choice correct="true">"isn't" <choicehint>"hello"</choicehint></choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""")
it 'produces xml with almost but not quite multiple choice syntax', ->
data = MarkdownEditingDescriptor.markdownToXml("""
>>q1<<
this (x)
() a {{ (hint) }}
(x) b
that (y)
""")
expect(data).toEqual("""
<problem>
<p>q1</p>
<p>this (x)</p>
<multiplechoiceresponse>
<choicegroup label="q1" type="MultipleChoice">
<choice correct="false">a <choicehint>(hint)</choicehint></choice>
<choice correct="true">b</choice>
</choicegroup>
</multiplechoiceresponse>
<p>that (y)</p>
</problem>
""")
# An incomplete checkbox hint passes through to cue the author
it 'produce xml with almost but not quite checkboxgroup syntax', ->
data = MarkdownEditingDescriptor.markdownToXml("""
>>q1<<
this [x]
[ ] a [square]
[x] b {{ this hint passes through }}
that []
""")
expect(data).toEqual("""
<problem>
<p>q1</p>
<p>this [x]</p>
<choiceresponse>
<checkboxgroup label="q1" direction="vertical">
<choice correct="false">a [square]</choice>
<choice correct="true">b {{ this hint passes through }}</choice>
</checkboxgroup>
</choiceresponse>
<p>that []</p>
</problem>
""")
# It's sort of a pain to edit DOS line endings without some editor or other "fixing" them
# for you. Therefore, we construct DOS line endings on the fly just for the test.
it 'produces xml with DOS \r\n line endings', ->
markdown = """
>>q22<<
[[
(x) {{ hintx
these
span
}}
yy {{ meh::hinty }}
zzz {{ hintz }}
]]
"""
markdown = markdown.replace(/\n/g, '\r\n') # make DOS line endings
data = MarkdownEditingDescriptor.markdownToXml(markdown)
expect(data).toEqual("""
<problem>
<p>q22</p>
<optionresponse>
<optioninput label="q22">
<option correct="True">x <optionhint>hintx these span</optionhint></option>
<option correct="False">yy <optionhint label="meh">hinty</optionhint></option>
<option correct="False">zzz <optionhint>hintz</optionhint></option>
</optioninput>
</optionresponse>
</problem>
""")
...@@ -32,6 +32,8 @@ class @Problem ...@@ -32,6 +32,8 @@ class @Problem
@checkButtonCheckText = @checkButtonLabel.text() @checkButtonCheckText = @checkButtonLabel.text()
@checkButtonCheckingText = @checkButton.data('checking') @checkButtonCheckingText = @checkButton.data('checking')
@checkButton.click @check_fd @checkButton.click @check_fd
@$('div.action button.hint-button').click @hint_button
@$('div.action button.reset').click @reset @$('div.action button.reset').click @reset
@$('div.action button.show').click @show @$('div.action button.show').click @show
@$('div.action button.save').click @save @$('div.action button.save').click @save
...@@ -699,3 +701,17 @@ class @Problem ...@@ -699,3 +701,17 @@ class @Problem
if @has_response if @has_response
@enableCheckButton true @enableCheckButton true
window.setTimeout(enableCheckButton, 750) window.setTimeout(enableCheckButton, 750)
hint_button: =>
# Store the index of the currently shown hint as an attribute.
# Use that to compute the next hint number when the button is clicked.
hint_index = @$('.problem-hint').attr('hint_index')
if hint_index == undefined
next_index = 0
else
next_index = parseInt(hint_index) + 1
$.postWithPrefix "#{@url}/hint_button", hint_index: next_index, input_id: @id, (response) =>
@$('.problem-hint').html(response.contents)
@$('.problem-hint').attr('hint_index', response.hint_index)
@$('.hint-button').focus() # a11y focus on click, like the Check button
# Coffeescript markdown support.
# Most of the functionality is in the markdownToXml function,
# which in fact is regular javascript within backticks.
class @MarkdownEditingDescriptor extends XModule.Descriptor class @MarkdownEditingDescriptor extends XModule.Descriptor
# TODO really, these templates should come from or also feed the cheatsheet # TODO really, these templates should come from or also feed the cheatsheet
@multipleChoiceTemplate : "( ) #{gettext 'incorrect'}\n( ) #{gettext 'incorrect'}\n(x) #{gettext 'correct'}\n" @multipleChoiceTemplate : "( ) #{gettext 'incorrect'}\n( ) #{gettext 'incorrect'}\n(x) #{gettext 'correct'}\n"
...@@ -132,8 +136,7 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor ...@@ -132,8 +136,7 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
if @current_editor == @markdown_editor if @current_editor == @markdown_editor
{ {
data: MarkdownEditingDescriptor.markdownToXml(@markdown_editor.getValue()) data: MarkdownEditingDescriptor.markdownToXml(@markdown_editor.getValue())
metadata: metadata: markdown: @markdown_editor.getValue()
markdown: @markdown_editor.getValue()
} }
else else
{ {
...@@ -189,31 +192,129 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor ...@@ -189,31 +192,129 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
else else
return template return template
# We may wish to add insertHeader. Here is Tom's code.
# function makeHeader() {
# var selection = simpleEditor.getSelection();
# var revisedSelection = selection + '\n';
# for(var i = 0; i < selection.length; i++) {
#revisedSelection += '=';
# }
# simpleEditor.replaceSelection(revisedSelection);
#}
#
@markdownToXml: (markdown)-> @markdownToXml: (markdown)->
toXml = `function (markdown) { toXml = `function (markdown) {
var xml = markdown, var xml = markdown,
i, splits, scriptFlag; i, splits, scriptFlag;
// fix DOS \r\n line endings to look like \n
xml = xml.replace(/\r\n/g, '\n');
// replace headers // replace headers
xml = xml.replace(/(^.*?$)(?=\n\=\=+$)/gm, '<h1>$1</h1>'); xml = xml.replace(/(^.*?$)(?=\n\=\=+$)/gm, '<h1>$1</h1>');
xml = xml.replace(/\n^\=\=+$/gm, ''); xml = xml.replace(/\n^\=\=+$/gm, '');
// group multiple choice answers // Pull out demand hints, || a hint ||
var demandhints = '';
xml = xml.replace(/(^\s*\|\|.*?\|\|\s*$\n?)+/gm, function(match) { // $\n
var options = match.split('\n');
for (i = 0; i < options.length; i += 1) {
var inner = /\s*\|\|(.*?)\|\|/.exec(options[i]);
if (inner) {
demandhints += ' <hint>' + inner[1].trim() + '</hint>\n';
}
}
return '';
});
// replace \n+whitespace within extended hint {{ .. }}, by a space, so the whole
// hint sits on one line.
// This is the one instance of {{ ... }} matching that permits \n
xml = xml.replace(/{{(.|\n)*?}}/gm, function(match) {
return match.replace(/\r?\n( |\t)*/g, ' ');
});
// Function used in many places to extract {{ label:: a hint }}.
// Returns a little hash with various parts of the hint:
// hint: the hint or empty, nothint: the rest
// labelassign: javascript assignment of label attribute, or empty
extractHint = function(text, detectParens) {
var curly = /\s*{{(.*?)}}/.exec(text);
var hint = '';
var label = '';
var parens = false;
var labelassign = '';
if (curly) {
text = text.replace(curly[0], '');
hint = curly[1].trim();
var labelmatch = /^(.*?)::/.exec(hint);
if (labelmatch) {
hint = hint.replace(labelmatch[0], '').trim();
label = labelmatch[1].trim();
labelassign = ' label="' + label + '"';
}
}
if (detectParens) {
if (text.length >= 2 && text[0] == '(' && text[text.length-1] == ')') {
text = text.substring(1, text.length-1)
parens = true;
}
}
return {'nothint': text, 'hint': hint, 'label': label, 'parens': parens, 'labelassign': labelassign};
}
// replace selects
// [[ a, b, (c) ]]
// [[
// a
// b
// (c)
// ]]
// <optionresponse>
// <optioninput>
// <option correct="True">AAA<optionhint label="Good Job">Yes, multiple choice is the right answer.</optionhint>
// Note: part of the option-response syntax looks like multiple-choice, so it must be processed first.
xml = xml.replace(/\[\[((.|\n)+?)\]\]/g, function(match, group1) {
// decide if this is old style or new style
if (match.indexOf('\n') == -1) { // OLD style, [[ .... ]] on one line
var options = group1.split(/\,\s*/g);
var optiontag = ' <optioninput options="(';
for (i = 0; i < options.length; i += 1) {
optiontag += "'" + options[i].replace(/(?:^|,)\s*\((.*?)\)\s*(?:$|,)/g, '$1') + "'" + (i < options.length -1 ? ',' : '');
}
optiontag += ')" correct="';
var correct = /(?:^|,)\s*\((.*?)\)\s*(?:$|,)/g.exec(group1);
if (correct) {
optiontag += correct[1];
}
optiontag += '">';
return '\n<optionresponse>\n' + optiontag + '</optioninput>\n</optionresponse>\n\n';
}
// new style [[ many-lines ]]
var lines = group1.split('\n');
var optionlines = ''
for (i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (line.length > 0) {
var textHint = extractHint(line, true);
var correctstr = ' correct="' + (textHint.parens?'True':'False') + '"';
var hintstr = '';
if (textHint.hint) {
var label = textHint.label;
if (label) {
label = ' label="' + label + '"';
}
hintstr = ' <optionhint' + label + '>' + textHint.hint + '</optionhint>';
}
optionlines += ' <option' + correctstr + '>' + textHint.nothint + hintstr + '</option>\n'
}
}
return '\n<optionresponse>\n <optioninput>\n' + optionlines + ' </optioninput>\n</optionresponse>\n\n';
});
//_____________________________________________________________________
//
// multiple choice questions
//
xml = xml.replace(/(^\s*\(.{0,3}\).*?$\n*)+/gm, function(match, p) { xml = xml.replace(/(^\s*\(.{0,3}\).*?$\n*)+/gm, function(match, p) {
var choices = ''; var choices = '';
var shuffle = false; var shuffle = false;
var options = match.split('\n'); var options = match.split('\n');
for(var i = 0; i < options.length; i++) { for(var i = 0; i < options.length; i++) {
options[i] = options[i].trim(); // trim off leading/trailing whitespace
if(options[i].length > 0) { if(options[i].length > 0) {
var value = options[i].split(/^\s*\(.{0,3}\)\s*/)[1]; var value = options[i].split(/^\s*\(.{0,3}\)\s*/)[1];
var inparens = /^\s*\((.{0,3})\)\s*/.exec(options[i])[1]; var inparens = /^\s*\((.{0,3})\)\s*/.exec(options[i])[1];
...@@ -225,6 +326,12 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor ...@@ -225,6 +326,12 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
if(/!/.test(inparens)) { if(/!/.test(inparens)) {
shuffle = true; shuffle = true;
} }
var hint = extractHint(value);
if (hint.hint) {
value = hint.nothint;
value = value + ' <choicehint' + hint.labelassign + '>' + hint.hint + '</choicehint>';
}
choices += ' <choice correct="' + correct + '"' + fixed + '>' + value + '</choice>\n'; choices += ' <choice correct="' + correct + '"' + fixed + '>' + value + '</choice>\n';
} }
} }
...@@ -241,40 +348,90 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor ...@@ -241,40 +348,90 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
}); });
// group check answers // group check answers
xml = xml.replace(/(^\s*\[.?\].*?$\n*)+/gm, function(match) { // [.] with {{...}} lines mixed in
xml = xml.replace(/(^\s*((\[.?\])|({{.*?}})).*?$\n*)+/gm, function(match) {
var groupString = '<choiceresponse>\n', var groupString = '<choiceresponse>\n',
options, value, correct; options, value, correct;
groupString += ' <checkboxgroup direction="vertical">\n'; groupString += ' <checkboxgroup direction="vertical">\n';
options = match.split('\n'); options = match.split('\n');
endHints = ''; // save these up to emit at the end
for (i = 0; i < options.length; i += 1) { for (i = 0; i < options.length; i += 1) {
if(options[i].length > 0) { if(options[i].trim().length > 0) {
// detect the {{ ((A*B)) ...}} case first
// emits: <compoundhint value="A*B">AB hint</compoundhint>
var abhint = /^\s*{{\s*\(\((.*?)\)\)(.*?)}}/.exec(options[i]);
if (abhint) {
// lone case of hint text processing outside of extractHint, since syntax here is unique
var hintbody = abhint[2];
hintbody = hintbody.replace('&lf;', '\n').trim()
endHints += ' <compoundhint value="' + abhint[1].trim() +'">' + hintbody + '</compoundhint>\n';
continue; // bail
}
value = options[i].split(/^\s*\[.?\]\s*/)[1]; value = options[i].split(/^\s*\[.?\]\s*/)[1];
correct = /^\s*\[x\]/i.test(options[i]); correct = /^\s*\[x\]/i.test(options[i]);
groupString += ' <choice correct="' + correct + '">' + value + '</choice>\n'; hints = '';
// {{ selected: Youre right that apple is a fruit. }, {unselected: Remember that apple is also a fruit.}}
var hint = extractHint(value);
if (hint.hint) {
var inner = '{' + hint.hint + '}'; // parsing is easier if we put outer { } back
var select = /{\s*(s|selected):((.|\n)*?)}/i.exec(inner); // include \n since we are downstream of extractHint()
// checkbox choicehints get their own line, since there can be two of them
// <choicehint selected="true">Youre right that apple is a fruit.</choicehint>
if (select) {
hints += '\n <choicehint selected="true">' + select[2].trim() + '</choicehint>';
}
var select = /{\s*(u|unselected):((.|\n)*?)}/i.exec(inner);
if (select) {
hints += '\n <choicehint selected="false">' + select[2].trim() + '</choicehint>';
}
// Blank out the original text only if the specific "selected" syntax is found
// That way, if the user types it wrong, at least they can see it's not processed.
if (hints) {
value = hint.nothint;
}
}
groupString += ' <choice correct="' + correct + '">' + value + hints +'</choice>\n';
} }
} }
groupString += endHints;
groupString += ' </checkboxgroup>\n'; groupString += ' </checkboxgroup>\n';
groupString += '</choiceresponse>\n\n'; groupString += '</choiceresponse>\n\n';
return groupString; return groupString;
}); });
// replace string and numerical
xml = xml.replace(/(^\=\s*(.*?$)(\n*or\=\s*(.*?$))*)+/gm, function(match, p) { // replace string and numerical, numericalresponse, stringresponse
// Split answers // A fine example of the function-composition programming style.
var answersList = p.replace(/^(or)?=\s*/gm, '').split('\n'), xml = xml.replace(/(^s?\=\s*(.*?$)(\n*(or|not)\=\s*(.*?$))*)+/gm, function(match, p) {
// Line split here, trim off leading xxx= in each function
var answersList = p.split('\n'),
processNumericalResponse = function (value) { processNumericalResponse = function (value) {
// Numeric case is just a plain leading = with a single answer
value = value.replace(/^\=\s*/, '');
var params, answer, string; var params, answer, string;
var textHint = extractHint(value);
var hintLine = '';
if (textHint.hint) {
value = textHint.nothint;
hintLine = ' <correcthint' + textHint.labelassign + '>' + textHint.hint + '</correcthint>\n'
}
if (_.contains([ '[', '(' ], value[0]) && _.contains([ ']', ')' ], value[value.length-1]) ) { if (_.contains([ '[', '(' ], value[0]) && _.contains([ ']', ')' ], value[value.length-1]) ) {
// [5, 7) or (5, 7), or (1.2345 * (2+3), 7*4 ] - range tolerance case // [5, 7) or (5, 7), or (1.2345 * (2+3), 7*4 ] - range tolerance case
// = (5*2)*3 should not be used as range tolerance // = (5*2)*3 should not be used as range tolerance
string = '<numericalresponse answer="' + value + '">\n'; string = '<numericalresponse answer="' + value + '">\n';
string += ' <formulaequationinput />\n'; string += ' <formulaequationinput />\n';
string += hintLine;
string += '</numericalresponse>\n\n'; string += '</numericalresponse>\n\n';
return string; return string;
} }
...@@ -296,22 +453,45 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor ...@@ -296,22 +453,45 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
} }
string += ' <formulaequationinput />\n'; string += ' <formulaequationinput />\n';
string += hintLine;
string += '</numericalresponse>\n\n'; string += '</numericalresponse>\n\n';
return string; return string;
}, },
processStringResponse = function (values) { processStringResponse = function (values) {
// First string case is s?=
var firstAnswer = values.shift(), string; var firstAnswer = values.shift(), string;
firstAnswer = firstAnswer.replace(/^s?\=\s*/, '');
if (firstAnswer[0] === '|') { // this is regexp case var textHint = extractHint(firstAnswer);
string = '<stringresponse answer="' + firstAnswer.slice(1).trim() + '" type="ci regexp" >\n'; firstAnswer = textHint.nothint;
} else { var typ = ' type="ci"';
string = '<stringresponse answer="' + firstAnswer + '" type="ci" >\n'; if (firstAnswer[0] == '|') { // this is regexp case
typ = ' type="ci regexp"';
firstAnswer = firstAnswer.slice(1).trim();
}
string = '<stringresponse answer="' + firstAnswer + '"' + typ + ' >\n';
if (textHint.hint) {
string += ' <correcthint' + textHint.labelassign + '>' + textHint.hint + '</correcthint>\n';
} }
// Subsequent cases are not= or or=
for (i = 0; i < values.length; i += 1) { for (i = 0; i < values.length; i += 1) {
string += ' <additional_answer>' + values[i] + '</additional_answer>\n'; var textHint = extractHint(values[i]);
var notMatch = /^not\=\s*(.*)/.exec(textHint.nothint);
if (notMatch) {
string += ' <stringequalhint answer="' + notMatch[1] + '"' + textHint.labelassign + '>' + textHint.hint + '</stringequalhint>\n';
continue;
}
var orMatch = /^or\=\s*(.*)/.exec(textHint.nothint);
if (orMatch) {
// additional_answer with answer= attribute
string += ' <additional_answer answer="' + orMatch[1] + '">';
if (textHint.hint) {
string += '<correcthint' + textHint.labelassign + '>' + textHint.hint + '</correcthint>';
}
string += '</additional_answer>\n';
}
} }
string += ' <textline size="20"/>\n</stringresponse>\n\n'; string += ' <textline size="20"/>\n</stringresponse>\n\n';
...@@ -322,31 +502,7 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor ...@@ -322,31 +502,7 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
return processNumericalResponse(answersList[0]) || processStringResponse(answersList); return processNumericalResponse(answersList[0]) || processStringResponse(answersList);
}); });
// replace selects
xml = xml.replace(/\[\[(.+?)\]\]/g, function(match, p) {
var selectString = '\n<optionresponse>\n',
correct, options;
selectString += ' <optioninput options="(';
options = p.split(/\,\s*/g);
for (i = 0; i < options.length; i += 1) {
selectString += "'" + options[i].replace(/(?:^|,)\s*\((.*?)\)\s*(?:$|,)/g, '$1') + "'" + (i < options.length -1 ? ',' : '');
}
selectString += ')" correct="';
correct = /(?:^|,)\s*\((.*?)\)\s*(?:$|,)/g.exec(p);
if (correct) {
selectString += correct[1];
}
selectString += '"></optioninput>\n';
selectString += '</optionresponse>\n\n';
return selectString;
});
// replace explanations // replace explanations
xml = xml.replace(/\[explanation\]\n?([^\]]*)\[\/?explanation\]/gmi, function(match, p1) { xml = xml.replace(/\[explanation\]\n?([^\]]*)\[\/?explanation\]/gmi, function(match, p1) {
var selectString = '<solution>\n<div class="detailed-solution">\nExplanation\n\n' + p1 + '\n</div>\n</solution>'; var selectString = '<solution>\n<div class="detailed-solution">\nExplanation\n\n' + p1 + '\n</div>\n</solution>';
...@@ -412,8 +568,13 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor ...@@ -412,8 +568,13 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
// rid white space // rid white space
xml = xml.replace(/\n\n\n/g, '\n'); xml = xml.replace(/\n\n\n/g, '\n');
// surround w/ problem tag // if we've come across demand hints, wrap in <demandhint> at the end
xml = '<problem>\n' + xml + '\n</problem>'; if (demandhints) {
demandhints = '\n<demandhint>\n' + demandhints + '</demandhint>';
}
// make all elements descendants of a single problem element
xml = '<problem>\n' + xml + demandhints + '\n</problem>';
return xml; return xml;
}` }`
......
---
metadata:
display_name: Checkboxes with Hints and Feedback
tab: hint
markdown: |
You can provide feedback for each option in a checkbox problem, with distinct feedback depending on whether or not the learner selects that option.
You can also provide compound feedback for a specific combination of answers. For example, if you have three possible answers in the problem, you can configure specific feedback for when a learner selects each combination of possible answers.
You can also add hints for learners.
Be sure to select Settings to specify a Display Name and other values that apply.
Use the following example problem as a model.
>>Which of the following is a fruit? Check all that apply.<<
[x] apple {{ selected: You are correct that an apple is a fruit because it is the fertilized ovary that comes from an apple tree and contains seeds. }, { unselected: Remember that an apple is also a fruit.}}
[x] pumpkin {{ selected: You are correct that a pumpkin is a fruit because it is the fertilized ovary of a squash plant and contains seeds. }, { unselected: Remember that a pumpkin is also a fruit.}}
[ ] potato {{ U: You are correct that a potato is a vegetable because it is an edible part of a plant in tuber form.}, { S: A potato is a vegetable, not a fruit, because it does not come from a flower and does not contain seeds.}}
[x] tomato {{ S: You are correct that a tomato is a fruit because it is the fertilized ovary of a tomato plant and contains seeds. }, { U: Many people mistakenly think a tomato is a vegetable. However, because a tomato is the fertilized ovary of a tomato plant and contains seeds, it is a fruit.}}
{{ ((A B D)) An apple, pumpkin, and tomato are all fruits as they all are fertilized ovaries of a plant and contain seeds. }}
{{ ((A B C D)) You are correct that an apple, pumpkin, and tomato are all fruits as they all are fertilized ovaries of a plant and contain seeds. However, a potato is not a fruit as it is an edible part of a plant in tuber form and is a vegetable. }}
||A fruit is the fertilized ovary from a flower.||
||A fruit contains seeds of the plant.||
data: |
<problem>
<p>You can provide feedback for each option in a checkbox problem, with distinct feedback depending on whether or not the learner selects that option.</p>
<p>You can also provide compound feedback for a specific combination of answers. For example, if you have three possible answers in the problem, you can configure specific feedback for when a learner selects each combination of possible answers.</p>
<p>You can also add hints for learners.</p>
<p>Use the following example problem as a model.</p>
<p>Which of the following is a fruit? Check all that apply.</p>
<choiceresponse>
<checkboxgroup direction="vertical">
<choice correct="true">apple
<choicehint selected="true">You are correct that an apple is a fruit because it is the fertilized ovary that comes from an apple tree and contains seeds.</choicehint>
<choicehint selected="false">Remember that an apple is also a fruit.</choicehint>
</choice>
<choice correct="true">pumpkin
<choicehint selected="true">You are correct that a pumpkin is a fruit because it is the fertilized ovary of a squash plant and contains seeds.</choicehint>
<choicehint selected="false">Remember that a pumpkin is also a fruit.</choicehint>
</choice>
<choice correct="false">potato
<choicehint selected="true">A potato is a vegetable, not a fruit, because it does not come from a flower and does not contain seeds.</choicehint>
<choicehint selected="false">You are correct that a potato is a vegetable because it is an edible part of a plant in tuber form.</choicehint>
</choice>
<choice correct="true">tomato
<choicehint selected="true">You are correct that a tomato is a fruit because it is the fertilized ovary of a tomato plant and contains seeds.</choicehint>
<choicehint selected="false">Many people mistakenly think a tomato is a vegetable. However, because a tomato is the fertilized ovary of a tomato plant and contains seeds, it a fruit.</choicehint>
</choice>
<compoundhint value="A B D">An apple, pumpkin, and tomato are all fruits as they all are fertilized ovaries of a plant and contain seeds.</compoundhint>
<compoundhint value="A B C D">You are correct that an apple, pumpkin, and tomato are all fruits as they all are fertilized ovaries of a plant and contain seeds. However, a potato is not a fruit as it is an edible part of a plant in tuber form and is classified as a vegetable.</compoundhint>
</checkboxgroup>
</choiceresponse>
<demandhint>
<hint>A fruit is the fertilized ovary from a flower.</hint>
<hint>A fruit contains seeds of the plant.</hint>
</demandhint>
</problem>
...@@ -9,7 +9,7 @@ metadata: ...@@ -9,7 +9,7 @@ metadata:
You can use the following example problem as a model. You can use the following example problem as a model.
>>Which of the following countries has the largest population?<< >>Which of the following countries has the largest population?<<
( ) Brazil ( ) Brazil {{ timely feedback -- explain why an almost correct answer is wrong }}
( ) Germany ( ) Germany
(x) Indonesia (x) Indonesia
( ) Russia ( ) Russia
...@@ -32,7 +32,9 @@ data: | ...@@ -32,7 +32,9 @@ data: |
<p>Which of the following countries has the largest population?</p> <p>Which of the following countries has the largest population?</p>
<multiplechoiceresponse> <multiplechoiceresponse>
<choicegroup type="MultipleChoice"> <choicegroup type="MultipleChoice">
<choice correct="false" name="brazil">Brazil</choice> <choice correct="false" name="brazil">Brazil
<choicehint>timely feedback -- explain why an almost correct answer is wrong</choicehint>
</choice>
<choice correct="false" name="germany">Germany</choice> <choice correct="false" name="germany">Germany</choice>
<choice correct="true" name="indonesia">Indonesia</choice> <choice correct="true" name="indonesia">Indonesia</choice>
<choice correct="false" name="russia">Russia</choice> <choice correct="false" name="russia">Russia</choice>
......
---
metadata:
display_name: Multiple Choice with Hints and Feedback
tab: hint
markdown: |
You can provide feedback for each option in a multiple choice problem.
You can also add hints for learners.
Be sure to select Settings to specify a Display Name and other values that apply.
Use the following example problem as a model.
>>Which of the following is a vegetable?<<
( ) apple {{An apple is the fertilized ovary that comes from an apple tree and contains seeds, meaning it is a fruit.}}
( ) pumpkin {{A pumpkin is the fertilized ovary of a squash plant and contains seeds, meaning it is a fruit.}}
(x) potato {{A potato is an edible part of a plant in tuber form and is a vegetable.}}
( ) tomato {{Many people mistakenly think a tomato is a vegetable. However, because a tomato is the fertilized ovary of a tomato plant and contains seeds, it is a fruit.}}
||A fruit is the fertilized ovary from a flower.||
||A fruit contains seeds of the plant.||
data: |
<problem>
<p>You can provide feedback for each option in a multiple choice problem.</p>
<p>You can also add hints for learners.</p>
<p>Use the following example problem as a model.</p>
<p>Which of the following is a vegetable?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice">
<choice correct="false">apple <choicehint>An apple is the fertilized ovary that comes from an apple tree and contains seeds, meaning it is a fruit.</choicehint></choice>
<choice correct="false">pumpkin <choicehint>A pumpkin is the fertilized ovary of a squash plant and contains seeds, meaning it is a fruit.</choicehint></choice>
<choice correct="true">potato <choicehint>A potato is an edible part of a plant in tuber form and is a vegetable.</choicehint></choice>
<choice correct="false">tomato <choicehint>Many people mistakenly think a tomato is a vegetable. However, because a tomato is the fertilized ovary of a tomato plant and contains seeds, it is a fruit.</choicehint></choice>
</choicegroup>
</multiplechoiceresponse>
<demandhint>
<hint>A fruit is the fertilized ovary from a flower.</hint>
<hint>A fruit contains seeds of the plant.</hint>
</demandhint>
</problem>
---
metadata:
display_name: Numerical Input with Hints and Feedback
tab: hint
markdown: |
You can provide feedback for correct answers in numerical input problems. You cannot provide feedback for incorrect answers.
Use feedback for the correct answer to reinforce the process for arriving at the numerical value.
You can also add hints for learners.
Be sure to select Settings to specify a Display Name and other values that apply.
Use the following example problem as a model.
>>What is the arithmetic mean for the following set of numbers? (1, 5, 6, 3, 5)<<
= 4 {{The mean for this set of numbers is 20 / 5, which equals 4.}}
||The mean is calculated by summing the set of numbers and dividing by n.||
||n is the count of items in the set.||
[explanation]
The mean is calculated by summing the set of numbers and dividing by n. In this case: (1 + 5 + 6 + 3 + 5) / 5 = 20 / 5 = 4.
[explanation]
data: |
<problem>
<p>You can provide feedback for correct answers in numerical input problems. You cannot provide feedback for incorrect answers.</p>
<p>Use feedback for the correct answer to reinforce the process for arriving at the numerical value.</p>
<p>Use the following example problem as a model.</p>
<p>What is the arithmetic mean for the following set of numbers? (1, 5, 6, 3, 5)</p>
<numericalresponse answer="4">
<formulaequationinput label="What is the arithmetic mean for the following set of numbers? (1, 5, 6, 3, 5)" />
<correcthint>The mean for this set of numbers is 20 / 5, which equals 4.</correcthint>
</numericalresponse>
<solution>
<div class="detailed-solution">
<p>Explanation</p>
<p>The mean is calculated by summing the set of numbers and dividing by n. In this case: (1 + 5 + 6 + 3 + 5) / 5 = 20 / 5 = 4.</p>
</div>
</solution>
<demandhint>
<hint>The mean is calculated by summing the set of numbers and dividing by n.</hint>
<hint>n is the count of items in the set.</hint>
</demandhint>
</problem>
\ No newline at end of file
---
metadata:
display_name: Dropdown with Hints and Feedback
tab: hint
markdown: |
You can provide feedback for each available option in a dropdown problem.
You can also add hints for learners.
Be sure to select Settings to specify a Display Name and other values that apply.
Use the following example problem as a model.
>> A/an ________ is a vegetable.<<
[[
apple {{An apple is the fertilized ovary that comes from an apple tree and contains seeds, meaning it is a fruit.}}
pumpkin {{A pumpkin is the fertilized ovary of a squash plant and contains seeds, meaning it is a fruit.}}
(potato) {{A potato is an edible part of a plant in tuber form and is a vegetable.}}
tomato {{Many people mistakenly think a tomato is a vegetable. However, because a tomato is the fertilized ovary of a tomato plant and contains seeds, it is a fruit.}}
]]
||A fruit is the fertilized ovary from a flower.||
||A fruit contains seeds of the plant.||
data: |
<problem>
<p>You can provide feedback for each available option in a dropdown problem.</p>
<p>You can also add hints for learners.</p>
<p>Use the following example problem as a model.</p>
<p> A/an ________ is a vegetable.</p>
<br/>
<optionresponse>
<optioninput>
<option correct="False">apple <optionhint>An apple is the fertilized ovary that comes from an apple tree and contains seeds, meaning it is a fruit.</optionhint></option>
<option correct="False">pumpkin <optionhint>A pumpkin is the fertilized ovary of a squash plant and contains seeds, meaning it is a fruit.</optionhint></option>
<option correct="True">potato <optionhint>A potato is an edible part of a plant in tuber form and is a vegetable.</optionhint></option>
<option correct="False">tomato <optionhint>Many people mistakenly think a tomato is a vegetable. However, because a tomato is the fertilized ovary of a tomato plant and contains seeds, it is a fruit.</optionhint></option>
</optioninput>
</optionresponse>
<demandhint>
<hint>A fruit is the fertilized ovary from a flower.</hint>
<hint>A fruit contains seeds of the plant.</hint>
</demandhint>
</problem>
---
metadata:
display_name: Text Input with Hints and Feedback
tab: hint
markdown: |
You can provide feedback for the correct answer in text input problems, as well as for specific incorrect answers.
Use feedback on expected incorrect answers to address common misconceptions and to provide guidance on how to arrive at the correct answer.
Be sure to select Settings to specify a Display Name and other values that apply.
Use the following example problem as a model.
>>Which U.S. state has the largest land area?<<
=Alaska {{Alaska is 576,400 square miles, more than double the land area
of the second largest state, Texas.}}
not=Texas {{While many people think Texas is the largest state, it is actually the second largest, with 261,797 square miles.}}
not=California {{California is the third largest state, with 155,959 square miles.}}
||Consider the square miles, not population.||
||Consider all 50 states, not just the continental United States.||
data: |
<problem>
<p>You can provide feedback for the correct answer in text input problems, as well as for specific incorrect answers.</p>
<p>Use feedback on expected incorrect answers to address common misconceptions and to provide guidance on how to arrive at the correct answer.</p>
<p>Use the following example problem as a model.</p>
<p>Which U.S. state has the largest land area?</p>
<stringresponse answer="Alaska" type="ci" >
<correcthint>Alaska is 576,400 square miles, more than double the land area of the second largest state, Texas.</correcthint>
<stringequalhint answer="Texas">While many people think Texas is the largest state, it is actually the second largest, with 261,797 square miles.</stringequalhint>
<stringequalhint answer="California">California is the third largest state, with 155,959 square miles.</stringequalhint>
<textline label="Which U.S. state has the largest land area?" size="20"/>
</stringresponse>
<demandhint>
<hint>Consider the square miles, not population.</hint>
<hint>Consider all 50 states, not just the continental United States.</hint>
</demandhint>
</problem>
...@@ -1265,6 +1265,54 @@ class CapaModuleTest(unittest.TestCase): ...@@ -1265,6 +1265,54 @@ class CapaModuleTest(unittest.TestCase):
# Assert that the encapsulated html contains the original html # Assert that the encapsulated html contains the original html
self.assertTrue(html in html_encapsulated) self.assertTrue(html in html_encapsulated)
demand_xml = """
<problem>
<p>That is the question</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice">
<choice correct="false">Alpha <choicehint>A hint</choicehint>
</choice>
<choice correct="true">Beta</choice>
</choicegroup>
</multiplechoiceresponse>
<demandhint>
<hint>Demand 1</hint>
<hint>Demand 2</hint>
</demandhint>
</problem>"""
def test_demand_hint(self):
# HTML generation is mocked out to be meaningless here, so instead we check
# the context dict passed into HTML generation.
module = CapaFactory.create(xml=self.demand_xml)
module.get_problem_html() # ignoring html result
context = module.system.render_template.call_args[0][1]
self.assertEqual(context['demand_hint_possible'], True)
# Check the AJAX call that gets the hint by index
result = module.get_demand_hint(0)
self.assertEqual(result['contents'], u'Hint (1 of 2): Demand 1')
self.assertEqual(result['hint_index'], 0)
result = module.get_demand_hint(1)
self.assertEqual(result['contents'], u'Hint (2 of 2): Demand 2')
self.assertEqual(result['hint_index'], 1)
result = module.get_demand_hint(2) # here the server wraps around to index 0
self.assertEqual(result['contents'], u'Hint (1 of 2): Demand 1')
self.assertEqual(result['hint_index'], 0)
def test_demand_hint_logging(self):
module = CapaFactory.create(xml=self.demand_xml)
# Re-mock the module_id to a fixed string, so we can check the logging
module.location = Mock(module.location)
module.location.to_deprecated_string.return_value = 'i4x://edX/capa_test/problem/meh'
module.get_problem_html()
module.get_demand_hint(0)
module.runtime.track_function.assert_called_with(
'edx.problem.hint.demandhint_displayed',
{'hint_index': 0, 'module_id': u'i4x://edX/capa_test/problem/meh',
'hint_text': 'Demand 1', 'hint_len': 2}
)
def test_input_state_consistency(self): def test_input_state_consistency(self):
module1 = CapaFactory.create() module1 = CapaFactory.create()
module2 = CapaFactory.create() module2 = CapaFactory.create()
...@@ -1794,7 +1842,6 @@ class TestProblemCheckTracking(unittest.TestCase): ...@@ -1794,7 +1842,6 @@ class TestProblemCheckTracking(unittest.TestCase):
factory.input_key(3): 'choice_0', factory.input_key(3): 'choice_0',
factory.input_key(4): ['choice_0', 'choice_1'], factory.input_key(4): ['choice_0', 'choice_1'],
} }
event = self.get_event_for_answers(module, answer_input_dict) event = self.get_event_for_answers(module, answer_input_dict)
self.assertEquals(event['submission'], { self.assertEquals(event['submission'], {
...@@ -1837,8 +1884,9 @@ class TestProblemCheckTracking(unittest.TestCase): ...@@ -1837,8 +1884,9 @@ class TestProblemCheckTracking(unittest.TestCase):
with patch.object(module.runtime, 'track_function') as mock_track_function: with patch.object(module.runtime, 'track_function') as mock_track_function:
module.check_problem(answer_input_dict) module.check_problem(answer_input_dict)
self.assertEquals(len(mock_track_function.mock_calls), 1) self.assertGreaterEqual(len(mock_track_function.mock_calls), 1)
mock_call = mock_track_function.mock_calls[0] # There are potentially 2 track logs: answers and hint. [-1]=answers.
mock_call = mock_track_function.mock_calls[-1]
event = mock_call[1][1] event = mock_call[1][1]
return event return event
...@@ -1902,6 +1950,71 @@ class TestProblemCheckTracking(unittest.TestCase): ...@@ -1902,6 +1950,71 @@ class TestProblemCheckTracking(unittest.TestCase):
}, },
}) })
def test_optioninput_extended_xml(self):
"""Test the new XML form of writing with <option> tag instead of options= attribute."""
factory = self.capa_factory_for_problem_xml("""\
<problem display_name="Woo Hoo">
<p>Are you the Gatekeeper?</p>
<optionresponse>
<optioninput>
<option correct="True" label="Good Job">
apple
<optionhint>
banana
</optionhint>
</option>
<option correct="False" label="blorp">
cucumber
<optionhint>
donut
</optionhint>
</option>
</optioninput>
<optioninput>
<option correct="True">
apple
<optionhint>
banana
</optionhint>
</option>
<option correct="False">
cucumber
<optionhint>
donut
</optionhint>
</option>
</optioninput>
</optionresponse>
</problem>
""")
module = factory.create()
answer_input_dict = {
factory.input_key(2, 1): 'apple',
factory.input_key(2, 2): 'cucumber',
}
event = self.get_event_for_answers(module, answer_input_dict)
self.assertEquals(event['submission'], {
factory.answer_key(2, 1): {
'question': '',
'answer': 'apple',
'response_type': 'optionresponse',
'input_type': 'optioninput',
'correct': True,
'variant': '',
},
factory.answer_key(2, 2): {
'question': '',
'answer': 'cucumber',
'response_type': 'optionresponse',
'input_type': 'optioninput',
'correct': False,
'variant': '',
},
})
def test_rerandomized_inputs(self): def test_rerandomized_inputs(self):
factory = CapaFactory factory = CapaFactory
module = factory.create(rerandomize=RANDOMIZATION.ALWAYS) module = factory.create(rerandomize=RANDOMIZATION.ALWAYS)
......
...@@ -28,6 +28,20 @@ class ProblemPage(PageObject): ...@@ -28,6 +28,20 @@ class ProblemPage(PageObject):
""" """
return self.q(css="div.problem p").text return self.q(css="div.problem p").text
@property
def message_text(self):
"""
Return the "message" text of the question of the problem.
"""
return self.q(css="div.problem span.message").text[0]
@property
def hint_text(self):
"""
Return the "hint" text of the problem from its div.
"""
return self.q(css="div.problem div.problem-hint").text[0]
def fill_answer(self, text): def fill_answer(self, text):
""" """
Fill in the answer to the problem. Fill in the answer to the problem.
...@@ -41,6 +55,13 @@ class ProblemPage(PageObject): ...@@ -41,6 +55,13 @@ class ProblemPage(PageObject):
self.q(css='div.problem button.check').click() self.q(css='div.problem button.check').click()
self.wait_for_ajax() self.wait_for_ajax()
def click_hint(self):
"""
Click the Hint button.
"""
self.q(css='div.problem button.hint-button').click()
self.wait_for_ajax()
def is_correct(self): def is_correct(self):
""" """
Is there a "correct" status showing? Is there a "correct" status showing?
......
...@@ -10,6 +10,7 @@ from ...pages.lms.courseware import CoursewarePage ...@@ -10,6 +10,7 @@ from ...pages.lms.courseware import CoursewarePage
from ...pages.lms.problem import ProblemPage from ...pages.lms.problem import ProblemPage
from ...fixtures.course import CourseFixture, XBlockFixtureDesc from ...fixtures.course import CourseFixture, XBlockFixtureDesc
from textwrap import dedent from textwrap import dedent
from ..helpers import EventsTestMixin
class ProblemsTest(UniqueCourseTest): class ProblemsTest(UniqueCourseTest):
...@@ -86,3 +87,77 @@ class ProblemClarificationTest(ProblemsTest): ...@@ -86,3 +87,77 @@ class ProblemClarificationTest(ProblemsTest):
self.assertIn('Return on Investment', tooltip_text) self.assertIn('Return on Investment', tooltip_text)
self.assertIn('per year', tooltip_text) self.assertIn('per year', tooltip_text)
self.assertNotIn('strong', tooltip_text) self.assertNotIn('strong', tooltip_text)
class ProblemExtendedHintTest(ProblemsTest, EventsTestMixin):
"""
Test that extended hint features plumb through to the page html and tracking log.
"""
def get_problem(self):
"""
Problem with extended hint features.
"""
xml = dedent("""
<problem>
<p>question text</p>
<stringresponse answer="A">
<stringequalhint answer="B">hint</stringequalhint>
<textline size="20"/>
</stringresponse>
<demandhint>
<hint>demand-hint1</hint>
<hint>demand-hint2</hint>
</demandhint>
</problem>
""")
return XBlockFixtureDesc('problem', 'TITLE', data=xml)
def test_check_hint(self):
"""
Test clicking Check shows the extended hint in the problem message.
"""
self.courseware_page.visit()
problem_page = ProblemPage(self.browser)
self.assertEqual(problem_page.problem_text[0], u'question text')
problem_page.fill_answer('B')
problem_page.click_check()
self.assertEqual(problem_page.message_text, u'Incorrect: hint')
# Check for corresponding tracking event
actual_events = self.wait_for_events(
event_filter={'event_type': 'edx.problem.hint.feedback_displayed'},
number_of_matches=1
)
self.assert_events_match(
[{'event': {'hint_label': u'Incorrect',
'trigger_type': 'single',
'student_answer': [u'B'],
'correctness': False,
'question_type': 'stringresponse',
'hints': [{'text': 'hint'}]}}],
actual_events)
def test_demand_hint(self):
"""
Test clicking hint button shows the demand hint in its div.
"""
self.courseware_page.visit()
problem_page = ProblemPage(self.browser)
# The hint button rotates through multiple hints
problem_page.click_hint()
self.assertEqual(problem_page.hint_text, u'Hint (1 of 2): demand-hint1')
problem_page.click_hint()
self.assertEqual(problem_page.hint_text, u'Hint (2 of 2): demand-hint2')
problem_page.click_hint()
self.assertEqual(problem_page.hint_text, u'Hint (1 of 2): demand-hint1')
# Check corresponding tracking events
actual_events = self.wait_for_events(
event_filter={'event_type': 'edx.problem.hint.demandhint_displayed'},
number_of_matches=3
)
self.assert_events_match(
[
{'event': {u'hint_index': 0, u'hint_len': 2, u'hint_text': u'demand-hint1'}},
{'event': {u'hint_index': 1, u'hint_len': 2, u'hint_text': u'demand-hint2'}},
{'event': {u'hint_index': 0, u'hint_len': 2, u'hint_text': u'demand-hint1'}}
],
actual_events)
...@@ -13,10 +13,15 @@ ...@@ -13,10 +13,15 @@
</div> </div>
<div class="action"> <div class="action">
<input type="hidden" name="problem_id" value="${ problem['name'] }" /> <input type="hidden" name="problem_id" value="${ problem['name'] }" />
% if demand_hint_possible:
<div class="problem-hint" aria-live="polite"></div>
% endif
% if check_button: % if check_button:
<button class="check ${ check_button }" data-checking="${ check_button_checking }" data-value="${ check_button }"><span class="check-label">${ check_button }</span><span class="sr"> ${_("your answer")}</span></button> <button class="check ${ check_button }" data-checking="${ check_button_checking }" data-value="${ check_button }"><span class="check-label">${ check_button }</span><span class="sr"> ${_("your answer")}</span></button>
% endif % endif
% if demand_hint_possible:
<button class="hint-button" data-value="${_('Hint')}">${_('Hint')}</button>
% endif
% if reset_button: % if reset_button:
<button class="reset" data-value="${_('Reset')}">${_('Reset')}<span class="sr"> ${_("your answer")}</span></button> <button class="reset" data-value="${_('Reset')}">${_('Reset')}<span class="sr"> ${_("your answer")}</span></button>
% endif % endif
......
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