diff --git a/xmodule/tests/test_item_bank.py b/xmodule/tests/test_item_bank.py index 3bf304aa9d4..9685af8f3ce 100644 --- a/xmodule/tests/test_item_bank.py +++ b/xmodule/tests/test_item_bank.py @@ -7,17 +7,13 @@ from unittest.mock import MagicMock, Mock, patch import ddt -from bson.objectid import ObjectId from fs.memoryfs import MemoryFS from lxml import etree -from opaque_keys.edx.locator import LibraryLocator from rest_framework import status -from search.search_engine_base import SearchEngine from web_fragments.fragment import Fragment from xblock.runtime import Runtime as VanillaRuntime -from openedx.core.djangolib.testing.utils import skip_unless_cms -from xmodule.modulestore import ModuleStoreEnum +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 @@ -32,7 +28,7 @@ dummy_render = lambda block, _: Fragment(block.data) # pylint: disable=invalid-name -class ItemBankTestMixin(MixedSplitTestCase): +class ItemBankTestBase(MixedSplitTestCase): """ Base class for tests of ItemBankBlock """ @@ -41,14 +37,18 @@ 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) - self.sequential = self.make_block(category="sequential", parent_block=self.chapter) - self.vertical = self.make_block(category="vertical", parent_block=self.sequential) + 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" + 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"Item {i}") + self.make_block( + category="problem", parent_block=self.item_bank, display_name=f"My Item {i}", + data="

Hello world from problem {i}

", + publish_item=True, + ) for i in range(4) ] self.item_bank = self.store.get_item(self.item_bank.usage_key, self.user_id) @@ -72,7 +72,7 @@ def get_block(descriptor): @skip_unless_cms -class TestItemBankExportImport(ItemBankTestMixin): +class ItemBankTest(ItemBankTestBase): """ Export and import tests for ItemBankBlock """ @@ -80,10 +80,10 @@ def setUp(self): super().setUp() self.expected_olx = ( '\n' - ' \n' - ' \n' - ' \n' - ' \n' + ' \n' + ' \n' + ' \n' + ' \n' '\n' ) @@ -150,13 +150,12 @@ def test_xml_import_with_comments(self): self._verify_xblock_properties(imported_item_bank) +@skip_unless_cms @ddt.ddt -class ItemBankTestsMore(ItemBankTestMixin): +class ItemBankChildrenTest(ItemBankTestBase): """ @@TODO """ - def setUp(self): - super().setUp() def test_children_seen_by_a_user(self): """ @@ -173,11 +172,10 @@ def test_children_seen_by_a_user(self): # Check that get_content_titles() doesn't return titles for hidden/unused children assert len(self.item_bank.get_content_titles()) == 1 - def _assert_has_only_N_matching_problems(self, result, n): assert result.summary assert StudioValidationMessage.WARNING == result.summary.type - assert f'only {n} matching problem' in result.summary.text + assert f'only {n} have been selected' in result.summary.text def test_validation_of_matching_blocks(self): """ @@ -189,8 +187,6 @@ def test_validation_of_matching_blocks(self): # Ensure we're starting wtih clean validation assert self.item_bank.validate() - return # @@TODO finish implementing this test case - # Set max_count to higher value than exists in library self.item_bank.max_count = 50 result = self.item_bank.validate() @@ -203,36 +199,7 @@ def test_validation_of_matching_blocks(self): assert self.item_bank.validate() assert len(self.item_bank.selected_children()) == 1 - # Existing problem type should pass validation - self.item_bank.capa_type = 'multiplechoiceresponse' - self._sync_lc_block_from_library() - self.item_bank.max_count = 1 - assert self.item_bank.validate() - assert len(self.item_bank.selected_children()) == 1 - - # ... unless requested more blocks than exists in library - self.item_bank.capa_type = 'multiplechoiceresponse' - self._sync_lc_block_from_library() - self.item_bank.max_count = 10 - result = self.item_bank.validate() - assert not result - self._assert_has_only_N_matching_problems(result, 1) - assert len(self.item_bank.selected_children()) == 1 - - # Missing problem type should always fail validation - self.item_bank.capa_type = 'customresponse' - self._sync_lc_block_from_library() - self.item_bank.max_count = 1 - result = self.item_bank.validate() - assert not result - # Validation fails due to at least one warning/message - assert result.summary - assert StudioValidationMessage.WARNING == result.summary.type - assert 'There are no problems in the specified library of type customresponse' in result.summary.text - assert len(self.item_bank.selected_children()) == 0 - # -1 selects all blocks from the library. - self._sync_lc_block_from_library() self.item_bank.max_count = -1 assert self.item_bank.validate() assert len(self.item_bank.selected_children()) == len(self.item_bank.children) @@ -255,6 +222,7 @@ def test_overlimit_blocks_chosen_randomly(self): 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 @@ -270,9 +238,9 @@ def _change_count_and_reselect_children(self, count): @ddt.data( # User resets selected children with reset button on content block - (True, 8), + (True, 5), # User resets selected children without reset button on content block - (False, 8), + (False, 5), # User resets selected children with reset button on content block when all library blocks should be selected. (True, -1), ) @@ -286,11 +254,13 @@ def test_reset_selected_children_capa_blocks(self, allow_resetting_children, max 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.item_bank = self.store.get_item(self.item_bank.usage_key, self.user_id) + self.item_bank.allow_resetting_children = allow_resetting_children self.item_bank.max_count = max_count - # Add some capa blocks - self._add_problems_to_library() - self._sync_lc_block_from_library(upgrade_to_latest=True) + # 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 = {} @@ -309,27 +279,23 @@ def test_reset_selected_children_capa_blocks(self, allow_resetting_children, max assert response.status_code == status.HTTP_400_BAD_REQUEST -search_index_mock = Mock(spec=SearchEngine) # pylint: disable=invalid-name - - +@skip_unless_cms @patch( 'xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render ) @patch('xmodule.html_block.HtmlBlock.author_view', dummy_render, create=True) @patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []) -class TestItemBankRender(ItemBankTestMixin): +class TestItemBankRender(ItemBankTestBase): """ Rendering unit tests for ItemBankBlock """ - def setUp(self): - super().setUp() - 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 block 1' in rendered.content + assert 'Hello world from problem 1' in rendered.content + assert 'Hello world from problem 4' in rendered.content def test_author_view(self): """ Test author view rendering """ @@ -341,7 +307,8 @@ def test_author_view(self): # but some js initialization should happen -class TestItemBankAnalytics(ItemBankTestMixin): +@skip_unless_lms +class TestItemBankAnalytics(ItemBankTestBase): """ Test analytics features of ItemBankBlock """ @@ -349,7 +316,19 @@ class TestItemBankAnalytics(ItemBankTestMixin): def setUp(self): super().setUp() self.publisher = Mock() + self._reload_item_bank() + + 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: The setup of this test case doesn't save student state. The only student state we care about is the + `.selected` list, so just save it to a temp variable and then re-apply it after reloading the block. + """ + selected = self.item_bank.selected + self.item_bank = self.store.get_item(self.item_bank.location) self._bind_course_block(self.item_bank) + self.item_bank.selected = selected self.item_bank.runtime.publish = self.publisher def _assert_event_was_published(self, event_type): @@ -367,16 +346,11 @@ def test_assigned_event(self): """ Test the "assigned" event emitted when a student is assigned specific blocks. """ - # In the beginning was the lc_block and it assigned one child to the student: + # In the beginning was the itembank and it assigned one child to the student: child = self.item_bank.get_child_blocks()[0] - child_lib_location, child_lib_version = self.store.get_block_original_usage(child.location) - assert isinstance(child_lib_version, ObjectId) event_data = self._assert_event_was_published("assigned") block_info = { "usage_key": str(child.location), - "original_usage_key": str(child_lib_location), - "original_usage_version": str(child_lib_version), - "descendants": [], } assert event_data ==\ {'location': str(self.item_bank.location), @@ -396,67 +370,6 @@ def test_assigned_event(self): assert event_data['previous_count'] == 1 assert event_data['max_count'] == 2 - def test_assigned_event_published(self): - """ - Same as test_assigned_event but uses the published branch - """ - self.store.publish(self.course.location, self.user_id) - with self.store.branch_setting(ModuleStoreEnum.Branch.published_only): - self.item_bank = self.store.get_item(self.item_bank.location) - self._bind_course_block(self.item_bank) - self.item_bank.runtime.publish = self.publisher - self.test_assigned_event() - - def test_assigned_descendants(self): - """ - Test the "assigned" event emitted includes descendant block information. - """ - # Replace the blocks in the library with a block that has descendants: - with self.store.bulk_operations(self.library.location.library_key): - self.library.children = [] - main_vertical = self.make_block("vertical", self.library) - inner_vertical = self.make_block("vertical", main_vertical) - html_block = self.make_block("html", inner_vertical) - problem_block = self.make_block("problem", inner_vertical) - - # Reload lc_block and set it up for a student: - self._sync_lc_block_from_library(upgrade_to_latest=True) - self._bind_course_block(self.item_bank) - self.item_bank.runtime.publish = self.publisher - - # Get the keys of each of our blocks, as they appear in the course: - course_usage_main_vertical = self.item_bank.children[0] - course_usage_inner_vertical = self.store.get_item(course_usage_main_vertical).children[0] - inner_vertical_in_course = self.store.get_item(course_usage_inner_vertical) - course_usage_html = inner_vertical_in_course.children[0] - course_usage_problem = inner_vertical_in_course.children[1] - - # Trigger a publish event: - self.item_bank.get_child_blocks() - event_data = self._assert_event_was_published("assigned") - - for block_list in (event_data["added"], event_data["result"]): - assert len(block_list) == 1 - # main_vertical is the only root block added, and is the only result. - assert block_list[0]['usage_key'] == str(course_usage_main_vertical) - - # Check that "descendants" is a flat, unordered list of all of main_vertical's descendants: - descendants_expected = ( - (inner_vertical.location, course_usage_inner_vertical), - (html_block.location, course_usage_html), - (problem_block.location, course_usage_problem), - ) - descendant_data_expected = {} - for lib_key, course_usage_key in descendants_expected: - descendant_data_expected[str(course_usage_key)] = { - "usage_key": str(course_usage_key), - "original_usage_key": str(lib_key), - "original_usage_version": str(self.store.get_block_original_usage(course_usage_key)[1]), - } - assert len(block_list[0]['descendants']) == len(descendant_data_expected) - for descendant in block_list[0]["descendants"]: - assert descendant == descendant_data_expected.get(descendant['usage_key']) - def test_removed_overlimit(self): """ Test the "removed" event emitted when we un-assign blocks previously assigned to a student. @@ -478,42 +391,106 @@ def test_removed_overlimit(self): def test_removed_invalid(self): """ Test the "removed" event emitted when we un-assign blocks previously assigned to a student. - We go from two blocks assigned, to one because the others have been deleted from the library. - """ + In this test, we keep `.max_count==2`, but do a series of two removals from `.children`: + + * Starting condition: 4 children, 2 assigned: A [B] [C] D + * Delete the first assigned block (B) + * New condition: 3 children, 2 assigned: A _ [C] [D] + * Delete both assigned blocks (C, D) + * Final condition: 1 child, 1 asigned: [A] _ _ _ + + """ # Start by assigning two blocks to the student: - self.item_bank.get_child_blocks() # This line is needed in the test environment or the change has no effect self.item_bank.max_count = 2 - initial_blocks_assigned = self.item_bank.get_child_blocks() - assert len(initial_blocks_assigned) == 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 all blocks from the content library - # except for one of the two already assigned to the student: - - keep_block_key = initial_blocks_assigned[0].location - keep_block_lib_usage_key, keep_block_lib_version = self.store.get_block_original_usage(keep_block_key) - assert keep_block_lib_usage_key is not None - deleted_block_key = initial_blocks_assigned[1].location - self.library.children = [keep_block_lib_usage_key] - self.store.update_item(self.library, self.user_id) self.store.update_item(self.item_bank, self.user_id) - old_selected = self.item_bank.selected - self._sync_lc_block_from_library(upgrade_to_latest=True) - self.item_bank.selected = old_selected - self.item_bank.runtime.publish = self.publisher - # Check that the event says that one block was removed, leaving one block left: - children = self.item_bank.get_child_blocks() - assert len(children) == 1 - event_data = self._assert_event_was_published("removed") - assert event_data['removed'] ==\ - [{'usage_key': str(deleted_block_key), - 'original_usage_key': None, - 'original_usage_version': None, - 'descendants': []}] - assert event_data['result'] ==\ - [{'usage_key': str(keep_block_key), - 'original_usage_key': str(keep_block_lib_usage_key), - 'original_usage_version': str(keep_block_lib_version), 'descendants': []}] - assert event_data['reason'] == 'invalid' + # Sanity check + assert len(self.item_bank.children) == 4 + children_initial = [(child.block_type, child.block_id) for child in self.item_bank.children] + selected_initial: list[tuple[str, str]] = 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: tuple[str, str] = selected_initial[0] + to_keep_usage_key = self.course.context_key.make_usage_key(*to_keep) + to_drop: tuple[str, str] = selected_initial[1] + 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). + 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.librarycontentblock.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.librarycontentblock.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.librarycontentblock.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 selected_new_usage_keys], + "reason": "invalid", + } + _, assigned_event_name, assigned_event_data = self.publisher.call_args_list[1][0] + assert assigned_event_name == "edx.librarycontentblock.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)}], + }