"""
Unit tests for ItemBankBlock.
"""
from unittest.mock import MagicMock, Mock, patch
from random import Random
import ddt
from fs.memoryfs import MemoryFS
from lxml import etree
from rest_framework import status
from web_fragments.fragment import Fragment
from xblock.runtime import Runtime as VanillaRuntime
from openedx.core.djangolib.testing.utils import skip_unless_lms, skip_unless_cms
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.utils import MixedSplitTestCase
from xmodule.tests import prepare_block_runtime
from xmodule.validation import StudioValidationMessage
from xmodule.x_module import AUTHOR_VIEW
from xmodule.capa_block import ProblemBlock
from common.djangoapps.student.tests.factories import UserFactory
from ..item_bank_block import ItemBankBlock
from .test_course_block import DummySystem as TestImportSystem
dummy_render = lambda block, _: Fragment(block.data) # pylint: disable=invalid-name
class ItemBankTestBase(MixedSplitTestCase):
"""
Base class for tests of ItemBankBlock
"""
maxDiff = None # We need all the diff we can get for some of these asserts.
def setUp(self):
super().setUp()
self.user_id = UserFactory().id
self.course = CourseFactory.create(modulestore=self.store)
self.chapter = self.make_block(category="chapter", parent_block=self.course, publish_item=True)
self.sequential = self.make_block(category="sequential", parent_block=self.chapter, publish_item=True)
self.vertical = self.make_block(category="vertical", parent_block=self.sequential, publish_item=True)
self.item_bank = self.make_block(
category="itembank", parent_block=self.vertical, max_count=1, display_name="My Item Bank", publish_item=True
)
self.items = [
self.make_block(
category="problem", parent_block=self.item_bank, display_name=f"My Item {i}",
data=f"
Hello world from problem {i}
",
publish_item=True,
)
for i in range(4)
]
self.publisher = Mock() # for tests that look at analytics
self._reload_item_bank()
def _bind_course_block(self, block):
"""
Bind a block (part of self.course) so we can access student-specific data.
(Not clear if this is necessary since XModules are all removed now. It's possible that this
could be removed without breaking tests.)
"""
prepare_block_runtime(block.runtime, course_id=block.context_key)
def get_block(descriptor):
"""Mocks module_system get_block function"""
prepare_block_runtime(descriptor.runtime, course_id=block.context_key)
descriptor.runtime.get_block_for_descriptor = get_block
descriptor.bind_for_student(self.user_id)
return descriptor
block.runtime.get_block_for_descriptor = get_block
def _reload_item_bank(self):
"""
Reload self.item_bank. Do this if you want its `.children` list to be updated with what's in the db.
HACK: These test cases don't persist student state, but some tests need `selected` to persist betweem item_bank
reloads. So, we just transfer it from the old item_bank object instance to the new one, as if it were persisted.
"""
selected = self.item_bank.selected
self.item_bank = self.store.get_item(self.item_bank.usage_key)
self._bind_course_block(self.item_bank)
if selected:
self.item_bank.selected = selected
self.item_bank.runtime.publish = self.publisher
@skip_unless_cms
class TestItemBankForCms(ItemBankTestBase):
"""
Test Studio ItemBank behaviors -- export/import, validation, author-facing views.
"""
def test_xml_export_import_cycle(self):
"""
Test the export-import cycle.
"""
# Export self.item_bank to the virtual filesystem
export_fs = MemoryFS()
self.item_bank.runtime.export_fs = export_fs # pylint: disable=protected-access
node = etree.Element("unknown_root")
self.item_bank.add_xml_to_node(node)
# Read back the itembank OLX
with export_fs.open('{dir}/{file_name}.xml'.format(
dir=self.item_bank.scope_ids.usage_id.block_type,
file_name=self.item_bank.scope_ids.usage_id.block_id
)) as f:
actual_olx_export = f.read()
# And compare.
expected_olx_export = (
'\n'
' \n'
' \n'
' \n'
' \n'
'\n'
)
assert actual_olx_export == expected_olx_export
olx_element = etree.fromstring(actual_olx_export)
# Re-import the OLX.
runtime = TestImportSystem(load_error_blocks=True, course_id=self.item_bank.context_key)
runtime.resources_fs = export_fs
imported_item_bank = ItemBankBlock.parse_xml(olx_element, runtime, None)
# And make sure the result looks right.
self._verify_xblock_properties(imported_item_bank)
def _verify_xblock_properties(self, imported_item_bank):
"""
Check the new XBlock has the same properties as the old one.
"""
assert imported_item_bank.display_name == self.item_bank.display_name
assert imported_item_bank.max_count == self.item_bank.max_count
assert len(imported_item_bank.children) == len(self.item_bank.children)
def test_max_count_validation(self):
"""
Test that the validation method of ItemBankBlocks can warn the user about problems with settings (max_count).
"""
# Ensure we're starting with clean validation
assert self.item_bank.validate()
# Ensure that setting the max_count too high (> than # of children) raises a validation warning.
self.item_bank.max_count = 50
assert len(self.item_bank.selected_children()) == 4
assert not (result := self.item_bank.validate())
assert StudioValidationMessage.WARNING == result.summary.type
assert 'configured to show 50 problems, but only 4 have been selected' in result.summary.text
# Now set max_count to valid value (<= than # of children), and ensure the validation error goes away.
self.item_bank.max_count = 3
assert len(self.item_bank.selected_children()) == 3
assert self.item_bank.validate()
# Ensure that setting max_count to 0 raises a validation error.
self.item_bank.max_count = 0
assert len(self.item_bank.selected_children()) == 0
assert not (result := self.item_bank.validate())
assert StudioValidationMessage.ERROR == result.summary.type
assert 'configured to show 0 problems. Please specify' in result.summary.text
# Finally, set max_count to -1, and ensure the validation error goes away.
self.item_bank.max_count = -1
assert len(self.item_bank.selected_children()) == 4
assert self.item_bank.validate()
@patch(
'xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render',
VanillaRuntime.render,
)
@patch('xmodule.capa_block.ProblemBlock.author_view', dummy_render, create=True)
@patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: [])
def test_preview_view(self):
""" Test preview view rendering """
self._bind_course_block(self.item_bank)
rendered = self.item_bank.render(AUTHOR_VIEW, {'root_xblock': self.item_bank})
assert 'Hello world from problem 0
' in rendered.content
assert 'Hello world from problem 1
' in rendered.content
assert 'Hello world from problem 2
' in rendered.content
assert 'Hello world from problem 3
' in rendered.content
@patch(
'xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render',
VanillaRuntime.render,
)
@patch('xmodule.capa_block.ProblemBlock.author_view', dummy_render, create=True)
@patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: [])
def test_author_view(self):
""" Test author view rendering """
self._bind_course_block(self.item_bank)
rendered = self.item_bank.render(AUTHOR_VIEW, {})
assert 'Learners will see 1 of the 4 selected components' in rendered.content
assert 'My Item 0' in rendered.content
assert 'My Item 1' in rendered.content
assert 'My Item 2' in rendered.content
assert 'My Item 3' in rendered.content
@skip_unless_lms
@ddt.ddt
class TestItemBankForLms(ItemBankTestBase):
"""
Test LMS ItemBank features: selection, analytics, resetting problems.
"""
def _assert_event_was_published(self, event_type):
"""
Check that a LegacyLibraryContentBlock analytics event was published by self.item_bank.
"""
assert self.publisher.called
assert len(self.publisher.call_args[0]) == 3 # pylint:disable=unsubscriptable-object
_, event_name, event_data = self.publisher.call_args[0] # pylint:disable=unsubscriptable-object
assert event_name == f'edx.itembankblock.content.{event_type}'
assert event_data['location'] == str(self.item_bank.usage_key)
return event_data
def test_children_seen_by_a_user(self):
"""
Test that each student sees only one block as a child of the LibraryContent block.
"""
self._bind_course_block(self.item_bank)
# Make sure the runtime knows that the block's children vary per-user:
assert self.item_bank.has_dynamic_children()
assert len(self.item_bank.children) == len(self.items)
# Check how many children each user will see:
assert len(self.item_bank.get_child_blocks()) == 1
# Check that get_content_titles() doesn't return titles for hidden/unused children
assert len(self.item_bank.get_content_titles()) == 1
def test_overlimit_blocks_chosen_randomly(self):
"""
Tests that blocks to remove from selected children are chosen
randomly when len(selected) > max_count.
"""
blocks_seen = set()
total_tries, max_tries = 0, 100
self._bind_course_block(self.item_bank)
# Eventually, we should see every child block selected
while len(blocks_seen) != len(self.items):
self._change_count_and_reselect_children(len(self.items))
# Now set the number of selections to 1
selected = self._change_count_and_reselect_children(1)
blocks_seen.update(selected)
total_tries += 1
if total_tries >= max_tries:
# The chance that this happens by accident is (4 * (3/4)^100) ~= 1/10^12
assert False, "Max tries exceeded before seeing all blocks."
break
def _change_count_and_reselect_children(self, count):
"""
Helper method that changes the max_count of self.item_bank, reselects
children, and asserts that the number of selected children equals the count provided.
"""
self.item_bank.max_count = count
selected = self.item_bank.get_child_blocks()
assert len(selected) == count
return selected
@ddt.data(
# User resets selected children with reset button on content block
(True, 5),
# User resets selected children without reset button on content block
(False, 5),
# User resets selected children with reset button on content block when all library blocks should be selected.
(True, -1),
)
@ddt.unpack
def test_reset_selected_children_capa_blocks(self, allow_resetting_children, max_count):
"""
Tests that the `reset_selected_children` method of a content block resets only
XBlocks that have a `reset_problem` attribute when `allow_resetting_children` is True
This test block has 4 HTML XBlocks and 4 Problem XBlocks. Therefore, if we ensure
that the `reset_problem` has been called len(self.problem_types) times, then
it means that this is working correctly
"""
# Add a non-ProblemBlock just to make sure that this setting doesn't break with it.
self.make_block(category="html", parent_block=self.item_bank)
self._reload_item_bank()
self.item_bank.allow_resetting_children = allow_resetting_children
self.item_bank.max_count = max_count
# Mock the student view to return an empty dict to be returned as response
self.item_bank.student_view = MagicMock()
self.item_bank.student_view.return_value.content = {}
with patch.object(ProblemBlock, 'reset_problem', return_value={'success': True}) as reset_problem:
response = self.item_bank.reset_selected_children(None, None)
if allow_resetting_children:
self.item_bank.student_view.assert_called_once_with({})
assert reset_problem.call_count == 4 # the # of problems in self.items
assert response.status_code == status.HTTP_200_OK
assert response.content_type == "text/html"
assert response.body == b"{}"
else:
reset_problem.assert_not_called()
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_assigned_event(self):
"""
Test the "assigned" event emitted when a student is assigned specific blocks.
"""
# In the beginning was the itembank and it assigned one child to the student:
child = self.item_bank.get_child_blocks()[0]
event_data = self._assert_event_was_published("assigned")
block_info = {
"usage_key": str(child.usage_key),
}
assert event_data ==\
{'location': str(self.item_bank.usage_key),
'added': [block_info],
'result': [block_info],
'previous_count': 0, 'max_count': 1}
self.publisher.reset_mock()
# Now increase max_count so that one more child will be added:
self.item_bank.max_count = 2
children = self.item_bank.get_child_blocks()
assert len(children) == 2
child, new_child = children if children[0].usage_key == child.usage_key else reversed(children)
event_data = self._assert_event_was_published("assigned")
assert event_data['added'][0]['usage_key'] == str(new_child.usage_key)
assert len(event_data['result']) == 2
assert event_data['previous_count'] == 1
assert event_data['max_count'] == 2
def test_removed_overlimit(self):
"""
Test the "removed" event emitted when we un-assign blocks previously assigned to a student.
We go from one blocks assigned to none because max_count has been decreased.
"""
# Decrease max_count to 1, causing the block to be overlimit:
self.item_bank.get_child_blocks() # This line is needed in the test environment or the change has no effect
self.publisher.reset_mock() # Clear the "assigned" event that was just published.
self.item_bank.max_count = 0
# Check that the event says that one block was removed, leaving no blocks left:
children = self.item_bank.get_child_blocks()
assert len(children) == 0
event_data = self._assert_event_was_published("removed")
assert len(event_data['removed']) == 1
assert event_data['result'] == []
assert event_data['reason'] == 'overlimit'
@ddt.data(
(
[("problem", "My_Item_1"), ("problem", "My_Item_2")],
("problem", "My_Item_1"),
[("problem", "My_Item_2"), ("problem", "My_Item_3")],
),
(
[("problem", "My_Item_1"), ("problem", "My_Item_2")],
("problem", "My_Item_2"),
[("problem", "My_Item_1"), ("problem", "My_Item_3")],
),
(
[("problem", "My_Item_3"), ("problem", "My_Item_0")],
("problem", "My_Item_3"),
[("problem", "My_Item_2"), ("problem", "My_Item_3")],
),
(
[("problem", "My_Item_3"), ("problem", "My_Item_0")],
("problem", "My_Item_0"),
[("problem", "My_Item_3"), ("problem", "My_Item_2")],
),
)
@ddt.unpack
def test_removed_invalid(self, to_select_initial, to_drop, to_select_new):
"""
Test the "removed" event emitted when we un-assign blocks previously assigned to a student.
In this test, we keep `.max_count==2`, but do a series of two removals from `.children`.
* Initial condition: 4 children, 2 assigned. 0 [1] [2] 3
* First deletion: one of the assigned blocks, e.g. block 1.
* New condition: 3 children, 2 assigned. 0 _ [2] [3]
* Selecond deletion: the other two assigned blocks (2 and 3).
* Final condition: 1 child, 1 assigned. [0] _ _ _
The grid on the right shows how the test should go for our first ddt case.
"""
# pylint: disable=too-many-statements
# Start by assigning two blocks to the student:
self.item_bank.max_count = 2
self.store.update_item(self.item_bank, self.user_id)
# Initial selection
assert len(self.item_bank.children) == 4
children_initial = [(child.block_type, child.block_id) for child in self.item_bank.children]
with patch.object(Random, "sample", _make_mock_sample(children_initial, to_select_initial)):
with patch.object(Random, "shuffle", _make_mock_shuffle(to_select_initial)):
selected_initial = self.item_bank.selected_children()
assert len(selected_initial) == 2
self.publisher.reset_mock() # Clear the "assigned" event that was just published.
# Now make sure that one of the assigned blocks will have to be un-assigned.
# To cause an "invalid" event, we delete exactly one of the currently-assigned children:
(to_keep,) = set(selected_initial) - set([to_drop])
to_keep_usage_key = self.course.context_key.make_usage_key(*to_keep)
to_drop_usage_key = self.course.context_key.make_usage_key(*to_drop)
self.store.delete_item(to_drop_usage_key, self.user_id)
self._reload_item_bank()
assert len(self.item_bank.children) == 3 # Sanity: We had 4 blocks, we deleted 1, should be 3 left.
# Because there are 3 available children and max_count==2, when we reselect children for assignment,
# we should get 2. To maximize stability from the student's perspective, we expect that one of those children
# was the one that was previously assigned (to_keep).
remaining_selectable = set(children_initial) - {to_keep, to_drop}
to_add = set(to_select_new) & remaining_selectable
assert len(to_add) == 1 # sanity check
with patch.object(Random, "sample", _make_mock_sample(remaining_selectable, to_add)):
with patch.object(Random, "shuffle", _make_mock_shuffle(to_select_new)):
selected_new = self.item_bank.selected_children()
assert len(selected_new) == 2
assert to_keep in selected_new
assert to_drop not in selected_new
selected_new_usage_keys = [self.course.context_key.make_usage_key(*sel) for sel in selected_new]
# and, obviously, the one block that was added to the selection should be one of the remaining 3 children.
(added_usage_key,) = set(selected_new_usage_keys) - set([to_keep_usage_key])
added = (added_usage_key.block_type, added_usage_key.block_id)
assert added_usage_key in self.item_bank.children
# Check that the event says that one block was removed and one was added
assert self.publisher.call_count == 2
_, removed_event_name, removed_event_data = self.publisher.call_args_list[0][0]
assert removed_event_name == "edx.itembankblock.content.removed"
assert removed_event_data == {
"location": str(self.item_bank.usage_key),
"result": [{"usage_key": str(uk)} for uk in selected_new_usage_keys],
"previous_count": 2,
"max_count": 2,
"removed": [{"usage_key": str(to_drop_usage_key)}],
"reason": "invalid",
}
_, assigned_event_name, assigned_event_data = self.publisher.call_args_list[1][0]
assert assigned_event_name == "edx.itembankblock.content.assigned"
assert assigned_event_data == {
"location": str(self.item_bank.usage_key),
"result": [{"usage_key": str(uk)} for uk in selected_new_usage_keys],
"previous_count": 2,
"max_count": 2,
"added": [{"usage_key": str(added_usage_key)}],
}
self.publisher.reset_mock() # Clear these events
# Now drop both of the selected blocks, so that only 1 remains (less than max_count).
for selected in selected_new:
self.store.delete_item(self.course.id.make_usage_key(*selected), self.user_id)
self._reload_item_bank()
assert len(self.item_bank.children) == 1 # Sanity: We had 3 blocks, we deleted 2, should be 1 left.
# The remaining block should be one of the itembank's children, and it shouldn't be one of the ones that we had
# removed from the children.
(final,) = self.item_bank.selected_children()
final_usage_key = self.course.context_key.make_usage_key(*final)
assert final_usage_key in self.item_bank.children
assert final in children_initial
assert final not in {to_keep, to_drop, added}
# Check that the event says that two blocks were removed and one added
assert self.publisher.call_count == 2
_, removed_event_name, removed_event_data = self.publisher.call_args_list[0][0]
assert removed_event_name == "edx.itembankblock.content.removed"
assert removed_event_data == {
"location": str(self.item_bank.usage_key),
"result": [{"usage_key": str(final_usage_key)}],
"previous_count": 2,
"max_count": 2,
"removed": [{"usage_key": str(uk)} for uk in sorted(selected_new_usage_keys)],
"reason": "invalid",
}
_, assigned_event_name, assigned_event_data = self.publisher.call_args_list[1][0]
assert assigned_event_name == "edx.itembankblock.content.assigned"
assert assigned_event_data == {
"location": str(self.item_bank.usage_key),
"result": [{"usage_key": str(final_usage_key)}],
"previous_count": 1,
"max_count": 2,
"added": [{"usage_key": str(final_usage_key)}],
}
def _make_mock_sample(expected_pool, mock_sample):
"""
A replacement for Random.sample that confirms that the pool and sample size are as expected.
"""
def sample(_self, pool, desired_sample_size):
"""
The mock implementation.
"""
assert set(pool) == set(expected_pool)
assert len(mock_sample) == desired_sample_size
return mock_sample
return sample
def _make_mock_shuffle(mock_result):
"""
A replacement for Random.shuffle which confirms that the provided mock_result has the same set of items as
the selection we're shuffling.
"""
def shuffle(_self, selection):
"""
The mock implementation.
"""
assert set(selection) == set(mock_result)
return mock_result
return shuffle