diff --git a/xblocks_contrib/test_settings.py b/test_settings.py similarity index 77% rename from xblocks_contrib/test_settings.py rename to test_settings.py index 545087f1..384d27e4 100644 --- a/xblocks_contrib/test_settings.py +++ b/test_settings.py @@ -5,6 +5,7 @@ INSTALLED_APPS = [ "django.contrib.auth", "django.contrib.contenttypes", + "edxval", ] DATABASES = { @@ -12,3 +13,5 @@ "ENGINE": "django.db.backends.sqlite3", } } + +TRANSCRIPT_LANG_CACHE_TIMEOUT = 60 * 60 * 24 # 24 hours diff --git a/tox.ini b/tox.ini index fe9fb231..96a424d5 100644 --- a/tox.ini +++ b/tox.ini @@ -14,12 +14,15 @@ max-line-length = 120 match-dir = (?!migrations) [pytest] -DJANGO_SETTINGS_MODULE = xblocks_contrib.test_settings +DJANGO_SETTINGS_MODULE = test_settings django_find_project = false addopts = --cov xblocks_contrib --cov-report term-missing --cov-report xml norecursedirs = .* docs requirements site-packages [testenv] +setenv = + PYTHONPATH = {toxinidir} + DJANGO_SETTINGS_MODULE = test_settings deps = django42: Django>=4.2,<5.0 django52: Django>=5.2,<6.0 diff --git a/xblocks_contrib/video/tests/test_utils.py b/xblocks_contrib/video/tests/test_utils.py new file mode 100644 index 00000000..0fc48f38 --- /dev/null +++ b/xblocks_contrib/video/tests/test_utils.py @@ -0,0 +1,113 @@ + +from collections import defaultdict +from unittest.mock import Mock + +from web_fragments.fragment import Fragment +from xblock.core import XBlockAside +from xblock.field_data import DictFieldData +from xblock.fields import Scope, ScopeIds, String +from xblock.test.tools import TestRuntime + +EXPORT_IMPORT_STATIC_DIR = 'static' +VALIDATION_MESSAGE_WARNING = "warning" + +# Mock for openedx save_to_store (openedx.core.djangoapps.video_config.transcripts_utils); +# not available in standalone xblocks-contrib. Use in tests that need to "save" transcript files. +save_to_store = Mock(name='save_to_store') + +# Transcript text used by video_config mock for index_dictionary / get_transcript (lang key). +_TRANSCRIPT_TEXT_BY_LANG = { + 'ge': 'sprechen sie deutsch? Ja, ich spreche Deutsch', + 'hr': 'Dobar dan! Kako ste danas?', +} + + +def _mock_available_translations(block, transcripts_info, verify_assets=True): + """Behave like openedx video_config: return transcript langs or ['en'] when empty.""" + transcripts = ( + transcripts_info.get('transcripts', {}) + if isinstance(transcripts_info, dict) else {} + ) + if transcripts: + return list(transcripts.keys()) + try: + from django.conf import settings + features = getattr(settings, 'FEATURES', {}) + if features.get('FALLBACK_TO_ENGLISH_TRANSCRIPTS', True): + return ['en'] + except Exception: + return ['en'] + return [] + + +def _mock_get_transcript(block, lang=None, output_format=None): + """Return (transcript_text,) so index_dictionary can use [0].replace(...).""" + text = _TRANSCRIPT_TEXT_BY_LANG.get(lang, '') + return (text,) + + +class DummyRuntime(TestRuntime): + """ + Construct a test DummyRuntime instance. + """ + + def __init__(self, render_template=None, **kwargs): + services = kwargs.setdefault('services', {}) + services['field-data'] = DictFieldData({}) + video_config_mock = Mock(name='video_config') + video_config_mock.available_translations = Mock(side_effect=_mock_available_translations) + video_config_mock.get_transcript = Mock(side_effect=_mock_get_transcript) + services['video_config'] = video_config_mock + + # Ignore load_error_blocks as it's not supported by modern TestRuntime + kwargs.pop('load_error_blocks', None) + + super().__init__(**kwargs) + # Use a Mock with root_path so edxval.api create_transcript_objects works + # (it does resource_fs.root_path.split('/drafts')[0]). MemoryFS has no root_path. + self._resources_fs = Mock(name='DummyRuntime.resources_fs', root_path='.') + self._asides = defaultdict(list) + + # TODO: Need to look into all asided code, do we need to add any functionality related to it in edx-platform? + def get_asides(self, block): + return self._asides.get(block.scope_ids.usage_id, []) + + def get_aside_of_type(self, block, aside_type): + for aside in self._asides.get(block.scope_ids.usage_id, []): + if getattr(aside.scope_ids, 'block_type', None) == aside_type: + return aside + return super().get_aside_of_type(block, aside_type) + + def parse_asides(self, node, definition_id, usage_id, id_generator): + asides = [] + for child in node: + if child.get('xblock-family') == 'xblock_asides.v1': + # Simplified mock parser for tests + aside_scope_ids = ScopeIds(None, child.tag, definition_id, usage_id) + aside = AsideTestType(runtime=self, scope_ids=aside_scope_ids) + aside.tag = child.tag + for attr, val in child.attrib.items(): + if attr in aside.fields: + setattr(aside, attr, val) + asides.append(aside) + self._asides[usage_id].append(aside) + return asides + + @property + def resources_fs(self): + return self._resources_fs + + +class AsideTestType(XBlockAside): + """ + Test Aside type + """ + FRAG_CONTENT = "

Aside rendered

" + + content = String(default="default_content", scope=Scope.content) + data_field = String(default="default_data", scope=Scope.settings) + + @XBlockAside.aside_for('student_view') + def student_view_aside(self, block, context): # pylint: disable=unused-argument + """Add to the student view""" + return Fragment(self.FRAG_CONTENT) diff --git a/xblocks_contrib/video/tests/test_video.py b/xblocks_contrib/video/tests/test_video.py new file mode 100644 index 00000000..b6b8decb --- /dev/null +++ b/xblocks_contrib/video/tests/test_video.py @@ -0,0 +1,1206 @@ +# pylint: disable=protected-access +"""Test for Video XBlock functional logic. +These test data read from xml, not from mongo. + +We have a ModuleStoreTestCase class defined in +xmodule/modulestore/tests/django_utils.py. You can +search for usages of this in the cms and lms tests for examples. You use +this so that it will do things like point the modulestore setting to mongo, +flush the contentstore before and after, load the templates, etc. +You can then use the CourseFactory and BlockFactory as defined +in xmodule/modulestore/tests/factories.py to create +the course, section, subsection, unit, etc. +""" + + +import datetime +import shutil +import unittest +from tempfile import mkdtemp +from unittest.mock import ANY, MagicMock, Mock, patch +from uuid import uuid4 + +from django.test.utils import override_settings +import pytest +import ddt +from django.conf import settings +from django.test import TestCase +from fs.osfs import OSFS +from lxml import etree +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import CourseLocator +from xblock.field_data import DictFieldData +from xblock.fields import ScopeIds + +from xblocks_contrib.video.tests.test_utils import ( + VALIDATION_MESSAGE_WARNING, + AsideTestType, + DummyRuntime, + save_to_store, +) +from xblocks_contrib.video.video import ( + EXPORT_IMPORT_STATIC_DIR, + VideoBlock, + create_youtube_string, +) +from xblock.core import XBlockAside + +# TODO: Need to look into it +VideoBlock.add_aside = MagicMock() + +SRT_FILEDATA = ''' +0 +00:00:00,270 --> 00:00:02,720 +sprechen sie deutsch? + +1 +00:00:02,720 --> 00:00:05,430 +Ja, ich spreche Deutsch +''' + +CRO_SRT_FILEDATA = ''' +0 +00:00:00,270 --> 00:00:02,720 +Dobar dan! + +1 +00:00:02,720 --> 00:00:05,430 +Kako ste danas? +''' + +YOUTUBE_SUBTITLES = ( + "Sample trascript line 1. " + "Sample trascript line 2. " + "Sample trascript line 3." +) + +MOCKED_YOUTUBE_TRANSCRIPT_API_RESPONSE = ''' + + Sample trascript line 1. + Sample trascript line 2. + Sample trascript line 3. + +''' + +ALL_LANGUAGES = ( + ["en", "English"], + ["eo", "Esperanto"], + ["ur", "Urdu"] +) + + +def instantiate_block(**field_data): + """ + Instantiate block with most properties. + """ + if field_data.get('data', None): + field_data = VideoBlock.parse_video_xml(field_data['data']) + system = DummyRuntime() + course_key = CourseLocator('org', 'course', 'run') + usage_key = course_key.make_usage_key('video', 'SampleProblem') + return system.construct_xblock_from_class( + VideoBlock, + scope_ids=ScopeIds(None, None, usage_key, usage_key), + field_data=DictFieldData(field_data), + ) + + +# Because of the way xblocks_contrib.video.video imports edxval.api, we +# must mock the entire module, which requires making mock exception classes. + +class _MockValVideoNotFoundError(Exception): + """Mock ValVideoNotFoundError exception""" + pass # lint-amnesty, pylint: disable=unnecessary-pass + + +class _MockValCannotCreateError(Exception): + """Mock ValCannotCreateError exception""" + pass # lint-amnesty, pylint: disable=unnecessary-pass + + +class VideoBlockTest(unittest.TestCase): + """Logic tests for Video XBlock.""" + + raw_field_data = { + 'data': '