Does 3 things: (1) Use django for modulestore tests (2) Use normal LMS settings for modulestore tests instead of openedx/tests/settings.py (3) Simplify some TestCase subclasses by converting them to use ModuleStoreTestCase Details and rationale: (1) Currently parts of the modulestore test suite are designed to run "without django", although there is still a lot of django functionality imported at times, and many of the tests do in fact use django. But for the upcoming PR #27565 (moving split's course indexes from MongoDB to MySQL), we will need to always have Django enabled. So this commit paves the way for that change. (2) The previous tests that did use Django used a special settings file, openedx/tests/settings.py which made some debugging confusing because those tests had quite different django settings than other tests. This change deletes that file and runs the tests using the LMS test settings. (3) The test suite also contains many different ways of initializing and testing a modulestore, with significant differences in their configuration, and also a lot of repetition. I find this makes understanding, debugging and writing tests more difficult. So this commit also reduces the number of different "test case using modulestore" base classes: * Simplifies MixedWithOptionsTestCase and MixedSplitTestCase by making them simple subclasses of ModuleStoreTestCase. * Removes PureModulestoreTestCase.
451 lines
19 KiB
Python
451 lines
19 KiB
Python
# lint-amnesty, pylint: disable=missing-module-docstring
|
|
|
|
import json
|
|
import unittest
|
|
from unittest.mock import Mock, patch
|
|
|
|
from django.conf import settings
|
|
from fs.memoryfs import MemoryFS
|
|
from lxml import etree
|
|
from opaque_keys.edx.keys import CourseKey
|
|
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
|
|
from web_fragments.fragment import Fragment
|
|
from xblock.field_data import DictFieldData
|
|
from xblock.fields import ScopeIds
|
|
|
|
from xmodule.conditional_module import ConditionalBlock
|
|
from xmodule.error_module import NonStaffErrorBlock
|
|
from xmodule.modulestore.xml import CourseLocationManager, ImportSystem, XMLModuleStore
|
|
from xmodule.tests import DATA_DIR, get_test_descriptor_system, get_test_system
|
|
from xmodule.tests.xml import XModuleXmlImportTest
|
|
from xmodule.tests.xml import factories as xml
|
|
from xmodule.validation import StudioValidationMessage
|
|
from xmodule.x_module import AUTHOR_VIEW, STUDENT_VIEW
|
|
|
|
ORG = 'test_org'
|
|
COURSE = 'conditional' # name of directory with course data
|
|
|
|
|
|
class DummySystem(ImportSystem): # lint-amnesty, pylint: disable=abstract-method, missing-class-docstring
|
|
|
|
@patch('xmodule.modulestore.xml.OSFS', lambda directory: MemoryFS())
|
|
def __init__(self, load_error_modules):
|
|
|
|
xmlstore = XMLModuleStore("data_dir", source_dirs=[], load_error_modules=load_error_modules)
|
|
|
|
super().__init__(
|
|
xmlstore=xmlstore,
|
|
course_id=CourseKey.from_string('/'.join([ORG, COURSE, 'test_run'])),
|
|
course_dir='test_dir',
|
|
error_tracker=Mock(),
|
|
load_error_modules=load_error_modules,
|
|
)
|
|
|
|
def render_template(self, template, context): # lint-amnesty, pylint: disable=method-hidden
|
|
raise Exception("Shouldn't be called")
|
|
|
|
|
|
class ConditionalBlockFactory(xml.XmlImportFactory):
|
|
"""
|
|
Factory for generating ConditionalBlock for testing purposes
|
|
"""
|
|
tag = 'conditional'
|
|
|
|
|
|
class ConditionalFactory:
|
|
"""
|
|
A helper class to create a conditional module and associated source and child modules
|
|
to allow for testing.
|
|
"""
|
|
@staticmethod
|
|
def create(system, source_is_error_module=False, source_visible_to_staff_only=False):
|
|
"""
|
|
return a dict of modules: the conditional with a single source and a single child.
|
|
Keys are 'cond_module', 'source_module', and 'child_module'.
|
|
|
|
if the source_is_error_module flag is set, create a real ErrorBlock for the source.
|
|
"""
|
|
descriptor_system = get_test_descriptor_system()
|
|
|
|
# construct source descriptor and module:
|
|
source_location = BlockUsageLocator(CourseLocator("edX", "conditional_test", "test_run", deprecated=True),
|
|
"problem", "SampleProblem", deprecated=True)
|
|
if source_is_error_module:
|
|
# Make an error descriptor and module
|
|
source_descriptor = NonStaffErrorBlock.from_xml(
|
|
'some random xml data',
|
|
system,
|
|
id_generator=CourseLocationManager(source_location.course_key),
|
|
error_msg='random error message'
|
|
)
|
|
else:
|
|
source_descriptor = Mock(name='source_descriptor')
|
|
source_descriptor.location = source_location
|
|
|
|
source_descriptor.visible_to_staff_only = source_visible_to_staff_only
|
|
source_descriptor.runtime = descriptor_system
|
|
source_descriptor.render = lambda view, context=None: descriptor_system.render(source_descriptor, view, context)
|
|
|
|
# construct other descriptors:
|
|
child_descriptor = Mock(name='child_descriptor')
|
|
child_descriptor.visible_to_staff_only = False
|
|
child_descriptor._xmodule.student_view.return_value = Fragment(content='<p>This is a secret</p>') # lint-amnesty, pylint: disable=protected-access
|
|
child_descriptor.student_view = child_descriptor._xmodule.student_view # lint-amnesty, pylint: disable=protected-access
|
|
child_descriptor.displayable_items.return_value = [child_descriptor]
|
|
child_descriptor.runtime = descriptor_system
|
|
child_descriptor.xmodule_runtime = get_test_system()
|
|
child_descriptor.render = lambda view, context=None: descriptor_system.render(child_descriptor, view, context)
|
|
child_descriptor.location = source_location.replace(category='html', name='child')
|
|
|
|
def visible_to_nonstaff_users(desc):
|
|
"""
|
|
Returns if the object is visible to nonstaff users.
|
|
"""
|
|
return not desc.visible_to_staff_only
|
|
|
|
def load_item(usage_id, for_parent=None): # pylint: disable=unused-argument
|
|
"""Test-only implementation of load_item that simply returns static xblocks."""
|
|
return {
|
|
child_descriptor.location: child_descriptor,
|
|
source_location: source_descriptor
|
|
}.get(usage_id)
|
|
|
|
descriptor_system.load_item = load_item
|
|
|
|
system.descriptor_runtime = descriptor_system
|
|
|
|
# construct conditional module:
|
|
cond_location = BlockUsageLocator(CourseLocator("edX", "conditional_test", "test_run", deprecated=True),
|
|
"conditional", "SampleConditional", deprecated=True)
|
|
field_data = DictFieldData({
|
|
'data': '<conditional/>',
|
|
'conditional_attr': 'attempted',
|
|
'conditional_value': 'true',
|
|
'xml_attributes': {'attempted': 'true'},
|
|
'children': [child_descriptor.location],
|
|
})
|
|
|
|
cond_descriptor = ConditionalBlock(
|
|
descriptor_system,
|
|
field_data,
|
|
ScopeIds(None, None, cond_location, cond_location)
|
|
)
|
|
cond_descriptor.xmodule_runtime = system
|
|
system.get_module = lambda desc: desc if visible_to_nonstaff_users(desc) else None
|
|
cond_descriptor.get_required_blocks = [
|
|
system.get_module(source_descriptor),
|
|
]
|
|
|
|
# return dict:
|
|
return {'cond_module': cond_descriptor,
|
|
'source_module': source_descriptor,
|
|
'child_module': child_descriptor}
|
|
|
|
|
|
class ConditionalBlockBasicTest(unittest.TestCase):
|
|
"""
|
|
Make sure that conditional module works, using mocks for
|
|
other modules.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.test_system = get_test_system()
|
|
|
|
def test_icon_class(self):
|
|
'''verify that get_icon_class works independent of condition satisfaction'''
|
|
modules = ConditionalFactory.create(self.test_system)
|
|
for attempted in ["false", "true"]:
|
|
for icon_class in ['other', 'problem', 'video']:
|
|
modules['source_module'].is_attempted = attempted
|
|
modules['child_module'].get_icon_class = lambda: icon_class # lint-amnesty, pylint: disable=cell-var-from-loop
|
|
assert modules['cond_module'].get_icon_class() == icon_class
|
|
|
|
def test_get_html(self):
|
|
modules = ConditionalFactory.create(self.test_system)
|
|
# because get_test_system returns the repr of the context dict passed to render_template,
|
|
# we reverse it here
|
|
html = modules['cond_module'].render(STUDENT_VIEW).content
|
|
expected = modules['cond_module'].xmodule_runtime.render_template('conditional_ajax.html', {
|
|
'ajax_url': modules['cond_module'].ajax_url,
|
|
'element_id': 'i4x-edX-conditional_test-conditional-SampleConditional',
|
|
'depends': 'i4x-edX-conditional_test-problem-SampleProblem',
|
|
})
|
|
assert expected == html
|
|
|
|
def test_handle_ajax(self):
|
|
modules = ConditionalFactory.create(self.test_system)
|
|
modules['cond_module'].save()
|
|
modules['source_module'].is_attempted = "false"
|
|
ajax = json.loads(modules['cond_module'].handle_ajax('', ''))
|
|
print("ajax: ", ajax)
|
|
fragments = ajax['fragments']
|
|
assert not any(('This is a secret' in item['content']) for item in fragments)
|
|
|
|
# now change state of the capa problem to make it completed
|
|
modules['source_module'].is_attempted = "true"
|
|
ajax = json.loads(modules['cond_module'].handle_ajax('', ''))
|
|
modules['cond_module'].save()
|
|
print("post-attempt ajax: ", ajax)
|
|
fragments = ajax['fragments']
|
|
assert any(('This is a secret' in item['content']) for item in fragments)
|
|
|
|
def test_error_as_source(self):
|
|
'''
|
|
Check that handle_ajax works properly if the source is really an ErrorBlock,
|
|
and that the condition is not satisfied.
|
|
'''
|
|
modules = ConditionalFactory.create(self.test_system, source_is_error_module=True)
|
|
modules['cond_module'].save()
|
|
ajax = json.loads(modules['cond_module'].handle_ajax('', ''))
|
|
fragments = ajax['fragments']
|
|
assert not any(('This is a secret' in item['content']) for item in fragments)
|
|
|
|
@patch('xmodule.conditional_module.log')
|
|
def test_conditional_with_staff_only_source_module(self, mock_log):
|
|
modules = ConditionalFactory.create(
|
|
self.test_system,
|
|
source_visible_to_staff_only=True,
|
|
)
|
|
cond_module = modules['cond_module']
|
|
cond_module.save()
|
|
cond_module.is_attempted = "false"
|
|
cond_module.handle_ajax('', '')
|
|
assert not mock_log.warn.called
|
|
assert None in cond_module.get_required_blocks
|
|
|
|
|
|
class ConditionalBlockXmlTest(unittest.TestCase):
|
|
"""
|
|
Make sure ConditionalBlock works, by loading data in from an XML-defined course.
|
|
"""
|
|
|
|
@staticmethod
|
|
def get_system(load_error_modules=True):
|
|
'''Get a dummy system'''
|
|
return DummySystem(load_error_modules)
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.test_system = get_test_system()
|
|
|
|
def get_course(self, name):
|
|
"""Get a test course by directory name. If there's more than one, error."""
|
|
print(f"Importing {name}")
|
|
|
|
modulestore = XMLModuleStore(DATA_DIR, source_dirs=[name])
|
|
courses = modulestore.get_courses()
|
|
self.modulestore = modulestore # lint-amnesty, pylint: disable=attribute-defined-outside-init
|
|
assert len(courses) == 1
|
|
return courses[0]
|
|
|
|
@patch('xmodule.x_module.descriptor_global_local_resource_url')
|
|
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': False})
|
|
def test_conditional_module(self, _):
|
|
"""Make sure that conditional module works"""
|
|
|
|
print("Starting import")
|
|
course = self.get_course('conditional_and_poll')
|
|
|
|
print("Course: ", course)
|
|
print("id: ", course.id)
|
|
|
|
def inner_get_module(descriptor):
|
|
if isinstance(descriptor, BlockUsageLocator):
|
|
location = descriptor
|
|
descriptor = self.modulestore.get_item(location, depth=None)
|
|
descriptor.xmodule_runtime = get_test_system()
|
|
descriptor.xmodule_runtime.descriptor_runtime = descriptor._runtime # pylint: disable=protected-access
|
|
descriptor.xmodule_runtime.get_module = inner_get_module
|
|
return descriptor
|
|
|
|
# edx - HarvardX
|
|
# cond_test - ER22x
|
|
location = BlockUsageLocator(CourseLocator("HarvardX", "ER22x", "2013_Spring", deprecated=True),
|
|
"conditional", "condone", deprecated=True)
|
|
|
|
def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None): # lint-amnesty, pylint: disable=unused-argument
|
|
return text
|
|
self.test_system.replace_urls = replace_urls
|
|
self.test_system.get_module = inner_get_module
|
|
|
|
module = inner_get_module(location)
|
|
print("module: ", module)
|
|
print("module children: ", module.get_children())
|
|
print("module display items (children): ", module.get_display_items())
|
|
|
|
html = module.render(STUDENT_VIEW).content
|
|
print("html type: ", type(html))
|
|
print("html: ", html)
|
|
html_expect = module.xmodule_runtime.render_template(
|
|
'conditional_ajax.html',
|
|
{
|
|
# Test ajax url is just usage-id / handler_name
|
|
'ajax_url': f'{str(location)}/xmodule_handler',
|
|
'element_id': 'i4x-HarvardX-ER22x-conditional-condone',
|
|
'depends': 'i4x-HarvardX-ER22x-problem-choiceprob'
|
|
}
|
|
)
|
|
assert html == html_expect
|
|
|
|
gdi = module.get_display_items()
|
|
print("gdi=", gdi)
|
|
|
|
ajax = json.loads(module.handle_ajax('', ''))
|
|
module.save()
|
|
print("ajax: ", ajax)
|
|
fragments = ajax['fragments']
|
|
assert not any(('This is a secret' in item['content']) for item in fragments)
|
|
|
|
# Now change state of the capa problem to make it completed
|
|
inner_module = inner_get_module(location.replace(category="problem", name='choiceprob'))
|
|
inner_module.attempts = 1
|
|
# Save our modifications to the underlying KeyValueStore so they can be persisted
|
|
inner_module.save()
|
|
|
|
ajax = json.loads(module.handle_ajax('', ''))
|
|
module.save()
|
|
print("post-attempt ajax: ", ajax)
|
|
fragments = ajax['fragments']
|
|
assert any(('This is a secret' in item['content']) for item in fragments)
|
|
|
|
maxDiff = None
|
|
|
|
def test_conditional_module_with_empty_sources_list(self):
|
|
"""
|
|
If a ConditionalBlock is initialized with an empty sources_list, we assert that the sources_list is set
|
|
via generating UsageKeys from the values in xml_attributes['sources']
|
|
"""
|
|
dummy_system = Mock()
|
|
dummy_location = BlockUsageLocator(CourseLocator("edX", "conditional_test", "test_run"),
|
|
"conditional", "SampleConditional")
|
|
dummy_scope_ids = ScopeIds(None, None, dummy_location, dummy_location)
|
|
dummy_field_data = DictFieldData({
|
|
'data': '<conditional/>',
|
|
'xml_attributes': {'sources': 'i4x://HarvardX/ER22x/poll_question/T15_poll'},
|
|
'children': None,
|
|
})
|
|
conditional = ConditionalBlock(
|
|
dummy_system,
|
|
dummy_field_data,
|
|
dummy_scope_ids,
|
|
)
|
|
|
|
new_run = conditional.location.course_key.run # lint-amnesty, pylint: disable=unused-variable
|
|
assert conditional.sources_list[0] == BlockUsageLocator.from_string(conditional.xml_attributes['sources'])\
|
|
.replace(run=dummy_location.course_key.run)
|
|
|
|
def test_conditional_module_parse_sources(self):
|
|
dummy_system = Mock()
|
|
dummy_location = BlockUsageLocator(CourseLocator("edX", "conditional_test", "test_run"),
|
|
"conditional", "SampleConditional")
|
|
dummy_scope_ids = ScopeIds(None, None, dummy_location, dummy_location)
|
|
dummy_field_data = DictFieldData({
|
|
'data': '<conditional/>',
|
|
'xml_attributes': {'sources': 'i4x://HarvardX/ER22x/poll_question/T15_poll;i4x://HarvardX/ER22x/poll_question/T16_poll'}, # lint-amnesty, pylint: disable=line-too-long
|
|
'children': None,
|
|
})
|
|
conditional = ConditionalBlock(
|
|
dummy_system,
|
|
dummy_field_data,
|
|
dummy_scope_ids,
|
|
)
|
|
assert conditional.parse_sources(conditional.xml_attributes) == ['i4x://HarvardX/ER22x/poll_question/T15_poll',
|
|
'i4x://HarvardX/ER22x/poll_question/T16_poll']
|
|
|
|
def test_conditional_module_parse_attr_values(self):
|
|
root = '<conditional attempted="false"></conditional>'
|
|
xml_object = etree.XML(root)
|
|
definition = ConditionalBlock.definition_from_xml(xml_object, Mock())[0]
|
|
expected_definition = {
|
|
'show_tag_list': [],
|
|
'conditional_attr': 'attempted',
|
|
'conditional_value': 'false',
|
|
'conditional_message': ''
|
|
}
|
|
|
|
assert definition == expected_definition
|
|
|
|
def test_presence_attributes_in_xml_attributes(self):
|
|
modules = ConditionalFactory.create(self.test_system)
|
|
modules['cond_module'].save()
|
|
modules['cond_module'].definition_to_xml(Mock())
|
|
expected_xml_attributes = {
|
|
'attempted': 'true',
|
|
'message': 'You must complete {link} before you can access this unit.',
|
|
'sources': ''
|
|
}
|
|
self.assertDictEqual(modules['cond_module'].xml_attributes, expected_xml_attributes)
|
|
|
|
|
|
class ConditionalBlockStudioTest(XModuleXmlImportTest):
|
|
"""
|
|
Unit tests for how conditional test interacts with Studio.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
course = xml.CourseFactory.build()
|
|
sequence = xml.SequenceFactory.build(parent=course)
|
|
conditional = ConditionalBlockFactory(
|
|
parent=sequence,
|
|
attribs={
|
|
'group_id_to_child': '{"0": "i4x://edX/xml_test_course/html/conditional_0"}'
|
|
}
|
|
)
|
|
xml.HtmlFactory(parent=conditional, url_name='conditional_0', text='This is a secret HTML')
|
|
|
|
self.course = self.process_xml(course)
|
|
self.sequence = self.course.get_children()[0]
|
|
self.conditional = self.sequence.get_children()[0]
|
|
|
|
self.module_system = get_test_system()
|
|
self.module_system.descriptor_runtime = self.course._runtime # pylint: disable=protected-access
|
|
|
|
user = Mock(username='ma', email='ma@edx.org', is_staff=False, is_active=True)
|
|
self.conditional.bind_for_student(
|
|
self.module_system,
|
|
user.id
|
|
)
|
|
|
|
def test_render_author_view(self,):
|
|
"""
|
|
Test the rendering of the Studio author view.
|
|
"""
|
|
|
|
def create_studio_context(root_xblock, is_unit_page):
|
|
"""
|
|
Context for rendering the studio "author_view".
|
|
"""
|
|
return {
|
|
'reorderable_items': set(),
|
|
'root_xblock': root_xblock,
|
|
'is_unit_page': is_unit_page
|
|
}
|
|
|
|
context = create_studio_context(self.conditional, False)
|
|
html = self.module_system.render(self.conditional, AUTHOR_VIEW, context).content
|
|
assert 'This is a secret HTML' in html
|
|
|
|
context = create_studio_context(self.sequence, True)
|
|
html = self.module_system.render(self.conditional, AUTHOR_VIEW, context).content
|
|
assert 'This is a secret HTML' not in html
|
|
|
|
def test_non_editable_settings(self):
|
|
"""
|
|
Test the settings that are marked as "non-editable".
|
|
"""
|
|
non_editable_metadata_fields = self.conditional.non_editable_metadata_fields
|
|
assert ConditionalBlock.due in non_editable_metadata_fields
|
|
|
|
def test_validation_messages(self):
|
|
"""
|
|
Test the validation message for a correctly configured conditional.
|
|
"""
|
|
self.conditional.sources_list = None
|
|
validation = self.conditional.validate()
|
|
assert validation.summary.text == 'This component has no source components configured yet.'
|
|
assert validation.summary.type == StudioValidationMessage.NOT_CONFIGURED
|
|
assert validation.summary.action_class == 'edit-button'
|
|
assert validation.summary.action_label == 'Configure list of sources'
|