diff --git a/xblocks_contrib/video/tests/test_utils.py b/xblocks_contrib/video/tests/test_utils.py
new file mode 100644
index 0000000..b7782c9
--- /dev/null
+++ b/xblocks_contrib/video/tests/test_utils.py
@@ -0,0 +1,271 @@
+from typing import Any
+from collections import defaultdict
+from unittest.mock import MagicMock
+from xblock.core import XBlockAside
+from xblock.runtime import Fragment, MemoryIdManager
+from xblock.test.tools import TestRuntime
+from xblock.validation import ValidationMessage
+from xblock.fields import Scope, String, ScopeIds
+from xblocks_contrib.video.exceptions import TranscriptNotFoundError
+from django.conf import settings
+from fs.memoryfs import MemoryFS
+from xblock.field_data import DictFieldData
+
+
+EXPORT_IMPORT_STATIC_DIR = 'static'
+
+def get_test_descriptor_system():
+ """
+ Construct a minimal test descriptor system for XBlocks.
+ """
+ return DummyModuleStoreRuntime(services={'video_config': MockedVideoConfigService()})
+
+
+class StudioValidationMessage(ValidationMessage):
+ """
+ A message containing validation information about an xblock, extended to provide Studio-specific fields.
+ """
+
+ # A special message type indicating that the xblock is not yet configured. This message may be rendered
+ # in a different way within Studio.
+ NOT_CONFIGURED = "not-configured"
+
+ TYPES = [ValidationMessage.WARNING, ValidationMessage.ERROR, NOT_CONFIGURED]
+
+ def __init__(self, message_type, message_text, action_label=None, action_class=None, action_runtime_event=None):
+ """
+ Create a new message.
+
+ Args:
+ message_type (str): The type associated with this message. Most be `WARNING` or `ERROR`.
+ message_text (unicode): The textual message.
+ action_label (unicode): Text to show on a "fix-up" action (optional). If present, either `action_class`
+ or `action_runtime_event` should be specified.
+ action_class (str): A class to link to the "fix-up" action (optional). A click handler must be added
+ for this class, unless it is "edit-button", "duplicate-button", or "delete-button" (which are all
+ handled in general for xblock instances.
+ action_runtime_event (str): An event name to be triggered on the xblock client-side runtime when
+ the "fix-up" action is clicked (optional).
+ """
+ super().__init__(message_type, message_text)
+ if action_label is not None:
+ if not isinstance(action_label, str):
+ raise TypeError("Action label must be unicode.")
+ self.action_label = action_label
+ if action_class is not None:
+ if not isinstance(action_class, str):
+ raise TypeError("Action class must be a string.")
+ self.action_class = action_class
+ if action_runtime_event is not None:
+ if not isinstance(action_runtime_event, str):
+ raise TypeError("Action runtime event must be a string.")
+ self.action_runtime_event = action_runtime_event
+
+ def to_json(self):
+ """
+ Convert to a json-serializable representation.
+
+ Returns:
+ dict: A dict representation that is json-serializable.
+ """
+ serialized = super().to_json()
+ if hasattr(self, "action_label"):
+ serialized["action_label"] = self.action_label
+ if hasattr(self, "action_class"):
+ serialized["action_class"] = self.action_class
+ if hasattr(self, "action_runtime_event"):
+ serialized["action_runtime_event"] = self.action_runtime_event
+ return serialized
+
+
+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)
+
+
+class DummyModuleStoreRuntime(TestRuntime):
+ def __init__(self, *args, **kwargs):
+ # MemoryIdManager is required for TestRuntime to work properly with ScopeIds
+ memory_id_manager = MemoryIdManager()
+ if not args:
+ kwargs.setdefault('id_reader', memory_id_manager)
+ kwargs.setdefault('id_generator', memory_id_manager)
+
+ # Ignore load_error_blocks as it's not supported by modern TestRuntime
+ kwargs.pop('load_error_blocks', None)
+
+ # Ensure field-data and video_config services are available
+ services = kwargs.setdefault('services', {})
+ if 'field-data' not in services:
+ services['field-data'] = DictFieldData({})
+ if 'video_config' not in services:
+ services['video_config'] = MockedVideoConfigService()
+
+ super().__init__(*args, **kwargs)
+
+ # resources_fs is used in parse_xml and export_to_xml tests
+ self.resources_fs = MemoryFS()
+ self._asides = defaultdict(list)
+
+ def handler_url(self, block, handler_name, suffix='', query='', thirdparty=False):
+ url = f"/handler/{handler_name}"
+ if suffix:
+ url += f"/{suffix}"
+ if query:
+ url += f"?{query}"
+ return url
+
+ 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
+
+ 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, []):
+ return aside
+ # Default aside if not found
+ aside_scope_ids = ScopeIds(None, aside_type, None, block.scope_ids.usage_id)
+ aside = AsideTestType(runtime=self, scope_ids=aside_scope_ids)
+ self._asides[block.scope_ids.usage_id].append(aside)
+ return aside
+
+
+class MockedVideoConfigService:
+ """
+ Service for providing video-related configuration and feature flags.
+
+ This service abstracts away edx-platform specific functionality
+ that the Video XBlock needs, allowing the Video XBlock to be
+ extracted to a separate repository.
+ """
+
+ def get_public_video_url(self, usage_id) -> str:
+ """
+ Returns the public video url
+ """
+ return ""
+
+ def get_public_sharing_context(self, video_block, course_key) -> dict:
+ """
+ Get the complete public sharing context for a video.
+ """
+ return {}
+
+ def is_transcript_feedback_enabled(self, course_id) -> bool:
+ """
+ Check if transcript feedback is enabled for the course.
+ """
+ return False
+
+ def is_youtube_deprecated(self, course_id) -> bool:
+ """
+ Check if YouTube is deprecated for the course.
+ """
+ return False
+
+ def is_youtube_blocked_for_course(self, course_id) -> bool:
+ """
+ Check if YouTube is blocked for the course.
+ """
+ return False
+
+ def is_hls_playback_enabled(self, course_id) -> bool:
+ """
+ Check if HLS playback is enabled for the course.
+ """
+ return False
+
+ def get_transcript(
+ self,
+ video_block,
+ lang: str | None = None,
+ output_format: str = 'srt',
+ youtube_id: str | None = None,
+ is_bumper=False,
+ ) -> tuple[Any, str, str]:
+ """
+ Retrieve a transcript from the runtime's storage.
+ """
+ # Canned responses for index_dictionary tests
+ content = ""
+ if output_format == 'txt' or output_format == 'srt':
+ if lang == 'ge':
+ content = "sprechen sie deutsch?\nJa, ich spreche Deutsch"
+ elif lang == 'hr':
+ content = "Dobar dan!\nKako ste danas?"
+ elif lang == 'en' or lang is None:
+ content = "Sample transcript"
+
+ return content, f"transcript_{lang}.{output_format}", "text/plain"
+
+ def available_translations(
+ self,
+ video_block,
+ transcripts: dict[str, Any],
+ verify_assets: bool | None = None,
+ is_bumper: bool = False,
+ ) -> list[str]:
+ """
+ Return a list of language codes for which we have transcripts.
+ """
+ sub, other_langs = transcripts["sub"], transcripts["transcripts"]
+ translations = list(other_langs)
+
+ fallback_enabled = settings.FEATURES.get('FALLBACK_TO_ENGLISH_TRANSCRIPTS', True)
+
+ if fallback_enabled:
+ if not translations or sub:
+ translations.append('en')
+ elif sub:
+ translations.append('en')
+
+ return list(set(translations))
+
+ def upload_transcript(
+ self,
+ *,
+ video_block,
+ language_code: str,
+ new_language_code: str | None,
+ transcript_file,
+ edx_video_id: str | None,
+ ) -> None:
+ """
+ Store a transcript, however the runtime prefers to.
+ """
+ pass
+
+ def delete_transcript(
+ self,
+ *,
+ video_block,
+ edx_video_id: str | None,
+ language_code: str,
+ ) -> None:
+ """
+ Delete a transcript from the runtime's storage.
+ """
+ pass
diff --git a/xblocks_contrib/video/tests/test_video.py b/xblocks_contrib/video/tests/test_video.py
new file mode 100644
index 0000000..f3f452c
--- /dev/null
+++ b/xblocks_contrib/video/tests/test_video.py
@@ -0,0 +1,1225 @@
+# 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 json
+import shutil
+import unittest
+from tempfile import mkdtemp
+from uuid import uuid4
+from unittest.mock import ANY, MagicMock, Mock, patch
+
+import pytest
+import ddt
+from django.conf import settings
+from django.test import TestCase
+from django.test.utils import override_settings
+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 .test_utils import (
+ get_test_descriptor_system,
+ StudioValidationMessage,
+ EXPORT_IMPORT_STATIC_DIR,
+ AsideTestType,
+ DummyModuleStoreRuntime
+)
+from xblocks_contrib.video.video import VideoBlock, create_youtube_string
+from xblock.core import XBlockAside
+
+VideoBlock.add_aside = MagicMock()
+
+
+def save_to_store(content, name, mime_type, location):
+ """
+ Save named content to store by location.
+
+ Returns location of saved content.
+ """
+ return None
+
+
+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 = get_test_descriptor_system()
+ 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': ''
+ }
+
+ def test_parse_youtube(self):
+ """Test parsing old-style Youtube ID strings into a dict."""
+ youtube_str = '0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg'
+ output = VideoBlock._parse_youtube(youtube_str)
+ assert output == {'0.75': 'jNCf2gIqpeE', '1.00': 'ZwkTiUPN0mg', '1.25': 'rsq9auxASqI', '1.50': 'kMyNdzVHHgg'}
+
+ def test_parse_youtube_one_video(self):
+ """
+ Ensure that all keys are present and missing speeds map to the
+ empty string.
+ """
+ youtube_str = '0.75:jNCf2gIqpeE'
+ output = VideoBlock._parse_youtube(youtube_str)
+ assert output == {'0.75': 'jNCf2gIqpeE', '1.00': '', '1.25': '', '1.50': ''}
+
+ def test_parse_youtube_invalid(self):
+ """Ensure that ids that are invalid return an empty dict"""
+ # invalid id
+ youtube_str = 'thisisaninvalidid'
+ output = VideoBlock._parse_youtube(youtube_str)
+ assert output == {'0.75': '', '1.00': '', '1.25': '', '1.50': ''}
+ # another invalid id
+ youtube_str = ',::,:,,'
+ output = VideoBlock._parse_youtube(youtube_str)
+ assert output == {'0.75': '', '1.00': '', '1.25': '', '1.50': ''}
+
+ # and another one, partially invalid
+ youtube_str = '0.75_BAD!!!,1.0:AXdE34_U,1.25:KLHF9K_Y,1.5:VO3SxfeD,'
+ output = VideoBlock._parse_youtube(youtube_str)
+ assert output == {'0.75': '', '1.00': 'AXdE34_U', '1.25': 'KLHF9K_Y', '1.50': 'VO3SxfeD'}
+
+ def test_parse_youtube_key_format(self):
+ """
+ Make sure that inconsistent speed keys are parsed correctly.
+ """
+ youtube_str = '1.00:p2Q6BrNhdh8'
+ youtube_str_hack = '1.0:p2Q6BrNhdh8'
+ assert VideoBlock._parse_youtube(youtube_str) == VideoBlock._parse_youtube(youtube_str_hack)
+
+ def test_parse_youtube_empty(self):
+ """
+ Some courses have empty youtube attributes, so we should handle
+ that well.
+ """
+ assert VideoBlock._parse_youtube('') == {'0.75': '', '1.00': '', '1.25': '', '1.50': ''}
+
+
+class VideoBlockTestBase(unittest.TestCase):
+ """
+ Base class for tests for VideoBlock
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.block = instantiate_block()
+
+ def assertXmlEqual(self, expected, xml):
+ """
+ Assert that the given XML fragments have the same attributes, text, and
+ (recursively) children
+ """
+ def get_child_tags(elem):
+ """Extract the list of tag names for children of elem"""
+ return [child.tag for child in elem]
+
+ for attr in ['tag', 'attrib', 'text', 'tail']:
+ expected_attr = getattr(expected, attr)
+ actual_attr = getattr(xml, attr)
+ assert expected_attr == actual_attr
+
+ assert get_child_tags(expected) == get_child_tags(xml)
+ for left, right in zip(expected, xml):
+ self.assertXmlEqual(left, right)
+
+
+class TestCreateYoutubeString(VideoBlockTestBase):
+ """
+ Checks that create_youtube_string correcty extracts information from Video block.
+ """
+
+ def test_create_youtube_string(self):
+ """
+ Test that Youtube ID strings are correctly created when writing back out to XML.
+ """
+ self.block.youtube_id_0_75 = 'izygArpw-Qo'
+ self.block.youtube_id_1_0 = 'p2Q6BrNhdh8'
+ self.block.youtube_id_1_25 = '1EeWXzPdhSA'
+ self.block.youtube_id_1_5 = 'rABDYkeK0x8'
+ expected = "0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8"
+ assert create_youtube_string(self.block) == expected
+
+ def test_create_youtube_string_missing(self):
+ """
+ Test that Youtube IDs which aren't explicitly set aren't included in the output string.
+ """
+ self.block.youtube_id_0_75 = 'izygArpw-Qo'
+ self.block.youtube_id_1_0 = 'p2Q6BrNhdh8'
+ self.block.youtube_id_1_25 = '1EeWXzPdhSA'
+ expected = "0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA"
+ assert create_youtube_string(self.block) == expected
+
+
+class TestCreateYouTubeUrl(VideoBlockTestBase):
+ """
+ Tests for helper method `create_youtube_url`.
+ """
+
+ def test_create_youtube_url_unicode(self):
+ """
+ Test that passing unicode to `create_youtube_url` doesn't throw
+ an error.
+ """
+ self.block.create_youtube_url("üñîçø∂é")
+
+
+@ddt.ddt
+class VideoBlockImportTestCase(TestCase):
+ """
+ Make sure that VideoBlock can import an old XML-based video correctly.
+ """
+
+ def assert_attributes_equal(self, video, attrs):
+ """
+ Assert that `video` has the correct attributes. `attrs` is a map of {metadata_field: value}.
+ """
+ for key, value in attrs.items():
+ assert getattr(video, key) == value
+
+ def test_constructor(self):
+ sample_xml = '''
+
+ '''
+ block = instantiate_block(data=sample_xml)
+ self.assert_attributes_equal(block, {
+ 'youtube_id_0_75': 'izygArpw-Qo',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': 'rABDYkeK0x8',
+ 'download_video': True,
+ 'show_captions': False,
+ 'start_time': datetime.timedelta(seconds=1),
+ 'end_time': datetime.timedelta(seconds=60),
+ 'track': 'http://www.example.com/track',
+ 'handout': 'http://www.example.com/handout',
+ 'download_track': True,
+ 'html5_sources': ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg'],
+ 'data': '',
+ 'transcripts': {'ua': 'ukrainian_translation.srt', 'ge': 'german_translation.srt'}
+ })
+
+ def test_parse_xml(self):
+ module_system = DummyModuleStoreRuntime(load_error_blocks=True)
+ xml_data = '''
+
+ '''
+ xml_object = etree.fromstring(xml_data)
+ output = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(output, {
+ 'youtube_id_0_75': 'izygArpw-Qo',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': 'rABDYkeK0x8',
+ 'show_captions': False,
+ 'start_time': datetime.timedelta(seconds=1),
+ 'end_time': datetime.timedelta(seconds=60),
+ 'track': 'http://www.example.com/track',
+ 'handout': 'http://www.example.com/handout',
+ 'download_track': False,
+ 'download_video': False,
+ 'html5_sources': ['http://www.example.com/source.mp4'],
+ 'data': '',
+ 'transcripts': {'uk': 'ukrainian_translation.srt', 'de': 'german_translation.srt'},
+ })
+
+ @XBlockAside.register_temp_plugin(AsideTestType, "test_aside")
+ @patch('xblocks_contrib.video.video.VideoBlock.load_file')
+ @patch('xblocks_contrib.video.video.is_pointer_tag')
+ @ddt.data(True, False)
+ def test_parse_xml_with_asides(self, video_xml_has_aside, mock_is_pointer_tag, mock_load_file):
+ """Test that `parse_xml` parses asides from the video xml"""
+ runtime = DummyModuleStoreRuntime(load_error_blocks=True)
+ if video_xml_has_aside:
+ xml_data = '''
+
+ '''
+ else:
+ xml_data = '''
+
+ '''
+ mock_is_pointer_tag.return_value = True
+ xml_object = etree.fromstring(xml_data)
+ mock_load_file.return_value = xml_object
+ output = VideoBlock.parse_xml(xml_object, runtime, None)
+ aside = runtime.get_aside_of_type(output, "test_aside")
+ if video_xml_has_aside:
+ assert aside.content == "default_content"
+ assert aside.data_field == "aside parsed"
+ else:
+ assert aside.content == "default_content"
+ assert aside.data_field == "default_data"
+
+ @ddt.data(
+ ('course-v1:test_org+test_course+test_run',
+ '/asset-v1:test_org+test_course+test_run+type@asset+block@test.png'),
+ ('test_org/test_course/test_run', '/c4x/test_org/test_course/asset/test.png')
+ )
+ @ddt.unpack
+ def test_parse_xml_when_handout_is_course_asset(self, course_id_string, expected_handout_link):
+ """
+ Test that if handout link is course_asset then it will contain targeted course_id in handout link.
+ """
+ module_system = DummyModuleStoreRuntime(load_error_blocks=True)
+ course_id = CourseKey.from_string(course_id_string)
+ xml_data = '''
+
+ '''
+ xml_object = etree.fromstring(xml_data)
+ module_system.id_generator.target_course_id = course_id
+
+ output = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(output, {
+ 'youtube_id_0_75': 'izygArpw-Qo',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': 'rABDYkeK0x8',
+ 'show_captions': False,
+ 'start_time': datetime.timedelta(seconds=1),
+ 'end_time': datetime.timedelta(seconds=60),
+ 'track': 'http://www.example.com/track',
+ 'handout': expected_handout_link,
+ 'download_track': False,
+ 'download_video': False,
+ 'html5_sources': ['http://www.example.com/source.mp4'],
+ 'data': '',
+ 'transcripts': {'uk': 'ukrainian_translation.srt', 'de': 'german_translation.srt'},
+ })
+
+ def test_parse_xml_missing_attributes(self):
+ """
+ Ensure that attributes have the right values if they aren't
+ explicitly set in XML.
+ """
+ module_system = DummyModuleStoreRuntime(load_error_blocks=True)
+ xml_data = '''
+
+ '''
+ xml_object = etree.fromstring(xml_data)
+ output = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(output, {
+ 'youtube_id_0_75': '',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': '',
+ 'show_captions': True,
+ 'start_time': datetime.timedelta(seconds=0.0),
+ 'end_time': datetime.timedelta(seconds=0.0),
+ 'track': '',
+ 'handout': None,
+ 'download_track': False,
+ 'download_video': False,
+ 'html5_sources': ['http://www.example.com/source.mp4'],
+ 'data': ''
+ })
+
+ def test_parse_xml_missing_download_track(self):
+ """
+ Ensure that attributes have the right values if they aren't
+ explicitly set in XML.
+ """
+ module_system = DummyModuleStoreRuntime(load_error_blocks=True)
+ xml_data = '''
+
+ '''
+ xml_object = etree.fromstring(xml_data)
+ output = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(output, {
+ 'youtube_id_0_75': '',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': '',
+ 'show_captions': True,
+ 'start_time': datetime.timedelta(seconds=0.0),
+ 'end_time': datetime.timedelta(seconds=0.0),
+ 'track': 'http://www.example.com/track',
+ 'download_track': True,
+ 'download_video': False,
+ 'html5_sources': ['http://www.example.com/source.mp4'],
+ 'data': '',
+ 'transcripts': {},
+ })
+
+ def test_parse_xml_no_attributes(self):
+ """
+ Make sure settings are correct if none are explicitly set in XML.
+ """
+ module_system = DummyModuleStoreRuntime(load_error_blocks=True)
+ xml_data = ''
+ xml_object = etree.fromstring(xml_data)
+ output = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(output, {
+ 'youtube_id_0_75': '',
+ 'youtube_id_1_0': '3_yD_cEKoCk',
+ 'youtube_id_1_25': '',
+ 'youtube_id_1_5': '',
+ 'show_captions': True,
+ 'start_time': datetime.timedelta(seconds=0.0),
+ 'end_time': datetime.timedelta(seconds=0.0),
+ 'track': '',
+ 'handout': None,
+ 'download_track': False,
+ 'download_video': False,
+ 'html5_sources': [],
+ 'data': '',
+ 'transcripts': {},
+ })
+
+ def test_parse_xml_double_quotes(self):
+ """
+ Make sure we can handle the double-quoted string format (which was used for exporting for
+ a few weeks).
+ """
+ module_system = DummyModuleStoreRuntime(load_error_blocks=True)
+ xml_data = '''
+
+ '''
+ xml_object = etree.fromstring(xml_data)
+ output = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(output, {
+ 'youtube_id_0_75': 'OEoXaMPEzf65',
+ 'youtube_id_1_0': 'OEoXaMPEzf10',
+ 'youtube_id_1_25': 'OEoXaMPEzf125',
+ 'youtube_id_1_5': 'OEoXaMPEzf15',
+ 'show_captions': False,
+ 'start_time': datetime.timedelta(seconds=0.0),
+ 'end_time': datetime.timedelta(seconds=0.0),
+ 'track': 'http://www.example.com/track',
+ 'handout': 'http://www.example.com/handout',
+ 'download_track': True,
+ 'download_video': True,
+ 'html5_sources': ["source_1", "source_2"],
+ 'data': ''
+ })
+
+ def test_parse_xml_double_quote_concatenated_youtube(self):
+ module_system = DummyModuleStoreRuntime(load_error_blocks=True)
+ xml_data = '''
+
+ '''
+ xml_object = etree.fromstring(xml_data)
+ output = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(output, {
+ 'youtube_id_0_75': '',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': '',
+ 'show_captions': True,
+ 'start_time': datetime.timedelta(seconds=0.0),
+ 'end_time': datetime.timedelta(seconds=0.0),
+ 'track': '',
+ 'handout': None,
+ 'download_track': False,
+ 'download_video': False,
+ 'html5_sources': [],
+ 'data': ''
+ })
+
+ def test_old_video_format(self):
+ """
+ Test backwards compatibility with VideoBlock's XML format.
+ """
+ module_system = DummyModuleStoreRuntime(load_error_blocks=True)
+ xml_data = """
+
+ """
+ xml_object = etree.fromstring(xml_data)
+ output = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(output, {
+ 'youtube_id_0_75': 'izygArpw-Qo',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': 'rABDYkeK0x8',
+ 'show_captions': False,
+ 'start_time': datetime.timedelta(seconds=1),
+ 'end_time': datetime.timedelta(seconds=60),
+ 'track': 'http://www.example.com/track',
+ # 'download_track': True,
+ 'html5_sources': ['http://www.example.com/source.mp4'],
+ 'data': '',
+ })
+
+ def test_old_video_data(self):
+ """
+ Ensure that Video is able to read VideoBlock's model data.
+ """
+ module_system = DummyModuleStoreRuntime(load_error_blocks=True)
+ xml_data = """
+
+ """
+ xml_object = etree.fromstring(xml_data)
+ video = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(video, {
+ 'youtube_id_0_75': 'izygArpw-Qo',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': 'rABDYkeK0x8',
+ 'show_captions': False,
+ 'start_time': datetime.timedelta(seconds=1),
+ 'end_time': datetime.timedelta(seconds=60),
+ 'track': 'http://www.example.com/track',
+ # 'download_track': True,
+ 'html5_sources': ['http://www.example.com/source.mp4'],
+ 'data': ''
+ })
+
+ def test_import_with_float_times(self):
+ """
+ Ensure that Video is able to read VideoBlock's model data.
+ """
+ module_system = DummyModuleStoreRuntime(load_error_blocks=True)
+ xml_data = """
+
+ """
+ xml_object = etree.fromstring(xml_data)
+ video = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(video, {
+ 'youtube_id_0_75': 'izygArpw-Qo',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': 'rABDYkeK0x8',
+ 'show_captions': False,
+ 'start_time': datetime.timedelta(seconds=1),
+ 'end_time': datetime.timedelta(seconds=60),
+ 'track': 'http://www.example.com/track',
+ # 'download_track': True,
+ 'html5_sources': ['http://www.example.com/source.mp4'],
+ 'data': ''
+ })
+
+ @patch('xblocks_contrib.video.video.edxval_api')
+ def test_import_val_data(self, mock_val_api):
+ """
+ Test that `parse_xml` works method works as expected.
+ """
+ def mock_val_import(xml, edx_video_id, resource_fs, static_dir, external_transcripts, course_id):
+ """Mock edxval.api.import_parse_xml"""
+ assert xml.tag == 'video_asset'
+ assert dict(list(xml.items())) == {'mock_attr': ''}
+ assert edx_video_id == 'test_edx_video_id'
+ assert static_dir == EXPORT_IMPORT_STATIC_DIR
+ assert resource_fs is not None
+ assert external_transcripts == {'en': ['subs_3_yD_cEKoCk.srt.sjson']}
+ assert course_id == 'test_course_id'
+ return edx_video_id
+
+ edx_video_id = 'test_edx_video_id'
+ mock_val_api.import_from_xml = Mock(wraps=mock_val_import)
+ module_system = DummyModuleStoreRuntime(load_error_blocks=True)
+
+ # Create static directory in import file system and place transcript files inside it.
+ module_system.resources_fs.makedirs(EXPORT_IMPORT_STATIC_DIR, recreate=True)
+
+ # import new edx_video_id
+ xml_data = """
+
+ """.format(
+ edx_video_id=edx_video_id
+ )
+ xml_object = etree.fromstring(xml_data)
+ module_system.id_generator.target_course_id = 'test_course_id'
+ video = VideoBlock.parse_xml(xml_object, module_system, None)
+
+ self.assert_attributes_equal(video, {'edx_video_id': edx_video_id})
+ mock_val_api.import_from_xml.assert_called_once_with(
+ ANY,
+ edx_video_id,
+ module_system.resources_fs,
+ EXPORT_IMPORT_STATIC_DIR,
+ {'en': ['subs_3_yD_cEKoCk.srt.sjson']},
+ course_id='test_course_id'
+ )
+
+ @patch('xblocks_contrib.video.video.edxval_api')
+ def test_import_val_data_invalid(self, mock_val_api):
+ mock_val_api.ValCannotCreateError = _MockValCannotCreateError
+ mock_val_api.import_from_xml = Mock(side_effect=mock_val_api.ValCannotCreateError)
+ module_system = DummyModuleStoreRuntime(load_error_blocks=True)
+
+ # Negative duration is invalid
+ xml_data = """
+
+ """
+ xml_object = etree.fromstring(xml_data)
+ with pytest.raises(mock_val_api.ValCannotCreateError):
+ VideoBlock.parse_xml(xml_object, module_system, None)
+
+
+class VideoExportTestCase(VideoBlockTestBase):
+ """
+ Make sure that VideoBlock can export itself to XML correctly.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.temp_dir = mkdtemp()
+ self.file_system = OSFS(self.temp_dir)
+ self.addCleanup(shutil.rmtree, self.temp_dir)
+
+ @patch('xblocks_contrib.video.video.edxval_api')
+ def test_export_to_xml(self, mock_val_api):
+ """
+ Test that we write the correct XML on export.
+ """
+ edx_video_id = 'test_edx_video_id'
+ mock_val_api.export_to_xml = Mock(
+ return_value=dict(
+ xml=etree.Element('video_asset'),
+ transcripts={}
+ )
+ )
+ self.block.youtube_id_0_75 = 'izygArpw-Qo'
+ self.block.youtube_id_1_0 = 'p2Q6BrNhdh8'
+ self.block.youtube_id_1_25 = '1EeWXzPdhSA'
+ self.block.youtube_id_1_5 = 'rABDYkeK0x8'
+ self.block.show_captions = False
+ self.block.start_time = datetime.timedelta(seconds=1.0)
+ self.block.end_time = datetime.timedelta(seconds=60)
+ self.block.track = 'http://www.example.com/track'
+ self.block.handout = 'http://www.example.com/handout'
+ self.block.download_track = True
+ self.block.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source1.ogg']
+ self.block.download_video = True
+ self.block.transcripts = {'ua': 'ukrainian_translation.srt', 'ge': 'german_translation.srt'}
+ self.block.edx_video_id = edx_video_id
+
+ xml = self.block.definition_to_xml(self.file_system)
+ parser = etree.XMLParser(remove_blank_text=True)
+ xml_string = '''\
+
+ '''
+ expected = etree.XML(xml_string, parser=parser)
+ self.assertXmlEqual(expected, xml)
+ mock_val_api.export_to_xml.assert_called_once_with(
+ video_id=edx_video_id,
+ static_dir=EXPORT_IMPORT_STATIC_DIR,
+ resource_fs=self.file_system,
+ course_id=self.block.scope_ids.usage_id.context_key,
+ )
+
+ def test_export_to_xml_without_video_id(self):
+ """
+ Test that we write the correct XML on export of a video without edx_video_id.
+ """
+ self.block.youtube_id_0_75 = 'izygArpw-Qo'
+ self.block.youtube_id_1_0 = 'p2Q6BrNhdh8'
+ self.block.youtube_id_1_25 = '1EeWXzPdhSA'
+ self.block.youtube_id_1_5 = 'rABDYkeK0x8'
+ self.block.show_captions = False
+ self.block.start_time = datetime.timedelta(seconds=1.0)
+ self.block.end_time = datetime.timedelta(seconds=60)
+ self.block.track = 'http://www.example.com/track'
+ self.block.handout = 'http://www.example.com/handout'
+ self.block.download_track = True
+ self.block.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source1.ogg']
+ self.block.download_video = True
+ self.block.transcripts = {'ua': 'ukrainian_translation.srt', 'ge': 'german_translation.srt'}
+
+ xml = self.block.definition_to_xml(self.file_system)
+ parser = etree.XMLParser(remove_blank_text=True)
+ xml_string = '''\
+
+ '''
+ expected = etree.XML(xml_string, parser=parser)
+ self.assertXmlEqual(expected, xml)
+
+ @patch('xblocks_contrib.video.video.edxval_api')
+ def test_export_to_xml_val_error(self, mock_val_api):
+ # Export should succeed without VAL data if video does not exist
+ mock_val_api.ValVideoNotFoundError = _MockValVideoNotFoundError
+ mock_val_api.export_to_xml = Mock(side_effect=mock_val_api.ValVideoNotFoundError)
+ self.block.edx_video_id = 'test_edx_video_id'
+
+ xml = self.block.definition_to_xml(self.file_system)
+ parser = etree.XMLParser(remove_blank_text=True)
+ xml_string = ''
+ expected = etree.XML(xml_string, parser=parser)
+ self.assertXmlEqual(expected, xml)
+
+ @patch('xblocks_contrib.video.video.edxval_api', None)
+ def test_export_to_xml_empty_end_time(self):
+ """
+ Test that we write the correct XML on export.
+ """
+ self.block.youtube_id_0_75 = 'izygArpw-Qo'
+ self.block.youtube_id_1_0 = 'p2Q6BrNhdh8'
+ self.block.youtube_id_1_25 = '1EeWXzPdhSA'
+ self.block.youtube_id_1_5 = 'rABDYkeK0x8'
+ self.block.show_captions = False
+ self.block.start_time = datetime.timedelta(seconds=5.0)
+ self.block.end_time = datetime.timedelta(seconds=0.0)
+ self.block.track = 'http://www.example.com/track'
+ self.block.download_track = True
+ self.block.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg']
+ self.block.download_video = True
+
+ xml = self.block.definition_to_xml(self.file_system)
+ parser = etree.XMLParser(remove_blank_text=True)
+ xml_string = '''\
+
+ '''
+ expected = etree.XML(xml_string, parser=parser)
+ self.assertXmlEqual(expected, xml)
+
+ @patch('xblocks_contrib.video.video.edxval_api', None)
+ def test_export_to_xml_empty_parameters(self):
+ """
+ Test XML export with defaults.
+ """
+ xml = self.block.definition_to_xml(self.file_system)
+ # Check that download_video field is also set to default (False) in xml for backward compatibility
+ expected = '\n'
+ assert expected == etree.tostring(xml, pretty_print=True).decode('utf-8')
+
+ @patch('xblocks_contrib.video.video.edxval_api', None)
+ def test_export_to_xml_with_transcripts_as_none(self):
+ """
+ Test XML export with transcripts being overridden to None.
+ """
+ self.block.transcripts = None
+ xml = self.block.definition_to_xml(self.file_system)
+ expected = b'\n'
+ assert expected == etree.tostring(xml, pretty_print=True)
+
+ @patch('xblocks_contrib.video.video.edxval_api', None)
+ def test_export_to_xml_invalid_characters_in_attributes(self):
+ """
+ Test XML export will *not* raise TypeError by lxml library if contains illegal characters.
+ The illegal characters in a String field are removed from the string instead.
+ """
+ self.block.display_name = 'Display\x1eName'
+ xml = self.block.definition_to_xml(self.file_system)
+ assert xml.get('display_name') == 'DisplayName'
+
+ @patch('xblocks_contrib.video.video.edxval_api', None)
+ def test_export_to_xml_unicode_characters(self):
+ """
+ Test XML export handles the unicode characters.
+ """
+ self.block.display_name = '这是文'
+ xml = self.block.definition_to_xml(self.file_system)
+ assert xml.get('display_name') == '这是文'
+
+
+@ddt.ddt
+@patch.object(settings, 'FEATURES', create=True, new={
+ 'FALLBACK_TO_ENGLISH_TRANSCRIPTS': False,
+})
+class VideoBlockStudentViewDataTestCase(unittest.TestCase):
+ """
+ Make sure that VideoBlock returns the expected student_view_data.
+ """
+
+ VIDEO_URL_1 = 'http://www.example.com/source_low.mp4'
+ VIDEO_URL_2 = 'http://www.example.com/source_med.mp4'
+ VIDEO_URL_3 = 'http://www.example.com/source_high.mp4'
+
+ @ddt.data(
+ # Ensure no extra data is returned if video block configured only for web display.
+ (
+ {'only_on_web': True},
+ {'only_on_web': True},
+ ),
+ # Ensure that YouTube URLs are included in `encoded_videos`, but not `all_sources`.
+ (
+ {
+ 'only_on_web': False,
+ 'youtube_id_1_0': 'abc',
+ 'html5_sources': [VIDEO_URL_2, VIDEO_URL_3],
+ },
+ {
+ 'only_on_web': False,
+ 'duration': None,
+ 'transcripts': {},
+ 'encoded_videos': {
+ 'fallback': {'url': VIDEO_URL_2, 'file_size': 0},
+ 'youtube': {'url': 'https://www.youtube.com/watch?v=abc', 'file_size': 0},
+ },
+ 'all_sources': [VIDEO_URL_2, VIDEO_URL_3],
+ },
+ ),
+ )
+ @ddt.unpack
+ def test_student_view_data(self, field_data, expected_student_view_data):
+ """
+ Ensure that student_view_data returns the expected results for video blocks.
+ """
+ block = instantiate_block(**field_data)
+ student_view_data = block.student_view_data()
+ assert student_view_data == expected_student_view_data
+
+ @patch(
+ 'xblocks_contrib.video.tests.test_utils.MockedVideoConfigService.is_hls_playback_enabled',
+ Mock(return_value=True)
+ )
+ @patch('xblocks_contrib.video.tests.test_utils.MockedVideoConfigService.available_translations',
+ Mock(return_value=['es']))
+ @patch('xblocks_contrib.video.video.edxval_api')
+ @patch('xblocks_contrib.video.tests.test_utils.MockedVideoConfigService.get_transcript')
+ def test_student_view_data_with_hls_flag(self, mock_get_transcript, mock_edxval_api):
+ mock_edxval_api.get_video_info_for_course_and_profiles.return_value = {}
+ mock_edxval_api.get_video_info.return_value = {
+ 'url': '/edxval/video/example',
+ 'edx_video_id': 'example_id',
+ 'duration': 111.0,
+ 'client_video_id': 'The example video',
+ 'encoded_videos': [
+ {
+ 'url': 'http://www.meowmix.com',
+ 'file_size': 25556,
+ 'bitrate': 9600,
+ 'profile': 'hls'
+ }
+ ]
+ }
+
+ mock_get_transcript.return_value = (
+ json.dumps({
+ "start": [10],
+ "end": [100],
+ "text": ["Hi, welcome to Edx."],
+ }),
+ 'edx.sjson',
+ 'application/json'
+ )
+
+ block = instantiate_block(edx_video_id='example_id', only_on_web=False)
+ block.runtime.handler_url = MagicMock()
+ student_view_data = block.student_view_data()
+ expected_video_data = {'hls': {'url': 'http://www.meowmix.com', 'file_size': 25556}}
+ self.assertDictEqual(student_view_data.get('encoded_videos'), expected_video_data)
+
+
+@ddt.ddt
+@patch.object(settings, 'YOUTUBE', create=True, new={
+ # YouTube JavaScript API
+ 'API': 'www.youtube.com/iframe_api',
+
+ # URL to get YouTube metadata
+ 'METADATA_URL': 'www.googleapis.com/youtube/v3/videos/',
+
+ # Current youtube api for requesting transcripts.
+ # For example: http://video.google.com/timedtext?lang=en&v=j_jEn79vS3g.
+ 'TEXT_API': {
+ 'url': 'video.google.com/timedtext',
+ 'params': {
+ 'lang': 'en',
+ 'v': 'set_youtube_id_of_11_symbols_here',
+ },
+ },
+
+ # Current web page mechanism for scraping transcript information from youtube video pages
+ 'TRANSCRIPTS': {
+ 'CAPTION_TRACKS_REGEX': r"captionTracks\"\:\[(?P[^\]]+)",
+ 'YOUTUBE_URL_BASE': 'https://www.youtube.com/watch?v=',
+ }
+})
+@patch.object(settings, 'CONTENTSTORE', create=True, new={
+ 'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
+ 'DOC_STORE_CONFIG': {
+ 'host': 'localhost',
+ 'db': 'test_xcontent_%s' % uuid4().hex,
+ },
+ # allow for additional options that can be keyed on a name, e.g. 'trashcan'
+ 'ADDITIONAL_OPTIONS': {
+ 'trashcan': {
+ 'bucket': 'trash_fs'
+ }
+ }
+})
+@patch.object(settings, 'FEATURES', create=True, new={
+ # The default value in {lms,cms}/envs/common.py and xmodule/tests/test_video.py should be consistent.
+ 'FALLBACK_TO_ENGLISH_TRANSCRIPTS': True,
+})
+class VideoBlockIndexingTestCase(unittest.TestCase):
+ """
+ Make sure that VideoBlock can format data for indexing as expected.
+ """
+
+ def test_video_with_no_subs_index_dictionary(self):
+ """
+ Test index dictionary of a video block without subtitles.
+ """
+ xml_data = '''
+
+ '''
+ block = instantiate_block(data=xml_data)
+ assert block.index_dictionary() == {'content': {'display_name': 'Test Video'}, 'content_type': 'Video'}
+
+ def test_video_with_multiple_transcripts_index_dictionary(self):
+ """
+ Test index dictionary of a video block with
+ two transcripts uploaded by a user.
+ """
+ xml_data_transcripts = '''
+
+ '''
+
+ block = instantiate_block(data=xml_data_transcripts)
+ save_to_store(SRT_FILEDATA, "subs_grmtran1.srt", 'text/srt', block.location)
+ save_to_store(CRO_SRT_FILEDATA, "subs_croatian1.srt", 'text/srt', block.location)
+ assert block.index_dictionary() ==\
+ {'content': {'display_name': 'Test Video',
+ 'transcript_ge': 'sprechen sie deutsch? Ja, ich spreche Deutsch',
+ 'transcript_hr': 'Dobar dan! Kako ste danas?'}, 'content_type': 'Video'}
+
+ def test_video_with_multiple_transcripts_translation_retrieval(self):
+ """
+ Test translation retrieval of a video block with
+ multiple transcripts uploaded by a user.
+ """
+ xml_data_transcripts = '''
+
+ '''
+
+ block = instantiate_block(data=xml_data_transcripts)
+ video_config_service = block.runtime.service(block, 'video_config')
+ translations = video_config_service.available_translations(block, block.get_transcripts_info())
+ assert sorted(translations) == sorted(['hr', 'ge'])
+
+ def test_video_with_no_transcripts_translation_retrieval(self):
+ """
+ Test translation retrieval of a video block with
+ no transcripts uploaded by a user- ie, that retrieval
+ does not throw an exception.
+ """
+ block = instantiate_block(data=None)
+ video_config_service = block.runtime.service(block, 'video_config')
+ translations_with_fallback = video_config_service.available_translations(block, block.get_transcripts_info())
+ assert translations_with_fallback == ['en']
+
+ with patch.dict(settings.FEATURES, FALLBACK_TO_ENGLISH_TRANSCRIPTS=False):
+ # Some organizations don't have English transcripts for all videos
+ # This feature makes it configurable
+ translations_no_fallback = video_config_service.available_translations(block, block.get_transcripts_info())
+ assert translations_no_fallback == []
+
+ @override_settings(ALL_LANGUAGES=ALL_LANGUAGES)
+ def test_video_with_language_do_not_have_transcripts_translation(self):
+ """
+ Test translation retrieval of a video block with
+ a language having no transcripts uploaded by a user.
+ """
+ xml_data_transcripts = '''
+
+ '''
+ block = instantiate_block(data=xml_data_transcripts)
+ video_config_service = block.runtime.service(block, 'video_config')
+ translations = video_config_service.available_translations(
+ block,
+ block.get_transcripts_info(),
+ verify_assets=False
+ )
+ assert translations != ['ur']
+
+ def assert_validation_message(self, validation, expected_msg):
+ """
+ Asserts that the validation message has all expected content.
+
+ Args:
+ validation (StudioValidation): A validation object.
+ expected_msg (string): An expected validation message.
+ """
+ assert not validation.empty
+ # Validation contains some warning/message
+ assert validation.summary
+ assert StudioValidationMessage.WARNING == validation.summary.type
+ assert expected_msg in validation.summary.text.replace('Urdu, Esperanto', 'Esperanto, Urdu')
+
+ @ddt.data(
+ (
+ '',
+ 'There is no transcript file associated with the Urdu language.'
+ ),
+ (
+ '',
+ 'There are no transcript files associated with the Esperanto, Urdu languages.'
+ ),
+ )
+ @ddt.unpack
+ @override_settings(ALL_LANGUAGES=ALL_LANGUAGES)
+ def test_no_transcript_validation_message(self, xml_transcripts, expected_validation_msg):
+ """
+ Test the validation message when no associated transcript file uploaded.
+ """
+ xml_data_transcripts = '''
+
+ '''.format(xml_transcripts=xml_transcripts)
+ block = instantiate_block(data=xml_data_transcripts)
+ validation = block.validate()
+ self.assert_validation_message(validation, expected_validation_msg)
+
+ def test_video_transcript_none(self):
+ """
+ Test video when transcripts is None.
+ """
+ block = instantiate_block(data=None)
+ block.transcripts = None
+ response = block.get_transcripts_info()
+ expected = {'transcripts': {}, 'sub': ''}
+ assert expected == response