Files
edx-platform/xmodule/tests/test_split_test_block.py
Kyle McCormick 834cb9482d refactor: rename ModuleStore runtimes now that XModules are gone (#35523)
* Consolidates and renames the runtime used as a base for all the others:
  * Before: `xmodule.x_module:DescriptorSystem` and
            `xmodule.mako_block:MakoDescriptorSystem`.
  * After:  `xmodule.x_module:ModuleStoreRuntime`.

* Co-locates and renames the runtimes for importing course OLX:
  * Before: `xmodule.x_module:XMLParsingSystem` and
            `xmodule.modulestore.xml:ImportSystem`.
  * After:  `xmodule.modulestore.xml:XMLParsingModuleStoreRuntime` and
            `xmodule.modulestore.xml:XMLImportingModuleStoreRuntime`.
  * Note: I would have liked to consolidate these, but it would have
          involved nontrivial test refactoring.

* Renames the stub Old Mongo runtime:
  * Before: `xmodule.modulestore.mongo.base:CachingDescriptorSystem`.
  * After: `xmodule.modulestore.mongo.base:OldModuleStoreRuntime`.

* Renames the Split Mongo runtime, the which is what runs courses in LMS and CMS:
  * Before: `xmodule.modulestore.split_mongo.caching_descriptor_system:CachingDescriptorSystem`.
  * After: `xmodule.modulestore.split_mongo.runtime:SplitModuleStoreRuntime`.

* Renames some of the dummy runtimes used only in unit tests.
2025-10-29 15:46:07 -04:00

598 lines
26 KiB
Python

"""
Tests for the Split Testing Block
"""
import json
from unittest.mock import Mock, patch
import ddt
import lxml
from fs.memoryfs import MemoryFS
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.utils import MixedSplitTestCase
from xmodule.partitions.partitions import MINIMUM_UNUSED_PARTITION_ID, Group, UserPartition
from xmodule.partitions.tests.test_partitions import MockPartitionService, MockUserPartitionScheme, PartitionTestCase
from xmodule.split_test_block import (
SplitTestBlock,
SplitTestFields,
get_split_user_partitions,
user_partition_values,
)
from xmodule.tests import prepare_block_runtime
from xmodule.tests.test_course_block import DummyModuleStoreRuntime
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
class SplitTestBlockFactory(xml.XmlImportFactory):
"""
Factory for generating SplitTestBlocks for testing purposes
"""
tag = 'split_test'
class SplitTestUtilitiesTest(PartitionTestCase):
"""
Tests for utility methods related to split_test block.
"""
def test_split_user_partitions(self):
"""
Tests the get_split_user_partitions helper method.
"""
first_random_partition = UserPartition(
0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("1", 'beta')],
self.random_scheme
)
second_random_partition = UserPartition(
0, 'second_partition', 'Second Partition', [Group("4", 'zeta'), Group("5", 'omega')],
self.random_scheme
)
all_partitions = [
first_random_partition,
# Only UserPartitions with scheme "random" will be returned as available options.
UserPartition(
1, 'non_random_partition', 'Will Not Be Returned', [Group("1", 'apple'), Group("2", 'banana')],
self.non_random_scheme
),
second_random_partition
]
assert [first_random_partition, second_random_partition] == get_split_user_partitions(all_partitions)
class SplitTestBlockTest(XModuleXmlImportTest, PartitionTestCase):
"""
Base class for all split_module tests.
"""
def setUp(self):
super().setUp()
self.course_id = 'test_org/test_course_number/test_run'
# construct module
course = xml.CourseFactory.build()
sequence = xml.SequenceFactory.build(parent=course)
split_test = SplitTestBlockFactory(
parent=sequence,
attribs={
'user_partition_id': '0',
'group_id_to_child': '{"0": "i4x://edX/xml_test_course/html/split_test_cond0", "1":'
' "i4x://edX/xml_test_course/html/split_test_cond1"}'
}
)
xml.HtmlFactory(parent=split_test, url_name='split_test_cond0', text='HTML FOR GROUP 0')
xml.HtmlFactory(parent=split_test, url_name='split_test_cond1', text='HTML FOR GROUP 1')
self.course = self.process_xml(course)
self.course_sequence = self.course.get_children()[0]
user = Mock(username='ma', email='ma@edx.org', is_staff=False, is_active=True)
prepare_block_runtime(self.course.runtime, user=user, add_get_block=True)
self.course.runtime.export_fs = MemoryFS()
# Create mock partition service, as these tests are running with XML in-memory system.
self.course.user_partitions = [
self.user_partition,
UserPartition(
MINIMUM_UNUSED_PARTITION_ID, 'second_partition', 'Second Partition',
[
Group(str(MINIMUM_UNUSED_PARTITION_ID + 1), 'abel'),
Group(str(MINIMUM_UNUSED_PARTITION_ID + 2), 'baker'), Group("103", 'charlie')
],
MockUserPartitionScheme()
)
]
partitions_service = MockPartitionService(
self.course,
course_id=self.course.id,
)
self.course.runtime._services['partitions'] = partitions_service # pylint: disable=protected-access
# Mock user_service user
user_service = Mock()
user_service._django_user = user # lint-amnesty, pylint: disable=protected-access
self.split_test_block = self.course_sequence.get_children()[0]
self.split_test_block.runtime = self.course.runtime
self.split_test_block.bind_for_student(user.id)
# Create mock modulestore for getting the course. Needed for rendering the HTML
# view, since mock services exist and the rendering code will not short-circuit.
mocked_modulestore = Mock()
mocked_modulestore.get_course.return_value = self.course
self.split_test_block.runtime.modulestore = mocked_modulestore
@ddt.ddt
class SplitTestBlockLMSTest(SplitTestBlockTest):
"""
Test the split test block
"""
def setUp(self):
super().setUp()
content_gating_flag_patcher = patch(
'openedx.features.content_type_gating.partitions.ContentTypeGatingConfig.current',
return_value=Mock(enabled=False, studio_override_enabled=False),
).start()
self.addCleanup(content_gating_flag_patcher.stop)
@ddt.data((0, 'split_test_cond0'), (1, 'split_test_cond1'))
@ddt.unpack
def test_child(self, user_tag, child_url_name):
self.user_partition.scheme.current_group = self.user_partition.groups[user_tag]
assert self.split_test_block.child_block.url_name == child_url_name
@ddt.data((0, 'HTML FOR GROUP 0'), (1, 'HTML FOR GROUP 1'))
@ddt.unpack
def test_get_html(self, user_tag, child_content):
self.user_partition.scheme.current_group = self.user_partition.groups[user_tag]
assert child_content in self.course.runtime.render(self.split_test_block, STUDENT_VIEW).content
@ddt.data(0, 1)
def test_child_missing_tag_value(self, _user_tag):
# If user_tag has a missing value, we should still get back a valid child url
assert self.split_test_block.child_block.url_name in ['split_test_cond0', 'split_test_cond1']
@ddt.data(100, 200, 300, 400, 500, 600, 700, 800, 900, 1000)
def test_child_persist_new_tag_value_when_tag_missing(self, _user_tag):
# If a user_tag has a missing value, a group should be saved/persisted for that user.
# So, we check that we get the same url_name when we call on the url_name twice.
# We run the test ten times so that, if our storage is failing, we'll be most likely to notice it.
assert self.split_test_block.child_block.url_name == self.split_test_block.child_block.url_name
# Patch the definition_to_xml for the html children.
@patch('xmodule.html_block.HtmlBlock.definition_to_xml')
def test_export_import_round_trip(self, def_to_xml):
# The HtmlBlock definition_to_xml tries to write to the filesystem
# before returning an xml object. Patch this to just return the xml.
def_to_xml.return_value = lxml.etree.Element('html')
# Mock out the process_xml
# Expect it to return a child block for the SplitTestDescriptor when called.
self.course.runtime.process_xml = Mock()
# Write out the xml.
xml_obj = self.split_test_block.definition_to_xml(MemoryFS())
assert xml_obj.get('user_partition_id') == '0'
assert xml_obj.get('group_id_to_child') is not None
# Read the xml back in.
fields, children = SplitTestBlock.definition_from_xml(xml_obj, self.course.runtime)
assert fields.get('user_partition_id') == '0'
assert fields.get('group_id_to_child') is not None
assert len(children) == 2
class SplitTestBlockStudioTest(SplitTestBlockTest):
"""
Unit tests for how split test interacts with Studio.
"""
@patch('xmodule.split_test_block.SplitTestBlock.group_configuration_url', return_value='http://example.com')
def test_render_author_view(self, group_configuration_url): # lint-amnesty, pylint: disable=unused-argument
"""
Test the rendering of the Studio author view.
"""
def create_studio_context(root_xblock):
"""
Context for rendering the studio "author_view".
"""
return {
'reorderable_items': set(),
'root_xblock': root_xblock,
}
# The split_test block should render both its groups when it is the root
context = create_studio_context(self.split_test_block)
html = self.course.runtime.render(self.split_test_block, AUTHOR_VIEW, context).content
assert 'HTML FOR GROUP 0' in html
assert 'HTML FOR GROUP 1' in html
# When rendering as a child, it shouldn't render either of its groups
context = create_studio_context(self.course_sequence)
html = self.course.runtime.render(self.split_test_block, AUTHOR_VIEW, context).content
assert 'HTML FOR GROUP 0' not in html
assert 'HTML FOR GROUP 1' not in html
# The "Create Missing Groups" button should be rendered when groups are missing
context = create_studio_context(self.split_test_block)
self.split_test_block.user_partitions = [
UserPartition(0, 'first_partition', 'First Partition',
[Group("0", 'alpha'), Group("1", 'beta'), Group("2", 'gamma')])
]
html = self.course.runtime.render(self.split_test_block, AUTHOR_VIEW, context).content
assert 'HTML FOR GROUP 0' in html
assert 'HTML FOR GROUP 1' in html
def test_group_configuration_url(self):
"""
Test creation of correct Group Configuration URL.
"""
mocked_course = Mock(advanced_modules=['split_test'])
mocked_modulestore = Mock()
mocked_modulestore.get_course.return_value = mocked_course
self.split_test_block.runtime.modulestore = mocked_modulestore
self.split_test_block.user_partitions = [
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("1", 'beta')])
]
expected_url = '/group_configurations/edX/xml_test_course/101#0'
assert expected_url == self.split_test_block.group_configuration_url
def test_editable_settings(self):
"""
Test the setting information passed back from editable_metadata_fields.
"""
editable_metadata_fields = self.split_test_block.editable_metadata_fields
assert SplitTestBlock.display_name.name in editable_metadata_fields
assert SplitTestBlock.due.name not in editable_metadata_fields
assert SplitTestBlock.user_partitions.name not in editable_metadata_fields
# user_partition_id will always appear in editable_metadata_settings, regardless
# of the selected value.
assert SplitTestBlock.user_partition_id.name in editable_metadata_fields
def test_non_editable_settings(self):
"""
Test the settings that are marked as "non-editable".
"""
non_editable_metadata_fields = self.split_test_block.non_editable_metadata_fields
assert SplitTestBlock.due in non_editable_metadata_fields
assert SplitTestBlock.user_partitions in non_editable_metadata_fields
assert SplitTestBlock.display_name not in non_editable_metadata_fields
@patch('xmodule.split_test_block.user_partition_values.values')
def test_available_partitions(self, _):
"""
Tests that the available partitions are populated correctly when editable_metadata_fields are called
"""
# user_partitions is empty, only the "Not Selected" item will appear.
self.split_test_block.user_partition_id = SplitTestFields.no_partition_selected['value']
self.split_test_block.editable_metadata_fields # pylint: disable=pointless-statement
partitions = user_partition_values.values
assert 1 == len(partitions)
assert SplitTestFields.no_partition_selected['value'] == partitions[0]['value']
# Populate user_partitions and call editable_metadata_fields again
self.split_test_block.user_partitions = [
UserPartition(
0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("1", 'beta')],
self.random_scheme
),
# Only UserPartitions with scheme "random" will be returned as available options.
UserPartition(
1, 'non_random_partition', 'Will Not Be Returned', [Group("1", 'apple'), Group("2", 'banana')],
self.non_random_scheme
)
]
self.split_test_block.editable_metadata_fields # pylint: disable=pointless-statement
partitions = user_partition_values.values
assert 2 == len(partitions)
assert SplitTestFields.no_partition_selected['value'] == partitions[0]['value']
assert 0 == partitions[1]['value']
assert 'first_partition' == partitions[1]['display_name']
# Try again with a selected partition and verify that there is no option for "No Selection"
self.split_test_block.user_partition_id = 0
self.split_test_block.editable_metadata_fields # pylint: disable=pointless-statement
partitions = user_partition_values.values
assert 1 == len(partitions)
assert 0 == partitions[0]['value']
assert 'first_partition' == partitions[0]['display_name']
# Finally try again with an invalid selected partition and verify that "No Selection" is an option
self.split_test_block.user_partition_id = 999
self.split_test_block.editable_metadata_fields # pylint: disable=pointless-statement
partitions = user_partition_values.values
assert 2 == len(partitions)
assert SplitTestFields.no_partition_selected['value'] == partitions[0]['value']
assert 0 == partitions[1]['value']
assert 'first_partition' == partitions[1]['display_name']
def test_active_and_inactive_children(self):
"""
Tests the active and inactive children returned for different split test configurations.
"""
split_test_block = self.split_test_block
children = split_test_block.get_children()
# Verify that a split test has no active children if it has no specified user partition.
split_test_block.user_partition_id = -1
[active_children, inactive_children] = split_test_block.active_and_inactive_children()
assert active_children == []
assert inactive_children == children
# Verify that all the children are returned as active for a correctly configured split_test
split_test_block.user_partition_id = 0
split_test_block.user_partitions = [
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("1", 'beta')])
]
[active_children, inactive_children] = split_test_block.active_and_inactive_children()
assert active_children == children
assert inactive_children == []
# Verify that a split_test does not return inactive children in the active children
self.split_test_block.user_partitions = [
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha')])
]
[active_children, inactive_children] = split_test_block.active_and_inactive_children()
assert active_children == [children[0]]
assert inactive_children == [children[1]]
# Verify that a split_test ignores misconfigured children
self.split_test_block.user_partitions = [
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("2", 'gamma')])
]
[active_children, inactive_children] = split_test_block.active_and_inactive_children()
assert active_children == [children[0]]
assert inactive_children == [children[1]]
# Verify that a split_test referring to a non-existent user partition has no active children
self.split_test_block.user_partition_id = 2
[active_children, inactive_children] = split_test_block.active_and_inactive_children()
assert active_children == []
assert inactive_children == children
def test_validation_messages(self): # lint-amnesty, pylint: disable=too-many-statements
"""
Test the validation messages produced for different split test configurations.
"""
split_test_block = self.split_test_block
def verify_validation_message(message, expected_message, expected_message_type,
expected_action_class=None, expected_action_label=None,
expected_action_runtime_event=None):
"""
Verify that the validation message has the expected validation message and type.
"""
assert message.text == expected_message
assert message.type == expected_message_type
if expected_action_class:
assert message.action_class == expected_action_class
else:
assert not hasattr(message, 'action_class')
if expected_action_label:
assert message.action_label == expected_action_label
else:
assert not hasattr(message, 'action_label')
if expected_action_runtime_event:
assert message.action_runtime_event == expected_action_runtime_event
else:
assert not hasattr(message, 'action_runtime_event')
def verify_summary_message(general_validation, expected_message, expected_message_type):
"""
Verify that the general validation message has the expected validation message and type.
"""
assert general_validation.text == expected_message
assert general_validation.type == expected_message_type
# Verify the messages for an unconfigured user partition
split_test_block.user_partition_id = -1
validation = split_test_block.validate()
assert len(validation.messages) == 0
verify_validation_message(
validation.summary,
"The experiment is not associated with a group configuration.",
StudioValidationMessage.NOT_CONFIGURED,
'edit-button',
"Select a Group Configuration",
)
# Verify the messages for a correctly configured split_test
split_test_block.user_partition_id = 0
split_test_block.user_partitions = [
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("1", 'beta')])
]
validation = split_test_block.validate_split_test()
assert validation
assert split_test_block.general_validation_message() is None, None
# Verify the messages for a split test with too few groups
split_test_block.user_partitions = [
UserPartition(0, 'first_partition', 'First Partition',
[Group("0", 'alpha'), Group("1", 'beta'), Group("2", 'gamma')])
]
validation = split_test_block.validate()
assert len(validation.messages) == 1
verify_validation_message(
validation.messages[0],
"The experiment does not contain all of the groups in the configuration.",
StudioValidationMessage.ERROR,
expected_action_runtime_event='add-missing-groups',
expected_action_label="Add Missing Groups"
)
verify_summary_message(
validation.summary,
"This content experiment has issues that affect content visibility.",
StudioValidationMessage.ERROR
)
# Verify the messages for a split test with children that are not associated with any group
split_test_block.user_partitions = [
UserPartition(0, 'first_partition', 'First Partition',
[Group("0", 'alpha')])
]
validation = split_test_block.validate()
assert len(validation.messages) == 1
verify_validation_message(
validation.messages[0],
"The experiment has an inactive group. Move content into active groups, then delete the inactive group.",
StudioValidationMessage.WARNING
)
verify_summary_message(
validation.summary,
"This content experiment has issues that affect content visibility.",
StudioValidationMessage.WARNING
)
# Verify the messages for a split test with both missing and inactive children
split_test_block.user_partitions = [
UserPartition(0, 'first_partition', 'First Partition',
[Group("0", 'alpha'), Group("2", 'gamma')])
]
validation = split_test_block.validate()
assert len(validation.messages) == 2
verify_validation_message(
validation.messages[0],
"The experiment does not contain all of the groups in the configuration.",
StudioValidationMessage.ERROR,
expected_action_runtime_event='add-missing-groups',
expected_action_label="Add Missing Groups"
)
verify_validation_message(
validation.messages[1],
"The experiment has an inactive group. Move content into active groups, then delete the inactive group.",
StudioValidationMessage.WARNING
)
# With two messages of type error and warning priority given to error.
verify_summary_message(
validation.summary,
"This content experiment has issues that affect content visibility.",
StudioValidationMessage.ERROR
)
# Verify the messages for a split test referring to a non-existent user partition
split_test_block.user_partition_id = 2
validation = split_test_block.validate()
assert len(validation.messages) == 1
verify_validation_message(
validation.messages[0],
"The experiment uses a deleted group configuration. "
"Select a valid group configuration or delete this experiment.",
StudioValidationMessage.ERROR
)
verify_summary_message(
validation.summary,
"This content experiment has issues that affect content visibility.",
StudioValidationMessage.ERROR
)
# Verify the message for a split test referring to a non-random user partition
split_test_block.user_partitions = [
UserPartition(
10, 'incorrect_partition', 'Non Random Partition', [Group("0", 'alpha'), Group("2", 'gamma')],
scheme=self.non_random_scheme
)
]
split_test_block.user_partition_id = 10
validation = split_test_block.validate()
assert len(validation.messages) == 1
verify_validation_message(
validation.messages[0],
"The experiment uses a group configuration that is not supported for experiments. "
"Select a valid group configuration or delete this experiment.",
StudioValidationMessage.ERROR
)
verify_summary_message(
validation.summary,
"This content experiment has issues that affect content visibility.",
StudioValidationMessage.ERROR
)
class SplitTestBlockExportImportTest(MixedSplitTestCase):
"""
Export import test for split test xblock.
"""
maxDiff = None
def setUp(self):
super().setUp()
self.course = CourseFactory.create(modulestore=self.store)
self.chapter = self.make_block("chapter", self.course)
self.sequential = self.make_block("sequential", self.chapter)
self.vertical = self.make_block("vertical", self.sequential)
self.split_test_block = self.make_block(
"split_test",
self.vertical,
display_name="A Split Test",
user_partition_id=2,
)
self.child_blocks = [
self.make_block("html", self.split_test_block, display_name=f"Hello HTML {i}")
for i in range(1, 3)
]
self.split_test_block.group_id_to_child = {
'0': self.child_blocks[0].location,
'1': self.child_blocks[1].location,
}
self.store.update_item(self.split_test_block, self.user_id)
def test_xml_export_import_cycle(self):
"""
Test the export-import cycle.
"""
split_test_block = self.store.get_item(self.split_test_block.location)
expected_olx = (
'<split_test group_id_to_child="{group_id_to_child}" user_partition_id="2" display_name="A Split Test">\n'
' <html url_name="{child_blocks[0].location.block_id}"/>\n'
' <html url_name="{child_blocks[1].location.block_id}"/>\n'
'</split_test>\n'
).format(
child_blocks=self.child_blocks,
group_id_to_child=json.dumps(
{
"0": str(self.child_blocks[0].location),
"1": str(self.child_blocks[1].location),
}
).replace('"', '&quot;')
)
export_fs = MemoryFS()
# Set the virtual FS to export the olx to.
split_test_block.runtime.export_fs = export_fs # pylint: disable=protected-access
# Export the olx.
node = lxml.etree.Element("unknown_root")
split_test_block.add_xml_to_node(node)
# Read it back
with export_fs.open('{dir}/{file_name}.xml'.format(
dir=split_test_block.scope_ids.usage_id.block_type,
file_name=split_test_block.scope_ids.usage_id.block_id
)) as f:
exported_olx = f.read()
# And compare.
assert exported_olx == expected_olx
runtime = DummyModuleStoreRuntime(load_error_blocks=True, course_id=split_test_block.location.course_key)
runtime.resources_fs = export_fs
# Now import it.
olx_element = lxml.etree.fromstring(exported_olx)
imported_split_test_block = SplitTestBlock.parse_xml(olx_element, runtime, None)
# Check the new XBlock has the same properties as the old one.
assert imported_split_test_block.display_name == split_test_block.display_name
assert len(imported_split_test_block.children) == len(split_test_block.children)
assert imported_split_test_block.children == split_test_block.children
assert imported_split_test_block.user_partition_id == split_test_block.user_partition_id
assert imported_split_test_block.group_id_to_child['0'] == str(split_test_block.group_id_to_child['0'])
assert imported_split_test_block.group_id_to_child['1'] == str(split_test_block.group_id_to_child['1'])