diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f9d007c..5d63835 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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: Extended hints feature + LMS: Mobile API available for courses that opt in using the Course Advanced Setting "Mobile Course Available" (only used in limited closed beta). diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index 743f54f..f295a35 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -59,12 +59,12 @@ def click_new_component_button(step, component_button_css): def _click_advanced(): - css = 'ul.problem-type-tabs a[href="#tab2"]' + css = 'ul.problem-type-tabs a[href="#tab3"]' world.css_click(css) # Wait for the advanced tab items to be displayed - tab2_css = 'div.ui-tabs-panel#tab2' - world.wait_for_visible(tab2_css) + tab3_css = 'div.ui-tabs-panel#tab3' + world.wait_for_visible(tab3_css) def _find_matching_link(category, component_type): diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index cd670e8..8169f54 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -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. """ - 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. @@ -235,14 +235,14 @@ def get_component_templates(courselike, library=False): display_name: the user-visible name of the component category: the type of component (problem, html, etc.) 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 { "display_name": name, "category": cat, "boilerplate_name": boilerplate_name, - "is_common": is_common + "tab": tab } component_display_names = { @@ -268,8 +268,8 @@ def get_component_templates(courselike, library=False): # add the default template with localized display name # TODO: Once mixins are defined per-application, rather than per-runtime, # this should use a cms mixed-in class. (cpennington) - display_name = xblock_type_display_name(category, _('Blank')) - templates_for_category.append(create_template_dict(display_name, category)) + display_name = xblock_type_display_name(category, _('Blank')) # this is the Blank Advanced problem + templates_for_category.append(create_template_dict(display_name, category, None, 'advanced')) categories.add(category) # add boilerplates @@ -277,12 +277,20 @@ def get_component_templates(courselike, library=False): for template in component_class.templates(): filter_templates = getattr(component_class, 'filter_templates', None) 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( create_template_dict( _(template['metadata'].get('display_name')), # pylint: disable=translation-of-non-string category, template.get('template_id'), - template['metadata'].get('markdown') is not None + tab ) ) @@ -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) else: 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) diff --git a/cms/templates/js/add-xblock-component-menu-problem.underscore b/cms/templates/js/add-xblock-component-menu-problem.underscore index aca3c34..3010649 100644 --- a/cms/templates/js/add-xblock-component-menu-problem.underscore +++ b/cms/templates/js/add-xblock-component-menu-problem.underscore @@ -4,13 +4,16 @@ <a class="link-tab" href="#tab1"><%= gettext("Common Problem Types") %></a> </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> </ul> <div class="tab current" id="tab1"> <ul class="new-component-template"> <% for (var i = 0; i < templates.length; i++) { %> - <% if (templates[i].is_common) { %> + <% if (templates[i].tab == "common") { %> <% if (!templates[i].boilerplate_name) { %> <li class="editor-md empty"> <a href="#" data-category="<%= templates[i].category %>"> @@ -32,7 +35,21 @@ <div class="tab" id="tab2"> <ul class="new-component-template"> <% 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"> <a href="#" data-category="<%= templates[i].category %>" data-boilerplate="<%= templates[i].boilerplate_name %>"> diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 707232c..42569d3 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -114,8 +114,8 @@ class LoncapaProblem(object): """ Main class for capa Problems. """ - - def __init__(self, problem_text, id, capa_system, state=None, seed=None): + def __init__(self, problem_text, id, capa_system, capa_module, # pylint: disable=redefined-builtin + state=None, seed=None): """ Initializes capa Problem. @@ -125,6 +125,7 @@ class LoncapaProblem(object): id (string): identifier for this problem, often a filename (no spaces). capa_system (LoncapaSystem): LoncapaSystem instance which provides OS, rendering, user context, and other resources. + capa_module: instance needed to access runtime/logging state (dict): containing the following keys: - `seed` (int) random number generator seed - `student_answers` (dict) maps input id to the stored answer for that input @@ -139,6 +140,7 @@ class LoncapaProblem(object): self.do_reset() self.problem_id = id self.capa_system = capa_system + self.capa_module = capa_module state = state or {} @@ -162,6 +164,8 @@ class LoncapaProblem(object): # parse problem XML file into an element tree self.tree = etree.XML(problem_text) + self.make_xml_compatible(self.tree) + # handle any <include file="foo"> tags self._process_includes() @@ -191,6 +195,49 @@ class LoncapaProblem(object): 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): """ Reset internal state to unfinished, with no answers @@ -819,7 +866,7 @@ class LoncapaProblem(object): # instantiate capa Response 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 self.responders[response] = responder diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 9125451..f4ea469 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -486,15 +486,17 @@ class ChoiceGroup(InputTypeBase): _ = i18n.ugettext for choice in element: - if choice.tag != 'choice': - msg = u"[capa.inputtypes.extract_choices] {error_message}".format( - # Translators: '<choice>' is a tag name and should not be translated. - error_message=_("Expected a <choice> tag; got {given_tag} instead").format( - given_tag=choice.tag + if choice.tag == 'choice': + choices.append((choice.get("name"), stringify_children(choice))) + else: + if choice.tag != 'compoundhint': + 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) - choices.append((choice.get("name"), stringify_children(choice))) + raise Exception(msg) return choices def get_user_visible_answer(self, internal_answer): diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 54fe52a..62a060c 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -61,6 +61,12 @@ CORRECTMAP_PY = None # Make '_' a no-op so we can scrape strings _ = 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 @@ -143,7 +149,7 @@ class LoncapaResponse(object): # By default, we set this to False, allowing subclasses to override as appropriate. 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: @@ -151,12 +157,13 @@ class LoncapaResponse(object): - inputfields : ordered list of ElementTrees for each input entry field in this Response - context : script processor context - system : LoncapaSystem instance which provides OS, rendering, and user context - + - capa_module : Capa module, to access runtime """ self.xml = xml self.inputfields = inputfields self.context = context self.capa_system = system + self.capa_module = capa_module # njp, note None self.id = xml.get('id') @@ -257,6 +264,103 @@ class LoncapaResponse(object): # log.debug('new_cmap = %s' % 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): """ Generate adaptive hints for this problem based on student answers, the old CorrectMap, @@ -266,13 +370,17 @@ class LoncapaResponse(object): Modifies new_cmap, by adding hints to answer_id entries as appropriate. """ + + hintfn = None + hint_function_provided = False hintgroup = self.xml.find('hintgroup') - if hintgroup is None: - return + if hintgroup is not None: + hintfn = hintgroup.get('hintfn') + if hintfn is not None: + hint_function_provided = True - # hint specified by function? - hintfn = hintgroup.get('hintfn') - if hintfn: + if hint_function_provided: + # if a hint function has been supplied, it will take precedence # Hint is determined by a function defined in the <script> context; evaluate # that function to obtain list of hint, hintmode for each answer_id. @@ -327,6 +435,7 @@ class LoncapaResponse(object): new_cmap.set_dict(globals_dict['new_cmap_dict']) return + # no hint function provided # hint specified by conditions and text dependent on conditions (a-la Loncapa design) # see http://help.loncapa.org/cgi-bin/fom?file=291 # @@ -344,7 +453,8 @@ class LoncapaResponse(object): # </formularesponse> 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')): rephints = hintgroup.findall(self.hint_tag) @@ -361,6 +471,9 @@ class LoncapaResponse(object): aid = self.answer_ids[-1] new_cmap.set_hint_and_mode(aid, hint_text, hintmode) 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 def get_score(self, student_answers): @@ -452,7 +565,6 @@ class JavascriptResponse(LoncapaResponse): allowed_inputfields = ['javascriptinput'] def setup_response(self): - # Sets up generator, grader, display, and their dependencies. self.parse_xml() @@ -691,7 +803,6 @@ class ChoiceResponse(LoncapaResponse): and it'd be nice to change this at some point. """ - human_name = _('Checkboxes') tags = ['choiceresponse'] max_inputfields = 1 @@ -700,7 +811,6 @@ class ChoiceResponse(LoncapaResponse): has_responsive_ui = True def setup_response(self): - self.assign_choice_names() correct_xml = self.xml.xpath('//*[@id=$id]//choice[@correct="true"]', @@ -717,6 +827,9 @@ class ChoiceResponse(LoncapaResponse): for index, choice in enumerate(self.xml.xpath('//*[@id=$id]//choice', id=self.xml.get('id'))): 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): @@ -741,6 +854,117 @@ class ChoiceResponse(LoncapaResponse): def get_answers(self): 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): self.correct_choices = [ contextualize_text(choice.get('name'), self.context) 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): """ Initialize name attributes in <choice> stanzas in the <choicegroup> in this response. @@ -831,8 +1086,6 @@ class MultipleChoiceResponse(LoncapaResponse): """ 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 and student_answers[self.answer_id] in self.correct_choices): return CorrectMap(self.answer_id, 'correct') @@ -1014,7 +1267,7 @@ class MultipleChoiceResponse(LoncapaResponse): incorrect_choices = [] for choice in choices: - if choice.get('correct') == 'true': + if choice.get('correct').upper() == 'TRUE': correct_choices.append(choice) else: incorrect_choices.append(choice) @@ -1097,7 +1350,6 @@ class OptionResponse(LoncapaResponse): self.answer_fields = self.inputfields def get_score(self, student_answers): - # log.debug('%s: student_answers=%s' % (unicode(self),student_answers)) cmap = CorrectMap() amap = self.get_answers() for aid in amap: @@ -1113,7 +1365,6 @@ class OptionResponse(LoncapaResponse): def get_answers(self): amap = dict([(af.get('id'), contextualize_text(af.get( 'correct'), self.context)) for af in self.answer_fields]) - # log.debug('%s: expected answers=%s' % (unicode(self),amap)) return amap def get_student_answer_variable_name(self, student_answers, aid): @@ -1128,6 +1379,30 @@ class OptionResponse(LoncapaResponse): return '$' + key 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): """ 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] _ = self.capa_system.i18n.ugettext @@ -1304,6 +1582,24 @@ class NumericalResponse(LoncapaResponse): def get_answers(self): 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): </stringresponse > <stringresponse answer="a1" type="ci regexp"> - <additional_answer>\d5</additional_answer> - <additional_answer>a3</additional_answer> + <additional_answer>d5</additional_answer> + <additional_answer answer="a3"><correcthint>a hint - new format</correcthint></additional_answer> <textline size="20"/> <hintgroup> <stringhint answer="a0" type="ci" name="ha0" /> @@ -1355,7 +1651,6 @@ class StringResponse(LoncapaResponse): ] def setup_response(self): - self.backward = '_or_' in self.xml.get('answer').lower() self.regexp = False self.case_insensitive = False @@ -1369,17 +1664,21 @@ class StringResponse(LoncapaResponse): return # 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] - # 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): """Grade a string response """ - student_answer = student_answers[self.answer_id].strip() - correct = self.check_string(self.correct_answer, student_answer) + if self.answer_id not in student_answers: + 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') def check_string_backward(self, expected, given): @@ -1387,6 +1686,101 @@ class StringResponse(LoncapaResponse): return given.lower() in [i.lower() for i 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): """ Find given in expected. @@ -2572,7 +2966,6 @@ class SchematicResponse(LoncapaResponse): self.code = answer.text def get_score(self, student_answers): - #from capa_problem import global_context submission = [ json.loads(student_answers[k]) for k in sorted(self.answer_ids) ] diff --git a/common/lib/capa/capa/tests/__init__.py b/common/lib/capa/capa/tests/__init__.py index d91a037..658833b 100644 --- a/common/lib/capa/capa/tests/__init__.py +++ b/common/lib/capa/capa/tests/__init__.py @@ -55,9 +55,21 @@ def test_capa_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): """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): diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py index 86a46f0..922a961 100644 --- a/common/lib/capa/capa/tests/response_xml_factory.py +++ b/common/lib/capa/capa/tests/response_xml_factory.py @@ -678,6 +678,9 @@ class StringResponseXMLFactory(ResponseXMLFactory): *additional_answers*: list of additional asnwers. + *non_attribute_answers*: list of additional answers to be coded in the + non-attribute format + """ # Retrieve the **kwargs answer = kwargs.get("answer", None) @@ -686,6 +689,7 @@ class StringResponseXMLFactory(ResponseXMLFactory): hint_fn = kwargs.get('hintfn', None) regexp = kwargs.get('regexp', None) additional_answers = kwargs.get('additional_answers', []) + non_attribute_answers = kwargs.get('non_attribute_answers', []) assert answer # Create the <stringresponse> element @@ -723,7 +727,12 @@ class StringResponseXMLFactory(ResponseXMLFactory): hintgroup_element.set("hintfn", hint_fn) 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 diff --git a/common/lib/capa/capa/tests/test_files/extended_hints.xml b/common/lib/capa/capa/tests/test_files/extended_hints.xml new file mode 100644 index 0000000..e762f19 --- /dev/null +++ b/common/lib/capa/capa/tests/test_files/extended_hints.xml @@ -0,0 +1,50 @@ +<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 diff --git a/common/lib/capa/capa/tests/test_files/extended_hints_checkbox.xml b/common/lib/capa/capa/tests/test_files/extended_hints_checkbox.xml new file mode 100644 index 0000000..c928457 --- /dev/null +++ b/common/lib/capa/capa/tests/test_files/extended_hints_checkbox.xml @@ -0,0 +1,117 @@ +<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> + diff --git a/common/lib/capa/capa/tests/test_files/extended_hints_dropdown.xml b/common/lib/capa/capa/tests/test_files/extended_hints_dropdown.xml new file mode 100644 index 0000000..32fdbf7 --- /dev/null +++ b/common/lib/capa/capa/tests/test_files/extended_hints_dropdown.xml @@ -0,0 +1,42 @@ +<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> diff --git a/common/lib/capa/capa/tests/test_files/extended_hints_multiple_choice.xml b/common/lib/capa/capa/tests/test_files/extended_hints_multiple_choice.xml new file mode 100644 index 0000000..510dc93 --- /dev/null +++ b/common/lib/capa/capa/tests/test_files/extended_hints_multiple_choice.xml @@ -0,0 +1,34 @@ +<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> diff --git a/common/lib/capa/capa/tests/test_files/extended_hints_numeric_input.xml b/common/lib/capa/capa/tests/test_files/extended_hints_numeric_input.xml new file mode 100644 index 0000000..4cebadb --- /dev/null +++ b/common/lib/capa/capa/tests/test_files/extended_hints_numeric_input.xml @@ -0,0 +1,37 @@ +<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> + diff --git a/common/lib/capa/capa/tests/test_files/extended_hints_text_input.xml b/common/lib/capa/capa/tests/test_files/extended_hints_text_input.xml new file mode 100644 index 0000000..abec2ac --- /dev/null +++ b/common/lib/capa/capa/tests/test_files/extended_hints_text_input.xml @@ -0,0 +1,78 @@ +<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><&"'></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> diff --git a/common/lib/capa/capa/tests/test_files/extended_hints_with_errors.xml b/common/lib/capa/capa/tests/test_files/extended_hints_with_errors.xml new file mode 100644 index 0000000..ad55f4f --- /dev/null +++ b/common/lib/capa/capa/tests/test_files/extended_hints_with_errors.xml @@ -0,0 +1,13 @@ +<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> diff --git a/common/lib/capa/capa/tests/test_hint_functionality.py b/common/lib/capa/capa/tests/test_hint_functionality.py new file mode 100644 index 0000000..8030ec9 --- /dev/null +++ b/common/lib/capa/capa/tests/test_hint_functionality.py @@ -0,0 +1,507 @@ +# -*- 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 diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 65351da..eeba76e 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -738,6 +738,12 @@ class StringResponseTest(ResponseTest): # Other strings and the lowercase version of the string are 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): problem = self.build_problem(answer="a2", case_sensitive=False, regexp=True, additional_answers=['.?\\d.?']) self.assert_grade(problem, "a3", "correct") diff --git a/common/lib/xmodule/xmodule/capa_base.py b/common/lib/xmodule/xmodule/capa_base.py index 3c0396a..579774c 100644 --- a/common/lib/xmodule/xmodule/capa_base.py +++ b/common/lib/xmodule/xmodule/capa_base.py @@ -9,6 +9,7 @@ import os import traceback import struct import sys +import re # We don't want to force a dependency on datadog, so make the import conditional try: @@ -196,7 +197,7 @@ class CapaFields(object): "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 " "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 ) @@ -205,7 +206,6 @@ class CapaMixin(CapaFields): """ Core logic for Capa Problem, which can be used by XModules or XBlocks. """ - def __init__(self, *args, **kwargs): super(CapaMixin, self).__init__(*args, **kwargs) @@ -319,6 +319,7 @@ class CapaMixin(CapaFields): state=state, seed=self.seed, capa_system=capa_system, + capa_module=self, # njp ) def get_state_for_lcp(self): @@ -589,13 +590,52 @@ class CapaMixin(CapaFields): return html - def get_problem_html(self, encapsulate=True): + def get_demand_hint(self, hint_index): """ 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: html = self.lcp.get_html() @@ -604,6 +644,8 @@ class CapaMixin(CapaFields): except Exception as err: # pylint: disable=broad-except 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 # to show a check button, and False otherwise This works because # non-empty strings evaluate to True. We use the same convention @@ -621,6 +663,10 @@ class CapaMixin(CapaFields): '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 = { 'problem': content, 'id': self.location.to_deprecated_string(), @@ -631,6 +677,7 @@ class CapaMixin(CapaFields): 'answer_available': self.answer_available(), 'attempts_used': self.attempts, 'attempts_allowed': self.max_attempts, + 'demand_hint_possible': demand_hint_possible } html = self.runtime.render_template('problem.html', context) @@ -651,6 +698,28 @@ class CapaMixin(CapaFields): 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): """ Is it now past this problem's due date, including grace period? diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 92459e9..e9c9a64 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -59,6 +59,7 @@ class CapaModule(CapaMixin, XModule): <other request-specific values here > } """ handlers = { + 'hint_button': self.hint_button, 'problem_get': self.get_problem, 'problem_check': self.check_problem, 'problem_reset': self.reset_problem, @@ -221,6 +222,7 @@ class CapaDescriptor(CapaFields, RawDescriptor): get_problem_html = module_attr('get_problem_html') get_state_for_lcp = module_attr('get_state_for_lcp') 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_ungraded_response = module_attr('handle_ungraded_response') is_attempted = module_attr('is_attempted') diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index 7484768..727630b 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -1,4 +1,7 @@ $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 { margin-top: 0; @@ -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]{ overflow: hidden; @@ -631,7 +667,7 @@ div.problem { div.action { margin-top: $baseline; - .save, .check, .show, .reset { + .save, .check, .show, .reset, .hint-button { height: ($baseline*2); vertical-align: middle; font-weight: 600; diff --git a/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee index f59a36b..a43bb21 100644 --- a/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee @@ -455,9 +455,9 @@ describe 'MarkdownEditingDescriptor', -> expect(data).toEqual("""<problem> <p>Who lead the civil right movement in the United States of America?</p> <stringresponse answer="Dr. Martin Luther King Jr." type="ci" > - <additional_answer>Doctor Martin Luther King Junior</additional_answer> - <additional_answer>Martin Luther King</additional_answer> - <additional_answer>Martin Luther King Junior</additional_answer> + <additional_answer answer="Doctor Martin Luther King Junior"></additional_answer> + <additional_answer answer="Martin Luther King"></additional_answer> + <additional_answer answer="Martin Luther King Junior"></additional_answer> <textline size="20"/> </stringresponse> @@ -484,9 +484,9 @@ describe 'MarkdownEditingDescriptor', -> expect(data).toEqual("""<problem> <p>Write a number from 1 to 4.</p> <stringresponse answer="^One$" type="ci regexp" > - <additional_answer>two</additional_answer> - <additional_answer>^thre+</additional_answer> - <additional_answer>^4|Four$</additional_answer> + <additional_answer answer="two"></additional_answer> + <additional_answer answer="^thre+"></additional_answer> + <additional_answer answer="^4|Four$"></additional_answer> <textline size="20"/> </stringresponse> diff --git a/common/lib/xmodule/xmodule/js/spec/problem/edit_spec_hint.coffee b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec_hint.coffee new file mode 100644 index 0000000..5d2cefa --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec_hint.coffee @@ -0,0 +1,936 @@ +# 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=""quotes" aren'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> + """) + diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index f50d748..139654e 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -32,6 +32,8 @@ class @Problem @checkButtonCheckText = @checkButtonLabel.text() @checkButtonCheckingText = @checkButton.data('checking') @checkButton.click @check_fd + + @$('div.action button.hint-button').click @hint_button @$('div.action button.reset').click @reset @$('div.action button.show').click @show @$('div.action button.save').click @save @@ -699,3 +701,17 @@ class @Problem if @has_response @enableCheckButton true 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 + diff --git a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee index 378b142..a3e878d 100644 --- a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee +++ b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee @@ -1,3 +1,7 @@ +# 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 # TODO really, these templates should come from or also feed the cheatsheet @multipleChoiceTemplate : "( ) #{gettext 'incorrect'}\n( ) #{gettext 'incorrect'}\n(x) #{gettext 'correct'}\n" @@ -132,8 +136,7 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor if @current_editor == @markdown_editor { data: MarkdownEditingDescriptor.markdownToXml(@markdown_editor.getValue()) - metadata: - markdown: @markdown_editor.getValue() + metadata: markdown: @markdown_editor.getValue() } else { @@ -189,31 +192,129 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor else 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)-> toXml = `function (markdown) { var xml = markdown, i, splits, scriptFlag; + // fix DOS \r\n line endings to look like \n + xml = xml.replace(/\r\n/g, '\n'); + // replace headers xml = xml.replace(/(^.*?$)(?=\n\=\=+$)/gm, '<h1>$1</h1>'); 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) { var choices = ''; var shuffle = false; var options = match.split('\n'); for(var i = 0; i < options.length; i++) { + options[i] = options[i].trim(); // trim off leading/trailing whitespace if(options[i].length > 0) { var value = options[i].split(/^\s*\(.{0,3}\)\s*/)[1]; var inparens = /^\s*\((.{0,3})\)\s*/.exec(options[i])[1]; @@ -225,6 +326,12 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor if(/!/.test(inparens)) { 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'; } } @@ -241,40 +348,90 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor }); // 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', options, value, correct; groupString += ' <checkboxgroup direction="vertical">\n'; options = match.split('\n'); + + endHints = ''; // save these up to emit at the end 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]; correct = /^\s*\[x\]/i.test(options[i]); - groupString += ' <choice correct="' + correct + '">' + value + '</choice>\n'; + hints = ''; + // {{ selected: You’re 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">You’re 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 += '</choiceresponse>\n\n'; return groupString; }); - // replace string and numerical - xml = xml.replace(/(^\=\s*(.*?$)(\n*or\=\s*(.*?$))*)+/gm, function(match, p) { - // Split answers - var answersList = p.replace(/^(or)?=\s*/gm, '').split('\n'), + + // replace string and numerical, numericalresponse, stringresponse + // A fine example of the function-composition programming style. + 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) { + // Numeric case is just a plain leading = with a single answer + value = value.replace(/^\=\s*/, ''); 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]) ) { // [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 string = '<numericalresponse answer="' + value + '">\n'; string += ' <formulaequationinput />\n'; + string += hintLine; string += '</numericalresponse>\n\n'; return string; } @@ -296,22 +453,45 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor } string += ' <formulaequationinput />\n'; + string += hintLine; string += '</numericalresponse>\n\n'; return string; }, processStringResponse = function (values) { + // First string case is s?= var firstAnswer = values.shift(), string; - - if (firstAnswer[0] === '|') { // this is regexp case - string = '<stringresponse answer="' + firstAnswer.slice(1).trim() + '" type="ci regexp" >\n'; - } else { - string = '<stringresponse answer="' + firstAnswer + '" type="ci" >\n'; + firstAnswer = firstAnswer.replace(/^s?\=\s*/, ''); + var textHint = extractHint(firstAnswer); + firstAnswer = textHint.nothint; + var typ = ' type="ci"'; + 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) { - 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'; @@ -322,31 +502,7 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor 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 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>'; @@ -412,8 +568,13 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor // rid white space xml = xml.replace(/\n\n\n/g, '\n'); - // surround w/ problem tag - xml = '<problem>\n' + xml + '\n</problem>'; + // if we've come across demand hints, wrap in <demandhint> at the end + 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; }` diff --git a/common/lib/xmodule/xmodule/templates/problem/checkboxes_response_hint.yaml b/common/lib/xmodule/xmodule/templates/problem/checkboxes_response_hint.yaml new file mode 100644 index 0000000..de88f55 --- /dev/null +++ b/common/lib/xmodule/xmodule/templates/problem/checkboxes_response_hint.yaml @@ -0,0 +1,70 @@ +--- +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> diff --git a/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml b/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml index 9a43879..bc9aa26 100644 --- a/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml +++ b/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml @@ -9,7 +9,7 @@ metadata: You can use the following example problem as a model. >>Which of the following countries has the largest population?<< - ( ) Brazil + ( ) Brazil {{ timely feedback -- explain why an almost correct answer is wrong }} ( ) Germany (x) Indonesia ( ) Russia @@ -32,7 +32,9 @@ data: | <p>Which of the following countries has the largest population?</p> <multiplechoiceresponse> <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="true" name="indonesia">Indonesia</choice> <choice correct="false" name="russia">Russia</choice> diff --git a/common/lib/xmodule/xmodule/templates/problem/multiplechoice_hint.yaml b/common/lib/xmodule/xmodule/templates/problem/multiplechoice_hint.yaml new file mode 100644 index 0000000..99998e7 --- /dev/null +++ b/common/lib/xmodule/xmodule/templates/problem/multiplechoice_hint.yaml @@ -0,0 +1,46 @@ +--- +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> diff --git a/common/lib/xmodule/xmodule/templates/problem/numericalresponse_hint.yaml b/common/lib/xmodule/xmodule/templates/problem/numericalresponse_hint.yaml new file mode 100644 index 0000000..d315ea2 --- /dev/null +++ b/common/lib/xmodule/xmodule/templates/problem/numericalresponse_hint.yaml @@ -0,0 +1,54 @@ +--- +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 diff --git a/common/lib/xmodule/xmodule/templates/problem/optionresponse_hint.yaml b/common/lib/xmodule/xmodule/templates/problem/optionresponse_hint.yaml new file mode 100644 index 0000000..c78933a --- /dev/null +++ b/common/lib/xmodule/xmodule/templates/problem/optionresponse_hint.yaml @@ -0,0 +1,51 @@ +--- +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> diff --git a/common/lib/xmodule/xmodule/templates/problem/string_response_hint.yaml b/common/lib/xmodule/xmodule/templates/problem/string_response_hint.yaml new file mode 100644 index 0000000..e52bf9f --- /dev/null +++ b/common/lib/xmodule/xmodule/templates/problem/string_response_hint.yaml @@ -0,0 +1,54 @@ +--- +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> diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index e88e597..33b0e9f 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -1265,6 +1265,54 @@ class CapaModuleTest(unittest.TestCase): # Assert that the encapsulated html contains the original html 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): module1 = CapaFactory.create() module2 = CapaFactory.create() @@ -1794,7 +1842,6 @@ class TestProblemCheckTracking(unittest.TestCase): factory.input_key(3): 'choice_0', factory.input_key(4): ['choice_0', 'choice_1'], } - event = self.get_event_for_answers(module, answer_input_dict) self.assertEquals(event['submission'], { @@ -1837,8 +1884,9 @@ class TestProblemCheckTracking(unittest.TestCase): with patch.object(module.runtime, 'track_function') as mock_track_function: module.check_problem(answer_input_dict) - self.assertEquals(len(mock_track_function.mock_calls), 1) - mock_call = mock_track_function.mock_calls[0] + self.assertGreaterEqual(len(mock_track_function.mock_calls), 1) + # There are potentially 2 track logs: answers and hint. [-1]=answers. + mock_call = mock_track_function.mock_calls[-1] event = mock_call[1][1] return event @@ -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): factory = CapaFactory module = factory.create(rerandomize=RANDOMIZATION.ALWAYS) diff --git a/common/test/acceptance/pages/lms/problem.py b/common/test/acceptance/pages/lms/problem.py index 5ba10d9..fa49ea8 100644 --- a/common/test/acceptance/pages/lms/problem.py +++ b/common/test/acceptance/pages/lms/problem.py @@ -28,6 +28,20 @@ class ProblemPage(PageObject): """ 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): """ Fill in the answer to the problem. @@ -41,6 +55,13 @@ class ProblemPage(PageObject): self.q(css='div.problem button.check').click() 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): """ Is there a "correct" status showing? diff --git a/common/test/acceptance/tests/lms/test_lms_problems.py b/common/test/acceptance/tests/lms/test_lms_problems.py index b19f65c..cb2fe66 100644 --- a/common/test/acceptance/tests/lms/test_lms_problems.py +++ b/common/test/acceptance/tests/lms/test_lms_problems.py @@ -10,6 +10,7 @@ from ...pages.lms.courseware import CoursewarePage from ...pages.lms.problem import ProblemPage from ...fixtures.course import CourseFixture, XBlockFixtureDesc from textwrap import dedent +from ..helpers import EventsTestMixin class ProblemsTest(UniqueCourseTest): @@ -86,3 +87,77 @@ class ProblemClarificationTest(ProblemsTest): self.assertIn('Return on Investment', tooltip_text) self.assertIn('per year', 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) diff --git a/lms/templates/problem.html b/lms/templates/problem.html index 0d3ff4e..17c9775 100644 --- a/lms/templates/problem.html +++ b/lms/templates/problem.html @@ -13,10 +13,15 @@ </div> <div class="action"> <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: <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 + % if demand_hint_possible: + <button class="hint-button" data-value="${_('Hint')}">${_('Hint')}</button> + % endif % if reset_button: <button class="reset" data-value="${_('Reset')}">${_('Reset')}<span class="sr"> ${_("your answer")}</span></button> % endif