441 lines
18 KiB
Python
441 lines
18 KiB
Python
"""
|
|
Tests for vertical block.
|
|
"""
|
|
|
|
# pylint: disable=protected-access
|
|
|
|
|
|
import json
|
|
from collections import namedtuple
|
|
from datetime import datetime, timedelta
|
|
from unittest.mock import Mock, patch
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import ddt
|
|
from django.contrib.auth.models import AnonymousUser
|
|
from django.test import override_settings
|
|
from fs.memoryfs import MemoryFS
|
|
from openedx_filters import PipelineStep
|
|
from openedx_filters.learning.filters import VerticalBlockChildRenderStarted, VerticalBlockRenderCompleted
|
|
|
|
from ..x_module import AUTHOR_VIEW, PUBLIC_VIEW, STUDENT_VIEW
|
|
from . import prepare_block_runtime
|
|
from .helpers import StubUserService
|
|
from .xml import XModuleXmlImportTest
|
|
from .xml import factories as xml
|
|
|
|
COMPLETION_DELAY = 9876
|
|
|
|
JsonRequest = namedtuple('JsonRequest', ['method', 'body'])
|
|
|
|
|
|
def get_json_request(data):
|
|
"""
|
|
Given a data dictionary, return an appropriate JSON request.
|
|
"""
|
|
return JsonRequest(
|
|
method='POST',
|
|
body=json.dumps(data),
|
|
)
|
|
|
|
|
|
class StubCompletionService:
|
|
"""
|
|
A stub implementation of the CompletionService for testing without access to django
|
|
"""
|
|
|
|
def __init__(self, enabled, completion_value):
|
|
self._enabled = enabled
|
|
self._completion_value = completion_value
|
|
self.delay = COMPLETION_DELAY
|
|
|
|
def completion_tracking_enabled(self):
|
|
"""
|
|
Turn on or off completion tracking for clients of the
|
|
StubCompletionService.
|
|
"""
|
|
return self._enabled
|
|
|
|
def get_completions(self, candidates):
|
|
"""
|
|
Return the (dummy) completion values for each specified candidate
|
|
block.
|
|
"""
|
|
return {candidate: self._completion_value for candidate in candidates}
|
|
|
|
def get_completable_children(self, node):
|
|
return node.get_children()
|
|
|
|
def get_complete_on_view_delay_ms(self):
|
|
"""
|
|
Return the completion-by-viewing delay in milliseconds.
|
|
"""
|
|
return self.delay
|
|
|
|
def blocks_to_mark_complete_on_view(self, blocks):
|
|
return {} if self._completion_value == 1.0 else blocks
|
|
|
|
def vertical_is_complete(self, item):
|
|
if item.scope_ids.block_type != 'vertical':
|
|
raise ValueError('The passed in xblock is not a vertical type!')
|
|
return self._completion_value == 1 if self._enabled else None
|
|
|
|
|
|
class TestVerticalBlockChildRenderStep(PipelineStep):
|
|
"""
|
|
Utility class for testing filters on vertical block children
|
|
"""
|
|
filter_content = "Altered Content"
|
|
|
|
def run_filter(self, block, context): # lint-amnesty, pylint: disable=arguments-differ
|
|
"""Pipeline step that changes child content"""
|
|
if type(block).__name__ == "HtmlBlockWithMixins":
|
|
block.get_html = lambda: TestVerticalBlockChildRenderStep.filter_content
|
|
return {"block": block, "context": context}
|
|
|
|
|
|
class TestPreventVerticalBlockChildRender(PipelineStep):
|
|
"""
|
|
Utility class to test vertical block children are skipped in rendering.
|
|
"""
|
|
|
|
def run_filter(self, block, context): # lint-amnesty, pylint: disable=arguments-differ
|
|
"""Pipeline step that raises exceptions during child block rendering"""
|
|
if type(block).__name__ == "HtmlBlockWithMixins":
|
|
raise VerticalBlockChildRenderStarted.PreventChildBlockRender(
|
|
"Skip block test exception"
|
|
)
|
|
|
|
|
|
class TestVerticalBlockRenderCompletedStep(PipelineStep):
|
|
"""
|
|
Utility class for testing filters on vertical block render completion
|
|
"""
|
|
filter_content = "Extra content added"
|
|
|
|
def run_filter(self, block, fragment, context, view): # lint-amnesty, pylint: disable=arguments-differ
|
|
"""Pipeline step that alters the output of the fragment"""
|
|
fragment.content += TestVerticalBlockRenderCompletedStep.filter_content
|
|
return {
|
|
"block": block,
|
|
"fragment": fragment,
|
|
"context": context,
|
|
"view": view
|
|
}
|
|
|
|
|
|
class TestPreventVerticalBlockRenderStep(PipelineStep):
|
|
"""
|
|
Utility class for testing VerticalBlock render can be stopped.
|
|
"""
|
|
filter_content = "<div class=\"alert alert-danger\">Assignments are not available for Audit students.<div>"
|
|
|
|
def run_filter(self, block, fragment, context, view): # lint-amnesty, pylint: disable=arguments-differ
|
|
"""Pipeline step that raises an exception"""
|
|
raise VerticalBlockRenderCompleted.PreventVerticalBlockRender(
|
|
TestPreventVerticalBlockRenderStep.filter_content
|
|
)
|
|
|
|
|
|
class BaseVerticalBlockTest(XModuleXmlImportTest):
|
|
"""
|
|
Tests for the BaseVerticalBlock.
|
|
"""
|
|
test_html = 'Test HTML'
|
|
test_problem = 'Test_Problem'
|
|
test_html_nested = 'Nest Nested HTML'
|
|
test_problem_nested = 'Nest_Nested_Problem'
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
# construct block: course/sequence/vertical - problems
|
|
# \_ nested_vertical / problems
|
|
course = xml.CourseFactory.build()
|
|
sequence = xml.SequenceFactory.build(parent=course)
|
|
vertical = xml.VerticalFactory.build(parent=sequence)
|
|
|
|
self.course = self.process_xml(course)
|
|
xml.HtmlFactory(parent=vertical, url_name='test-html', text=self.test_html)
|
|
xml.ProblemFactory(parent=vertical, url_name='test-problem', text=self.test_problem)
|
|
|
|
nested_vertical = xml.VerticalFactory.build(parent=vertical)
|
|
xml.HtmlFactory(parent=nested_vertical, url_name='test_html_nested', text=self.test_html_nested)
|
|
xml.ProblemFactory(parent=nested_vertical, url_name='test_problem_nested', text=self.test_problem_nested)
|
|
|
|
self.course = self.process_xml(course)
|
|
course_seq = self.course.get_children()[0]
|
|
prepare_block_runtime(self.course.runtime)
|
|
|
|
self.course.runtime.export_fs = MemoryFS()
|
|
|
|
self.vertical = course_seq.get_children()[0]
|
|
self.vertical.runtime = self.course.runtime
|
|
|
|
self.html_block = self.vertical.get_children()[0]
|
|
self.problem_block = self.vertical.get_children()[1]
|
|
self.problem_block.has_score = True
|
|
self.problem_block.graded = True
|
|
self.extra_vertical_block = self.vertical.get_children()[2] # VerticalBlockWithMixins
|
|
self.nested_problem_block = self.extra_vertical_block.get_children()[1]
|
|
self.nested_problem_block.has_score = True
|
|
self.nested_problem_block.graded = True
|
|
|
|
self.username = "bilbo"
|
|
self.default_context = {"bookmarked": False, "username": self.username}
|
|
|
|
|
|
@ddt.ddt
|
|
class VerticalBlockTestCase(BaseVerticalBlockTest):
|
|
"""
|
|
Tests for the VerticalBlock.
|
|
"""
|
|
|
|
def assert_bookmark_info(self, assertion, content):
|
|
"""
|
|
Assert content has/hasn't all the bookmark info.
|
|
"""
|
|
assertion('bookmark_id', content)
|
|
assertion(f'{self.username},{str(self.vertical.location)}', content)
|
|
assertion('bookmarked', content)
|
|
assertion('show_bookmark_button', content)
|
|
|
|
@ddt.unpack
|
|
@ddt.data(
|
|
{'context': None, 'view': STUDENT_VIEW, 'completion_value': 0.0, 'days': 1},
|
|
{'context': {}, 'view': STUDENT_VIEW, 'completion_value': 0.0, 'days': 1},
|
|
{'context': {}, 'view': PUBLIC_VIEW, 'completion_value': 0.0, 'days': 1},
|
|
{'context': {'format': 'Quiz'}, 'view': STUDENT_VIEW, 'completion_value': 1.0, 'days': 1}, # completed
|
|
{'context': {'format': 'Exam'}, 'view': STUDENT_VIEW, 'completion_value': 0.0, 'days': 1}, # upcoming
|
|
{'context': {'format': 'Homework'}, 'view': STUDENT_VIEW, 'completion_value': 0.0, 'days': -1}, # past due
|
|
)
|
|
def test_render_student_preview_view(self, context, view, completion_value, days):
|
|
"""
|
|
Test the rendering of the student and public view.
|
|
"""
|
|
self.course.runtime._services['bookmarks'] = Mock()
|
|
now = datetime.now(ZoneInfo("UTC"))
|
|
self.vertical.due = now + timedelta(days=days)
|
|
if view == STUDENT_VIEW:
|
|
self.course.runtime._services['user'] = StubUserService(user=Mock(username=self.username))
|
|
self.course.runtime._services['completion'] = StubCompletionService(
|
|
enabled=True,
|
|
completion_value=completion_value
|
|
)
|
|
elif view == PUBLIC_VIEW:
|
|
self.course.runtime._services['user'] = StubUserService(user=AnonymousUser())
|
|
|
|
html = self.course.runtime.render(
|
|
self.vertical, view, self.default_context if context is None else context
|
|
).content
|
|
assert self.test_html in html
|
|
if view == STUDENT_VIEW:
|
|
assert self.test_problem in html
|
|
else:
|
|
assert self.test_problem not in html
|
|
assert f"'due': datetime.datetime({self.vertical.due.year}, {self.vertical.due.month}, {self.vertical.due.day}"\
|
|
in html
|
|
if view == STUDENT_VIEW:
|
|
self.assert_bookmark_info(self.assertIn, html)
|
|
else:
|
|
self.assert_bookmark_info(self.assertNotIn, html)
|
|
if context:
|
|
assert "'has_assignments': True" in html
|
|
assert "'subsection_format': '{}'".format(context['format']) in html
|
|
assert f"'completed': {completion_value == 1}" in html
|
|
assert f"'past_due': {self.vertical.due < now}" in html
|
|
|
|
@ddt.data(True, False)
|
|
def test_render_problem_without_score(self, has_score):
|
|
"""
|
|
Test the rendering of the student and public view.
|
|
"""
|
|
self.course.runtime._services['bookmarks'] = Mock()
|
|
self.course.runtime._services['user'] = StubUserService(user=Mock())
|
|
self.course.runtime._services['completion'] = StubCompletionService(enabled=True, completion_value=0)
|
|
|
|
now = datetime.now(ZoneInfo("UTC"))
|
|
self.vertical.due = now + timedelta(days=-1)
|
|
self.problem_block.has_score = has_score
|
|
|
|
html = self.course.runtime.render(self.vertical, STUDENT_VIEW, self.default_context).content
|
|
if has_score:
|
|
assert "'has_assignments': True" in html
|
|
assert "'completed': False" in html
|
|
assert "'past_due': True" in html
|
|
else:
|
|
assert "'has_assignments': False" in html
|
|
assert "'completed': None" in html
|
|
assert "'past_due': False" in html
|
|
|
|
@ddt.data((True, True), (True, False), (False, True), (False, False))
|
|
@ddt.unpack
|
|
def test_render_access_denied_blocks(self, node_has_access_error, child_has_access_error):
|
|
""" Tests access denied blocks are not rendered when hide_access_error_blocks is True """
|
|
self.course.runtime._services['bookmarks'] = Mock()
|
|
self.course.runtime._services['user'] = StubUserService(user=Mock())
|
|
self.vertical.due = datetime.now(ZoneInfo("UTC")) + timedelta(days=-1)
|
|
self.problem_block.has_access_error = node_has_access_error
|
|
self.nested_problem_block.has_access_error = child_has_access_error
|
|
|
|
context = {'username': self.username, 'hide_access_error_blocks': True}
|
|
html = self.course.runtime.render(self.vertical, STUDENT_VIEW, context).content
|
|
|
|
if node_has_access_error and child_has_access_error:
|
|
assert self.test_problem not in html
|
|
assert self.test_problem_nested not in html
|
|
if node_has_access_error and not child_has_access_error:
|
|
assert self.test_problem not in html
|
|
assert self.test_problem_nested in html
|
|
if not node_has_access_error and child_has_access_error:
|
|
assert self.test_problem in html
|
|
assert self.test_problem_nested not in html
|
|
if not node_has_access_error and not child_has_access_error:
|
|
assert self.test_problem in html
|
|
assert self.test_problem_nested in html
|
|
|
|
@ddt.data(True, False)
|
|
def test_block_has_access_error(self, has_access_error):
|
|
""" Tests block_has_access_error gives the correct result for child node questions """
|
|
# Use special block from setup (vertical/nested_vertical/problem)
|
|
# has_access_error is set on problem an extra level down, so we have to recurse to pass
|
|
|
|
self.nested_problem_block.has_access_error = has_access_error
|
|
should_block = self.vertical.block_has_access_error(self.vertical)
|
|
|
|
assert should_block == has_access_error
|
|
|
|
@ddt.unpack
|
|
@ddt.data(
|
|
(True, 0.9, True),
|
|
(False, 0.9, False),
|
|
(True, 1.0, False),
|
|
)
|
|
def test_mark_completed_on_view_after_delay_in_context(
|
|
self, completion_enabled, completion_value, mark_completed_enabled
|
|
):
|
|
"""
|
|
Test that mark-completed-on-view-after-delay is only set for relevant child Xblocks.
|
|
"""
|
|
with patch.object(self.html_block, 'render') as mock_student_view:
|
|
self.course.runtime._services['completion'] = StubCompletionService(
|
|
enabled=completion_enabled,
|
|
completion_value=completion_value,
|
|
)
|
|
self.course.runtime.render(self.vertical, STUDENT_VIEW, self.default_context)
|
|
if mark_completed_enabled:
|
|
assert mock_student_view.call_args[0][1]['wrap_xblock_data']['mark-completed-on-view-after-delay'] ==\
|
|
9876
|
|
else:
|
|
assert 'wrap_xblock_data' not in mock_student_view.call_args[0][1]
|
|
|
|
def test_render_studio_view(self):
|
|
"""
|
|
Test the rendering of the Studio author view
|
|
"""
|
|
# Vertical shouldn't render children on the unit page
|
|
context = {
|
|
'is_unit_page': True
|
|
}
|
|
html = self.course.runtime.render(self.vertical, AUTHOR_VIEW, context).content
|
|
assert self.test_html not in html
|
|
assert self.test_problem not in html
|
|
|
|
# Vertical should render reorderable children on the container page
|
|
reorderable_items = set()
|
|
context = {
|
|
'is_unit_page': False,
|
|
'reorderable_items': reorderable_items,
|
|
}
|
|
html = self.course.runtime.render(self.vertical, AUTHOR_VIEW, context).content
|
|
assert self.test_html in html
|
|
assert self.test_problem in html
|
|
|
|
@override_settings(
|
|
OPEN_EDX_FILTERS_CONFIG={
|
|
"org.openedx.learning.vertical_block_child.render.started.v1": {
|
|
"pipeline": [
|
|
"xmodule.tests.test_vertical.TestVerticalBlockChildRenderStep"
|
|
],
|
|
"fail_silently": False,
|
|
},
|
|
},
|
|
)
|
|
def test_vertical_block_child_render_started_filter_execution(self):
|
|
"""
|
|
Test the VerticalBlockChildRenderStarted filter's effects on student view.
|
|
"""
|
|
self.course.runtime._services['bookmarks'] = Mock()
|
|
self.course.runtime._services['user'] = StubUserService(user=Mock())
|
|
self.course.runtime._services['completion'] = StubCompletionService(enabled=True, completion_value=0)
|
|
|
|
html = self.course.runtime.render(self.vertical, STUDENT_VIEW, self.default_context).content
|
|
|
|
assert TestVerticalBlockChildRenderStep.filter_content in html
|
|
|
|
@override_settings(
|
|
OPEN_EDX_FILTERS_CONFIG={
|
|
"org.openedx.learning.vertical_block_child.render.started.v1": {
|
|
"pipeline": [
|
|
"xmodule.tests.test_vertical.TestPreventVerticalBlockChildRender"
|
|
],
|
|
"fail_silently": False,
|
|
},
|
|
},
|
|
)
|
|
def test_vertical_block_child_render_is_skipped_on_filter_exception(self):
|
|
"""
|
|
Test VerticalBlockChildRenderStarted filter can be used to skip child blocks.
|
|
"""
|
|
self.course.runtime._services['bookmarks'] = Mock()
|
|
self.course.runtime._services['user'] = StubUserService(user=Mock())
|
|
self.course.runtime._services['completion'] = StubCompletionService(enabled=True, completion_value=0)
|
|
|
|
html = self.course.runtime.render(self.vertical, STUDENT_VIEW, self.default_context).content
|
|
|
|
assert self.test_html not in html
|
|
assert self.test_html_nested not in html
|
|
|
|
@override_settings(
|
|
OPEN_EDX_FILTERS_CONFIG={
|
|
"org.openedx.learning.vertical_block.render.completed.v1": {
|
|
"pipeline": [
|
|
"xmodule.tests.test_vertical.TestVerticalBlockRenderCompletedStep"
|
|
],
|
|
"fail_silently": False,
|
|
},
|
|
},
|
|
)
|
|
def test_vertical_block_render_completed_filter_execution(self):
|
|
"""
|
|
Test the VerticalBlockRenderCompleted filter's execution.
|
|
"""
|
|
self.course.runtime._services['bookmarks'] = Mock()
|
|
self.course.runtime._services['user'] = StubUserService(user=Mock())
|
|
self.course.runtime._services['completion'] = StubCompletionService(enabled=True, completion_value=0)
|
|
|
|
html = self.course.runtime.render(self.vertical, STUDENT_VIEW, self.default_context).content
|
|
|
|
assert TestVerticalBlockRenderCompletedStep.filter_content in html
|
|
|
|
@override_settings(
|
|
OPEN_EDX_FILTERS_CONFIG={
|
|
"org.openedx.learning.vertical_block.render.completed.v1": {
|
|
"pipeline": [
|
|
"xmodule.tests.test_vertical.TestPreventVerticalBlockRenderStep"
|
|
],
|
|
"fail_silently": False,
|
|
},
|
|
},
|
|
)
|
|
def test_vertical_block_render_output_is_changed_on_filter_exception(self):
|
|
"""
|
|
Test VerticalBlockRenderCompleted filter can be used to prevent vertical block from rendering.
|
|
"""
|
|
self.course.runtime._services['bookmarks'] = Mock()
|
|
self.course.runtime._services['user'] = StubUserService(user=Mock())
|
|
self.course.runtime._services['completion'] = StubCompletionService(enabled=True, completion_value=0)
|
|
|
|
html = self.course.runtime.render(self.vertical, STUDENT_VIEW, self.default_context).content
|
|
|
|
assert TestPreventVerticalBlockRenderStep.filter_content == html
|