diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index a0b74372e255..0ff086ea6214 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -58,6 +58,8 @@ CorrectMap = correctmap.CorrectMap # pylint: disable=invalid-name CORRECTMAP_PY = None +# Make '_' a no-op so we can scrape strings +_ = lambda text: text #----------------------------------------------------------------------------- # Exceptions @@ -439,6 +441,7 @@ class JavascriptResponse(LoncapaResponse): Javascript using Node.js. """ + human_name = _('Javascript Input') tags = ['javascriptresponse'] max_inputfields = 1 allowed_inputfields = ['javascriptinput'] @@ -684,6 +687,7 @@ class ChoiceResponse(LoncapaResponse): """ + human_name = _('Checkboxes') tags = ['choiceresponse'] max_inputfields = 1 allowed_inputfields = ['checkboxgroup', 'radiogroup'] @@ -754,6 +758,7 @@ class MultipleChoiceResponse(LoncapaResponse): """ # TODO: handle direction and randomize + human_name = _('Multiple Choice') tags = ['multiplechoiceresponse'] max_inputfields = 1 allowed_inputfields = ['choicegroup'] @@ -1042,6 +1047,7 @@ def sample_from_answer_pool(self, choices, rng, num_pool): @registry.register class TrueFalseResponse(MultipleChoiceResponse): + human_name = _('True/False Choice') tags = ['truefalseresponse'] def mc_setup_response(self): @@ -1073,6 +1079,7 @@ class OptionResponse(LoncapaResponse): TODO: handle direction and randomize """ + human_name = _('Dropdown') tags = ['optionresponse'] hint_tag = 'optionhint' allowed_inputfields = ['optioninput'] @@ -1108,6 +1115,7 @@ class NumericalResponse(LoncapaResponse): to a number (e.g. `4+5/2^2`), and accepts with a tolerance. """ + human_name = _('Numerical Input') tags = ['numericalresponse'] hint_tag = 'numericalhint' allowed_inputfields = ['textline', 'formulaequationinput'] @@ -1308,6 +1316,7 @@ class StringResponse(LoncapaResponse): """ + human_name = _('Text Input') tags = ['stringresponse'] hint_tag = 'stringhint' allowed_inputfields = ['textline'] @@ -1426,6 +1435,7 @@ class CustomResponse(LoncapaResponse): or in a """ + human_name = _('Custom Evaluated Script') tags = ['customresponse'] allowed_inputfields = ['textline', 'textbox', 'crystallography', @@ -1797,6 +1807,7 @@ class SymbolicResponse(CustomResponse): Symbolic math response checking, using symmath library. """ + human_name = _('Symbolic Math Input') tags = ['symbolicresponse'] max_inputfields = 1 @@ -1865,6 +1876,7 @@ class CodeResponse(LoncapaResponse): """ + human_name = _('Code Input') tags = ['coderesponse'] allowed_inputfields = ['textbox', 'filesubmission', 'matlabinput'] max_inputfields = 1 @@ -2142,6 +2154,7 @@ class ExternalResponse(LoncapaResponse): """ + human_name = _('External Grader') tags = ['externalresponse'] allowed_inputfields = ['textline', 'textbox'] awdmap = { @@ -2299,6 +2312,7 @@ class FormulaResponse(LoncapaResponse): Checking of symbolic math response using numerical sampling. """ + human_name = _('Math Expression Input') tags = ['formularesponse'] hint_tag = 'formulahint' allowed_inputfields = ['textline', 'formulaequationinput'] @@ -2511,6 +2525,7 @@ class SchematicResponse(LoncapaResponse): """ Circuit schematic response type. """ + human_name = _('Circuit Schematic Builder') tags = ['schematicresponse'] allowed_inputfields = ['schematic'] @@ -2589,6 +2604,7 @@ class ImageResponse(LoncapaResponse): True, if click is inside any region or rectangle. Otherwise False. """ + human_name = _('Image Mapped Input') tags = ['imageresponse'] allowed_inputfields = ['imageinput'] @@ -2707,6 +2723,7 @@ class AnnotationResponse(LoncapaResponse): The response contains both a comment (student commentary) and an option (student tag). Only the tag is currently graded. Answers may be incorrect, partially correct, or correct. """ + human_name = _('Annotation Input') tags = ['annotationresponse'] allowed_inputfields = ['annotationinput'] max_inputfields = 1 @@ -2831,6 +2848,7 @@ class ChoiceTextResponse(LoncapaResponse): ChoiceResponse. """ + human_name = _('Checkboxes With Text Input') tags = ['choicetextresponse'] max_inputfields = 1 allowed_inputfields = ['choicetextgroup', diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 776dc36e3fb9..47583d97065a 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -2,10 +2,12 @@ import json import logging import sys +from lxml import etree from pkg_resources import resource_string from .capa_base import CapaMixin, CapaFields, ComplexEncoder +from capa import responsetypes from .progress import Progress from xmodule.x_module import XModule, module_attr from xmodule.raw_module import RawDescriptor @@ -172,6 +174,13 @@ def non_editable_metadata_fields(self): ]) return non_editable_fields + @property + def problem_types(self): + """ Low-level problem type introspection for content libraries filtering by problem type """ + tree = etree.XML(self.data) + registered_tags = responsetypes.registry.registered_tags() + return set([node.tag for node in tree.iter() if node.tag in registered_tags]) + # Proxy to CapaModule for access to any of its attributes answer_available = module_attr('answer_available') check_button_name = module_attr('check_button_name') diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 8571540f7589..a3d632292961 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -5,6 +5,8 @@ from bson.objectid import ObjectId, InvalidId from collections import namedtuple from copy import copy +from capa.responsetypes import registry + from .mako_module import MakoModuleDescriptor from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import LibraryLocator @@ -19,15 +21,38 @@ from .xml_module import XmlDescriptor from pkg_resources import resource_string + # Make '_' a no-op so we can scrape strings _ = lambda text: text +ANY_CAPA_TYPE_VALUE = 'any' + + def enum(**enums): """ enum helper in lieu of enum34 """ return type('Enum', (), enums) +def _get_human_name(problem_class): + """ + Get the human-friendly name for a problem type. + """ + return getattr(problem_class, 'human_name', problem_class.__name__) + + +def _get_capa_types(): + """ + Gets capa types tags and labels + """ + capa_types = {tag: _get_human_name(registry.get_class_for_tag(tag)) for tag in registry.registered_tags()} + + return [{'value': ANY_CAPA_TYPE_VALUE, 'display_name': _('Any Type')}] + sorted([ + {'value': capa_type, 'display_name': caption} + for capa_type, caption in capa_types.items() + ], key=lambda item: item.get('display_name')) + + class LibraryVersionReference(namedtuple("LibraryVersionReference", "library_id version")): """ A reference to a specific library, with an optional version. @@ -146,6 +171,13 @@ class LibraryContentFields(object): default=1, scope=Scope.settings, ) + capa_type = String( + display_name=_("Problem Type"), + help=_('Choose a problem type to fetch from the library. If "Any Type" is selected no filtering is applied.'), + default=ANY_CAPA_TYPE_VALUE, + values=_get_capa_types(), + scope=Scope.settings, + ) filters = String(default="") # TBD has_score = Boolean( display_name=_("Scored"), @@ -311,6 +343,39 @@ def refresh_children(self, request, suffix, update_db=True): # pylint: disable= lib_tools.update_children(self, user_id, update_db) return Response() + def _validate_library_version(self, validation, lib_tools, version, library_key): + """ + Validates library version + """ + latest_version = lib_tools.get_library_version(library_key) + if latest_version is not None: + if version is None or version != latest_version: + validation.set_summary( + StudioValidationMessage( + StudioValidationMessage.WARNING, + _(u'This component is out of date. The library has new content.'), + action_class='library-update-btn', # TODO: change this to action_runtime_event='...' once the unit page supports that feature. + action_label=_(u"↻ Update now") + ) + ) + return False + else: + validation.set_summary( + StudioValidationMessage( + StudioValidationMessage.ERROR, + _(u'Library is invalid, corrupt, or has been deleted.'), + action_class='edit-button', + action_label=_(u"Edit Library List") + ) + ) + return False + return True + + def _set_validation_error_if_empty(self, validation, summary): + """ Helper method to only set validation summary if it's empty """ + if validation.empty: + validation.set_summary(summary) + def validate(self): """ Validates the state of this Library Content Module Instance. This @@ -331,30 +396,41 @@ def validate(self): ) return validation lib_tools = self.runtime.service(self, 'library_tools') + matching_children_count = 0 for library_key, version in self.source_libraries: - latest_version = lib_tools.get_library_version(library_key) - if latest_version is not None: - if version is None or version != latest_version: - validation.set_summary( - StudioValidationMessage( - StudioValidationMessage.WARNING, - _(u'This component is out of date. The library has new content.'), - action_class='library-update-btn', # TODO: change this to action_runtime_event='...' once the unit page supports that feature. - action_label=_(u"↻ Update now") - ) - ) - break - else: - validation.set_summary( - StudioValidationMessage( - StudioValidationMessage.ERROR, - _(u'Library is invalid, corrupt, or has been deleted.'), - action_class='edit-button', - action_label=_(u"Edit Library List") - ) - ) + if not self._validate_library_version(validation, lib_tools, version, library_key): break + library = lib_tools.get_library(library_key) + children_matching_filter = lib_tools.get_filtered_children(library, self.capa_type) + # get_filtered_children returns generator, so can't use len. + # And we don't actually need those children, so no point of constructing a list + matching_children_count += sum(1 for child in children_matching_filter) + + if matching_children_count == 0: + self._set_validation_error_if_empty( + validation, + StudioValidationMessage( + StudioValidationMessage.WARNING, + _(u'There are no matching problem types in the specified libraries.'), + action_class='edit-button', + action_label=_(u"Select another problem type") + ) + ) + + if matching_children_count < self.max_count: + self._set_validation_error_if_empty( + validation, + StudioValidationMessage( + StudioValidationMessage.WARNING, + _(u'The specified libraries are configured to fetch {count} problems, ' + u'but there are only {actual} matching problems.') + .format(actual=matching_children_count, count=self.max_count), + action_class='edit-button', + action_label=_(u"Edit configuration") + ) + ) + return validation def editor_saved(self, user, old_metadata, old_content): @@ -362,7 +438,8 @@ def editor_saved(self, user, old_metadata, old_content): If source_libraries has been edited, refresh_children automatically. """ old_source_libraries = LibraryList().from_json(old_metadata.get('source_libraries', [])) - if set(old_source_libraries) != set(self.source_libraries): + if set(old_source_libraries) != set(self.source_libraries) or \ + old_metadata.get('capa_type', ANY_CAPA_TYPE_VALUE) != self.capa_type: try: self.refresh_children(None, None, update_db=False) # update_db=False since update_item() is about to be called anyways except ValueError: diff --git a/common/lib/xmodule/xmodule/library_tools.py b/common/lib/xmodule/xmodule/library_tools.py index 0ff50d81a911..cb250586d395 100644 --- a/common/lib/xmodule/xmodule/library_tools.py +++ b/common/lib/xmodule/xmodule/library_tools.py @@ -4,8 +4,9 @@ import hashlib from opaque_keys.edx.locator import LibraryLocator from xblock.fields import Scope -from xmodule.library_content_module import LibraryVersionReference +from xmodule.library_content_module import LibraryVersionReference, ANY_CAPA_TYPE_VALUE from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.capa_module import CapaDescriptor class LibraryToolsService(object): @@ -16,7 +17,7 @@ class LibraryToolsService(object): def __init__(self, modulestore): self.store = modulestore - def _get_library(self, library_key): + def get_library(self, library_key): """ Given a library key like "library-v1:ProblemX+PR0B", return the 'library' XBlock with meta-information about the library. @@ -37,7 +38,7 @@ def get_library_version(self, lib_key): Get the version (an ObjectID) of the given library. Returns None if the library does not exist. """ - library = self._get_library(lib_key) + library = self.get_library(lib_key) if library: # We need to know the library's version so ensure it's set in library.location.library_key.version_guid assert library.location.library_key.version_guid is not None @@ -49,11 +50,38 @@ def get_library_display_name(self, lib_key): Get the display_name of the given library. Returns None if the library does not exist. """ - library = self._get_library(lib_key) + library = self.get_library(lib_key) if library: return library.display_name return None + def _filter_child(self, capa_type, child_descriptor): + """ + Filters children by CAPA problem type, if configured + """ + if capa_type == ANY_CAPA_TYPE_VALUE: + return True + + if not isinstance(child_descriptor, CapaDescriptor): + return False + + return capa_type in child_descriptor.problem_types + + def get_filtered_children(self, from_block, capa_type=ANY_CAPA_TYPE_VALUE): + """ + Filters children of `from_block` that satisfy filter criteria + Returns generator containing (child_key, child) for all children matching filter criteria + """ + children = ( + (child_key, self.store.get_item(child_key, depth=9)) + for child_key in from_block.children + ) + return ( + (child_key, child) + for child_key, child in children + if self._filter_child(capa_type, child) + ) + def update_children(self, dest_block, user_id, update_db=True): """ This method is to be used when any of the libraries that a LibraryContentModule @@ -83,7 +111,7 @@ def update_children(self, dest_block, user_id, update_db=True): # First, load and validate the source_libraries: libraries = [] for library_key, old_version in dest_block.source_libraries: # pylint: disable=unused-variable - library = self._get_library(library_key) + library = self.get_library(library_key) if library is None: raise ValueError("Required library not found.") libraries.append((library_key, library)) @@ -96,13 +124,14 @@ def update_children(self, dest_block, user_id, update_db=True): new_libraries = [] for library_key, library in libraries: - def copy_children_recursively(from_block): + def copy_children_recursively(from_block, filter_problem_type=False): """ Internal method to copy blocks from the library recursively """ new_children = [] - for child_key in from_block.children: - child = self.store.get_item(child_key, depth=9) + target_capa_type = dest_block.capa_type if filter_problem_type else ANY_CAPA_TYPE_VALUE + filtered_children = self.get_filtered_children(from_block, target_capa_type) + for child_key, child in filtered_children: # We compute a block_id for each matching child block found in the library. # block_ids are unique within any branch, but are not unique per-course or globally. # We need our block_ids to be consistent when content in the library is updated, so @@ -130,7 +159,7 @@ def copy_children_recursively(from_block): ) new_children.append(new_child_info.location) return new_children - root_children.extend(copy_children_recursively(from_block=library)) + root_children.extend(copy_children_recursively(from_block=library, filter_problem_type=True)) new_libraries.append(LibraryVersionReference(library_key, library.location.library_key.version_guid)) dest_block.source_libraries = new_libraries dest_block.children = root_children diff --git a/common/test/acceptance/pages/lms/library.py b/common/test/acceptance/pages/lms/library.py index 8655fae79f55..6978b5fa0b1e 100644 --- a/common/test/acceptance/pages/lms/library.py +++ b/common/test/acceptance/pages/lms/library.py @@ -16,6 +16,9 @@ def __init__(self, browser, locator): self.locator = locator def is_browser_on_page(self): + """ + Checks if page is opened + """ return self.q(css='{}[data-id="{}"]'.format(self.BODY_SELECTOR, self.locator)).present def _bounded_selector(self, selector): @@ -35,3 +38,11 @@ def children_contents(self): """ child_blocks = self.q(css=self._bounded_selector("div[data-id]")) return frozenset(child.text for child in child_blocks) + + @property + def children_headers(self): + """ + Gets headers of all child XBlocks as list of strings + """ + child_blocks_headers = self.q(css=self._bounded_selector("div[data-id] h2.problem-header")) + return frozenset(child.text for child in child_blocks_headers) diff --git a/common/test/acceptance/pages/studio/library.py b/common/test/acceptance/pages/studio/library.py index ea7f2299f961..71df4c4ad18d 100644 --- a/common/test/acceptance/pages/studio/library.py +++ b/common/test/acceptance/pages/studio/library.py @@ -122,6 +122,7 @@ class StudioLibraryContentXBlockEditModal(CourseOutlineModal, PageObject): LIBRARY_LABEL = "Libraries" COUNT_LABEL = "Count" SCORED_LABEL = "Scored" + PROBLEM_TYPE_LABEL = "Problem Type" def is_browser_on_page(self): """ @@ -196,6 +197,24 @@ def scored(self, scored): scored_select.select_by_value(str(scored)) EmptyPromise(lambda: self.scored == scored, "scored is updated in modal.").fulfill() + @property + def capa_type(self): + """ + Gets value of CAPA type select + """ + return self.get_metadata_input(self.PROBLEM_TYPE_LABEL).get_attribute('value') + + @capa_type.setter + def capa_type(self, value): + """ + Sets value of CAPA type select + """ + select_element = self.get_metadata_input(self.PROBLEM_TYPE_LABEL) + select_element.click() + problem_type_select = Select(select_element) + problem_type_select.select_by_value(value) + EmptyPromise(lambda: self.capa_type == value, "problem type is updated in modal.").fulfill() + def _add_library_key(self): """ Adds library key input diff --git a/common/test/acceptance/tests/lms/test_library.py b/common/test/acceptance/tests/lms/test_library.py index f83e6b94e9d5..d152f4338687 100644 --- a/common/test/acceptance/tests/lms/test_library.py +++ b/common/test/acceptance/tests/lms/test_library.py @@ -19,22 +19,25 @@ UNIT_NAME = 'Test Unit' -@ddt.ddt -class LibraryContentTest(UniqueCourseTest): - """ - Test courseware. - """ +class LibraryContentTestBase(UniqueCourseTest): + """ Base class for library content block tests """ USERNAME = "STUDENT_TESTER" EMAIL = "student101@example.com" STAFF_USERNAME = "STAFF_TESTER" STAFF_EMAIL = "staff101@example.com" + def populate_library_fixture(self, library_fixture): + """ + To be overwritten by subclassed tests. Used to install a library to + run tests on. + """ + def setUp(self): """ Set up library, course and library content XBlock """ - super(LibraryContentTest, self).setUp() + super(LibraryContentTestBase, self).setUp() self.courseware_page = CoursewarePage(self.browser, self.course_id) @@ -46,11 +49,7 @@ def setUp(self): ) self.library_fixture = LibraryFixture('test_org', self.unique_id, 'Test Library {}'.format(self.unique_id)) - self.library_fixture.add_children( - XBlockFixtureDesc("html", "Html1", data='html1'), - XBlockFixtureDesc("html", "Html2", data='html2'), - XBlockFixtureDesc("html", "Html3", data='html3'), - ) + self.populate_library_fixture(self.library_fixture) self.library_fixture.install() self.library_info = self.library_fixture.library_info @@ -83,7 +82,7 @@ def setUp(self): self.course_fixture.install() - def _refresh_library_content_children(self, count=1): + def _change_library_content_settings(self, count=1, capa_type=None): """ Performs library block refresh in Studio, configuring it to show {count} children """ @@ -91,6 +90,8 @@ def _refresh_library_content_children(self, count=1): library_container_block = StudioLibraryContainerXBlockWrapper.from_xblock_wrapper(unit_page.xblocks[0]) modal = StudioLibraryContentXBlockEditModal(library_container_block.edit()) modal.count = count + if capa_type is not None: + modal.capa_type = capa_type library_container_block.save_settings() self._go_to_unit_page(change_login=False) unit_page.wait_for_page() @@ -121,9 +122,13 @@ def _goto_library_block_page(self, block_id=None): Open library page in LMS """ self.courseware_page.visit() + paragraphs = self.courseware_page.q(css='.course-content p') + if paragraphs and "You were most recently in" in paragraphs.text[0]: + paragraphs[0].find_element_by_tag_name('a').click() block_id = block_id if block_id is not None else self.lib_block.locator #pylint: disable=attribute-defined-outside-init self.library_content_page = LibraryContentXBlockWrapper(self.browser, block_id) + self.library_content_page.wait_for_page() def _auto_auth(self, username, email, staff): """ @@ -132,6 +137,22 @@ def _auto_auth(self, username, email, staff): AutoAuthPage(self.browser, username=username, email=email, course_id=self.course_id, staff=staff).visit() + +@ddt.ddt +class LibraryContentTest(LibraryContentTestBase): + """ + Test courseware. + """ + def populate_library_fixture(self, library_fixture): + """ + Populates library fixture with XBlock Fixtures + """ + library_fixture.add_children( + XBlockFixtureDesc("html", "Html1", data='html1'), + XBlockFixtureDesc("html", "Html2", data='html2'), + XBlockFixtureDesc("html", "Html3", data='html3'), + ) + @ddt.data(1, 2, 3) def test_shows_random_xblocks_from_configured(self, count): """ @@ -143,7 +164,7 @@ def test_shows_random_xblocks_from_configured(self, count): When I go to LMS courseware page for library content xblock as student Then I can see {count} random xblocks from the library """ - self._refresh_library_content_children(count=count) + self._change_library_content_settings(count=count) self._auto_auth(self.USERNAME, self.EMAIL, False) self._goto_library_block_page() children_contents = self.library_content_page.children_contents @@ -160,9 +181,120 @@ def test_shows_all_if_max_set_to_greater_value(self): When I go to LMS courseware page for library content xblock as student Then I can see all xblocks from the library """ - self._refresh_library_content_children(count=10) + self._change_library_content_settings(count=10) self._auto_auth(self.USERNAME, self.EMAIL, False) self._goto_library_block_page() children_contents = self.library_content_page.children_contents self.assertEqual(len(children_contents), 3) self.assertEqual(children_contents, self.library_xblocks_texts) + + +@ddt.ddt +class StudioLibraryContainerCapaFilterTest(LibraryContentTestBase): + """ + Test Library Content block in LMS + """ + def _get_problem_choice_group_text(self, name, items): + """ Generates Choice Group CAPA problem XML """ + items_text = "\n".join([ + "{item}".format(correct=correct, item=item) + for item, correct in items + ]) + + return """ +

{name}

+ + {items} + +
""".format(name=name, items=items_text) + + def _get_problem_select_text(self, name, items, correct): + """ Generates Select Option CAPA problem XML """ + items_text = ",".join(["'{0}'".format(item) for item in items]) + + return """ +

{name}

+ + + +
""".format(name=name, options=items_text, correct=correct) + + def populate_library_fixture(self, library_fixture): + """ + Populates library fixture with XBlock Fixtures + """ + library_fixture.add_children( + XBlockFixtureDesc( + "problem", "Problem Choice Group 1", + data=self._get_problem_choice_group_text("Problem Choice Group 1 Text", [("1", False), ('2', True)]) + ), + XBlockFixtureDesc( + "problem", "Problem Choice Group 2", + data=self._get_problem_choice_group_text("Problem Choice Group 2 Text", [("Q", True), ('W', False)]) + ), + XBlockFixtureDesc( + "problem", "Problem Select 1", + data=self._get_problem_select_text("Problem Select 1 Text", ["Option 1", "Option 2"], "Option 1") + ), + XBlockFixtureDesc( + "problem", "Problem Select 2", + data=self._get_problem_select_text("Problem Select 2 Text", ["Option 3", "Option 4"], "Option 4") + ), + ) + + @property + def _problem_headers(self): + """ Expected XBLock headers according to populate_library_fixture """ + return frozenset(child.display_name.upper() for child in self.library_fixture.children) + + def _set_library_content_settings(self, count=1, capa_type="Any Type"): + """ + Sets library content XBlock parameters, saves, publishes unit, goes to LMS unit page and + gets children XBlock headers to assert against them + """ + self._change_library_content_settings(count=count, capa_type=capa_type) + self._auto_auth(self.USERNAME, self.EMAIL, False) + self._goto_library_block_page() + return self.library_content_page.children_headers + + def test_problem_type_selector(self): + """ + Scenario: Ensure setting "Any Type" for Problem Type does not filter out Problems + Given I have a library with two "Select Option" and two "Choice Group" problems, and a course containing + LibraryContent XBlock configured to draw XBlocks from that library + When I set library content xblock Problem Type to "Any Type" and Count to 3 and publish unit + When I go to LMS courseware page for library content xblock as student + Then I can see 3 xblocks from the library of any type + When I set library content xblock Problem Type to "Choice Group" and Count to 1 and publish unit + When I go to LMS courseware page for library content xblock as student + Then I can see 1 xblock from the library of "Choice Group" type + When I set library content xblock Problem Type to "Select Option" and Count to 2 and publish unit + When I go to LMS courseware page for library content xblock as student + Then I can see 2 xblock from the library of "Select Option" type + When I set library content xblock Problem Type to "Matlab" and Count to 2 and publish unit + When I go to LMS courseware page for library content xblock as student + Then I can see 0 xblocks from the library + """ + children_headers = self._set_library_content_settings(count=3, capa_type="Any Type") + self.assertEqual(len(children_headers), 3) + self.assertLessEqual(children_headers, self._problem_headers) + + # Choice group test + children_headers = self._set_library_content_settings(count=1, capa_type="Multiple Choice") + self.assertEqual(len(children_headers), 1) + self.assertLessEqual( + children_headers, + set([header.upper() for header in ["Problem Choice Group 1", "Problem Choice Group 2"]]) + ) + + # Choice group test + children_headers = self._set_library_content_settings(count=2, capa_type="Dropdown") + self.assertEqual(len(children_headers), 2) + self.assertLessEqual( + children_headers, + set([header.upper() for header in ["Problem Select 1", "Problem Select 2"]]) + ) + + # Missing problem type test + children_headers = self._set_library_content_settings(count=2, capa_type="Custom Evaluated Script") + self.assertEqual(children_headers, set()) diff --git a/common/test/acceptance/tests/studio/test_studio_library_container.py b/common/test/acceptance/tests/studio/test_studio_library_container.py index d7a592c79fce..1521b54cbc00 100644 --- a/common/test/acceptance/tests/studio/test_studio_library_container.py +++ b/common/test/acceptance/tests/studio/test_studio_library_container.py @@ -33,6 +33,14 @@ def populate_library_fixture(self, library_fixture): XBlockFixtureDesc("html", "Html1"), XBlockFixtureDesc("html", "Html2"), XBlockFixtureDesc("html", "Html3"), + + XBlockFixtureDesc( + "problem", "Dropdown", + data=""" + +

Dropdown

+ +
""") ) def populate_course_fixture(self, course_fixture): @@ -162,3 +170,64 @@ def test_out_of_date_message(self): self.assertFalse(library_block.has_validation_message) #self.assertIn("4 matching components", library_block.author_content) # Removed this assert until a summary message is added back to the author view (SOL-192) + + def test_no_content_message(self): + """ + Scenario: Given I have a library, a course and library content xblock in a course + When I go to studio unit page for library content block + And I set Problem Type selector so that no libraries have matching content + Then I can see that "No matching content" warning is shown + When I set Problem Type selector so that there are matching content + Then I can see that warning messages are not shown + """ + expected_text = 'There are no matching problem types in the specified libraries. Select another problem type' + + library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) + + # precondition check - assert library has children matching filter criteria + self.assertFalse(library_container.has_validation_error) + self.assertFalse(library_container.has_validation_warning) + + edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit()) + self.assertEqual(edit_modal.capa_type, "Any Type") # precondition check + edit_modal.capa_type = "Custom Evaluated Script" + + library_container.save_settings() + + self.assertTrue(library_container.has_validation_warning) + self.assertIn(expected_text, library_container.validation_warning_text) + + edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit()) + self.assertEqual(edit_modal.capa_type, "Custom Evaluated Script") # precondition check + edit_modal.capa_type = "Dropdown" + library_container.save_settings() + + # Library should contain single Dropdown problem, so now there should be no errors again + self.assertFalse(library_container.has_validation_error) + self.assertFalse(library_container.has_validation_warning) + + def test_not_enough_children_blocks(self): + """ + Scenario: Given I have a library, a course and library content xblock in a course + When I go to studio unit page for library content block + And I set Problem Type selector so "Any" + Then I can see that "No matching content" warning is shown + """ + expected_tpl = "The specified libraries are configured to fetch {count} problems, " \ + "but there are only {actual} matching problems." + + library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) + + # precondition check - assert block is configured fine + self.assertFalse(library_container.has_validation_error) + self.assertFalse(library_container.has_validation_warning) + + edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit()) + edit_modal.count = 50 + library_container.save_settings() + + self.assertTrue(library_container.has_validation_warning) + self.assertIn( + expected_tpl.format(count=50, actual=len(self.library_fixture.children)), + library_container.validation_warning_text + )