diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a6feac71a2..cac4757218 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +LMS: Forums. Added handling for case where discussion module can get `None` as +value of lms.start in `lms/djangoapps/django_comment_client/utils.py` Studio, LMS: Make ModelTypes more strict about their expected content (for instance, Boolean, Integer, String), but also allow them to hold either the @@ -14,6 +16,8 @@ an Integer can contain 3 or '3'. This changed an update to the xblock library. LMS: Courses whose id matches a regex in the COURSES_WITH_UNSAFE_CODE Django setting now run entirely outside the Python sandbox. +Blades: Added tests for Video Alpha player. + Blades: Video Alpha bug fix for speed changing to 1.0 in Firefox. Blades: Additional event tracking added to Video Alpha: fullscreen switch, show/hide @@ -49,6 +53,9 @@ Blades: Staff debug info is now accessible for Graphical Slider Tool problems. Blades: For Video Alpha the events ready, play, pause, seek, and speed change are logged on the server (in the logs). +Common: all dates and times are not time zone aware datetimes. No code should create or use struct_times nor naive +datetimes. + Common: Developers can now have private Django settings files. Common: Safety code added to prevent anything above the vertical level in the diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index f769652493..f7f330f91e 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -3,6 +3,10 @@ from django.core.urlresolvers import reverse from .utils import parse_json, user, registration from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from contentstore.tests.test_course_settings import CourseTestCase +from xmodule.modulestore.tests.factories import CourseFactory +import datetime +from pytz import UTC class ContentStoreTestCase(ModuleStoreTestCase): @@ -162,3 +166,21 @@ class AuthTestCase(ContentStoreTestCase): self.assertEqual(resp.status_code, 302) # Logged in should work. + + +class ForumTestCase(CourseTestCase): + def setUp(self): + """ Creates the test course. """ + super(ForumTestCase, self).setUp() + self.course = CourseFactory.create(org='testX', number='727', display_name='Forum Course') + + def test_blackouts(self): + now = datetime.datetime.now(UTC) + self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in + [(now - datetime.timedelta(days=14), now - datetime.timedelta(days=11)), + (now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]] + self.assertTrue(self.course.forum_posts_allowed) + self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in + [(now - datetime.timedelta(days=14), now + datetime.timedelta(days=2)), + (now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]] + self.assertFalse(self.course.forum_posts_allowed) diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 35b15fe6ba..c6a383211f 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -112,9 +112,6 @@ TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE) for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items(): MITX_FEATURES[feature] = value -# load segment.io key, provide a dummy if it does not exist -SEGMENT_IO_KEY = ENV_TOKENS.get('SEGMENT_IO_KEY', '***REMOVED***') - LOGGING = get_logger_config(LOG_DIR, logging_env=ENV_TOKENS['LOGGING_ENV'], syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514), @@ -126,6 +123,13 @@ LOGGING = get_logger_config(LOG_DIR, with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file: AUTH_TOKENS = json.load(auth_file) +# If Segment.io key specified, load it and turn on Segment.io if the feature flag is set +# Note that this is the Studio key. There is a separate key for the LMS. +SEGMENT_IO_KEY = AUTH_TOKENS.get('SEGMENT_IO_KEY') +if SEGMENT_IO_KEY: + MITX_FEATURES['SEGMENT_IO'] = ENV_TOKENS.get('SEGMENT_IO', False) + + AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"] AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"] DATABASES = AUTH_TOKENS['DATABASES'] diff --git a/cms/envs/common.py b/cms/envs/common.py index 3afc6cdcbd..8551a56c41 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -32,13 +32,23 @@ from path import path MITX_FEATURES = { 'USE_DJANGO_PIPELINE': True, + 'GITHUB_PUSH': False, + 'ENABLE_DISCUSSION_SERVICE': False, + 'AUTH_USE_MIT_CERTIFICATES': False, - 'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests - 'STAFF_EMAIL': '', # email address for staff (eg to request course creation) + + # do not display video when running automated acceptance tests + 'STUB_VIDEO_FOR_TESTING': False, + + # email address for staff (eg to request course creation) + 'STAFF_EMAIL': '', + 'STUDIO_NPS_SURVEY': True, - 'SEGMENT_IO': True, + + # Segment.io - must explicitly turn it on for production + 'SEGMENT_IO': False, # Enable URL that shows information about the status of various services 'ENABLE_SERVICE_STATUS': False, diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 988856dbf4..07630bdf31 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -168,8 +168,14 @@ MITX_FEATURES['STUDIO_NPS_SURVEY'] = False # Enable URL that shows information about the status of variuous services MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True -# segment-io key for dev -SEGMENT_IO_KEY = 'mty8edrrsg' +############################# SEGMENT-IO ################################## + +# If there's an environment variable set, grab it and turn on Segment.io +# Note that this is the Studio key. There is a separate key for the LMS. +import os +SEGMENT_IO_KEY = os.environ.get('SEGMENT_IO_KEY') +if SEGMENT_IO_KEY: + MITX_FEATURES['SEGMENT_IO'] = True ##################################################################### diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index d0333cbe36..945c3a3cfa 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -10,7 +10,6 @@ import dateutil.parser from xmodule.modulestore import Location from xmodule.seq_module import SequenceDescriptor, SequenceModule -from xmodule.timeparse import parse_time from xmodule.util.decorators import lazyproperty from xmodule.graders import grader_from_conf import json @@ -645,8 +644,11 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): def start_date_text(self): def try_parse_iso_8601(text): try: - result = datetime.strptime(text, "%Y-%m-%dT%H:%M") - result = result.strftime("%b %d, %Y") + result = Date().from_json(text) + if result is None: + result = text.title() + else: + result = result.strftime("%b %d, %Y") except ValueError: result = text.title() @@ -670,8 +672,10 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): @property def forum_posts_allowed(self): + date_proxy = Date() try: - blackout_periods = [(parse_time(start), parse_time(end)) + blackout_periods = [(date_proxy.from_json(start), + date_proxy.from_json(end)) for start, end in self.discussion_blackouts] now = datetime.now(UTC()) @@ -701,7 +705,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): if self.last_eligible_appointment_date is None: raise ValueError("Last appointment date must be specified") self.registration_start_date = (self._try_parse_time('Registration_Start_Date') or - datetime.utcfromtimestamp(0)) + datetime.fromtimestamp(0, UTC())) self.registration_end_date = self._try_parse_time('Registration_End_Date') or self.last_eligible_appointment_date # do validation within the exam info: if self.registration_start_date > self.registration_end_date: @@ -720,7 +724,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): """ if key in self.exam_info: try: - return parse_time(self.exam_info[key]) + return Date().from_json(self.exam_info[key]) except ValueError as e: msg = "Exam {0} in course {1} loaded with a bad exam_info key '{2}': '{3}'".format(self.exam_name, self.course_id, self.exam_info[key], e) log.warning(msg) diff --git a/common/lib/xmodule/xmodule/fields.py b/common/lib/xmodule/xmodule/fields.py index 963b70204e..8a74856fc1 100644 --- a/common/lib/xmodule/xmodule/fields.py +++ b/common/lib/xmodule/xmodule/fields.py @@ -6,7 +6,7 @@ from xblock.core import ModelType import datetime import dateutil.parser -from django.utils.timezone import UTC +from pytz import UTC log = logging.getLogger(__name__) @@ -15,6 +15,28 @@ class Date(ModelType): ''' Date fields know how to parse and produce json (iso) compatible formats. Converts to tz aware datetimes. ''' + # See note below about not defaulting these + CURRENT_YEAR = datetime.datetime.now(UTC).year + PREVENT_DEFAULT_DAY_MON_SEED1 = datetime.datetime(CURRENT_YEAR, 1, 1, tzinfo=UTC) + PREVENT_DEFAULT_DAY_MON_SEED2 = datetime.datetime(CURRENT_YEAR, 2, 2, tzinfo=UTC) + + def _parse_date_wo_default_month_day(self, field): + """ + Parse the field as an iso string but prevent dateutils from defaulting the day or month while + allowing it to default the other fields. + """ + # It's not trivial to replace dateutil b/c parsing timezones as Z, +03:30, -400 is hard in python + # however, we don't want dateutil to default the month or day (but some tests at least expect + # us to default year); so, we'll see if dateutil uses the defaults for these the hard way + result = dateutil.parser.parse(field, default=self.PREVENT_DEFAULT_DAY_MON_SEED1) + result_other = dateutil.parser.parse(field, default=self.PREVENT_DEFAULT_DAY_MON_SEED2) + if result != result_other: + log.warning("Field {0} is missing month or day".format(self._name, field)) + return None + if result.tzinfo is None: + result = result.replace(tzinfo=UTC) + return result + def from_json(self, field): """ Parse an optional metadata key containing a time: if present, complain @@ -26,14 +48,11 @@ class Date(ModelType): elif field is "": return None elif isinstance(field, basestring): - result = dateutil.parser.parse(field) - if result.tzinfo is None: - result = result.replace(tzinfo=UTC()) - return result + return self._parse_date_wo_default_month_day(field) elif isinstance(field, (int, long, float)): - return datetime.datetime.fromtimestamp(field / 1000, UTC()) + return datetime.datetime.fromtimestamp(field / 1000, UTC) elif isinstance(field, time.struct_time): - return datetime.datetime.fromtimestamp(time.mktime(field), UTC()) + return datetime.datetime.fromtimestamp(time.mktime(field), UTC) elif isinstance(field, datetime.datetime): return field else: diff --git a/common/lib/xmodule/xmodule/js/fixtures/videoalpha.html b/common/lib/xmodule/xmodule/js/fixtures/videoalpha.html new file mode 100644 index 0000000000..bccf5df2cc --- /dev/null +++ b/common/lib/xmodule/xmodule/js/fixtures/videoalpha.html @@ -0,0 +1,23 @@ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/fixtures/videoalpha_html5.html b/common/lib/xmodule/xmodule/js/fixtures/videoalpha_html5.html new file mode 100644 index 0000000000..6088d07f2b --- /dev/null +++ b/common/lib/xmodule/xmodule/js/fixtures/videoalpha_html5.html @@ -0,0 +1,27 @@ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/spec/helper.coffee b/common/lib/xmodule/xmodule/js/spec/helper.coffee index 5cf75366d8..5f7fc27be0 100644 --- a/common/lib/xmodule/xmodule/js/spec/helper.coffee +++ b/common/lib/xmodule/xmodule/js/spec/helper.coffee @@ -20,10 +20,25 @@ jasmine.stubbedMetadata = bogus: duration: 100 +jasmine.fireEvent = (el, eventName) -> + if document.createEvent + event = document.createEvent "HTMLEvents" + event.initEvent eventName, true, true + else + event = document.createEventObject() + event.eventType = eventName + event.eventName = eventName + if document.createEvent + el.dispatchEvent(event) + else + el.fireEvent("on" + event.eventType, event) + jasmine.stubbedCaption = start: [0, 10000, 20000, 30000] text: ['Caption at 0', 'Caption at 10000', 'Caption at 20000', 'Caption at 30000'] +jasmine.stubbedHtml5Speeds = ['0.75', '1.0', '1.25', '1.50'] + jasmine.stubRequests = -> spyOn($, 'ajax').andCallFake (settings) -> if match = settings.url.match /youtube\.com\/.+\/videos\/(.+)\?v=2&alt=jsonc/ @@ -41,9 +56,12 @@ jasmine.stubRequests = -> throw "External request attempted for #{settings.url}, which is not defined." jasmine.stubYoutubePlayer = -> - YT.Player = -> jasmine.createSpyObj 'YT.Player', ['cueVideoById', 'getVideoEmbedCode', + YT.Player = -> + obj = jasmine.createSpyObj 'YT.Player', ['cueVideoById', 'getVideoEmbedCode', 'getCurrentTime', 'getPlayerState', 'getVolume', 'setVolume', 'loadVideoById', - 'playVideo', 'pauseVideo', 'seekTo'] + 'playVideo', 'pauseVideo', 'seekTo', 'getDuration', 'getAvailablePlaybackRates', 'setPlaybackRate'] + obj['getAvailablePlaybackRates'] = jasmine.createSpy('getAvailablePlaybackRates').andReturn [0.75, 1.0, 1.25, 1.5] + obj jasmine.stubVideoPlayer = (context, enableParts, createPlayer=true) -> enableParts = [enableParts] unless $.isArray(enableParts) @@ -60,6 +78,21 @@ jasmine.stubVideoPlayer = (context, enableParts, createPlayer=true) -> if createPlayer return new VideoPlayer(video: context.video) +jasmine.stubVideoPlayerAlpha = (context, enableParts, createPlayer=true, html5=false) -> + suite = context.suite + currentPartName = suite.description while suite = suite.parentSuite + if html5 == false + loadFixtures 'videoalpha.html' + else + loadFixtures 'videoalpha_html5.html' + jasmine.stubRequests() + YT.Player = undefined + window.OldVideoPlayerAlpha = undefined + context.video = new VideoAlpha '#example', '.75:slowerSpeedYoutubeId,1.0:normalSpeedYoutubeId' + jasmine.stubYoutubePlayer() + if createPlayer + return new VideoPlayerAlpha(video: context.video) + # Stub jQuery.cookie $.cookie = jasmine.createSpy('jQuery.cookie').andReturn '1.0' diff --git a/common/lib/xmodule/xmodule/js/spec/videoalpha/display/html5_video.coffee b/common/lib/xmodule/xmodule/js/spec/videoalpha/display/html5_video.coffee new file mode 100644 index 0000000000..176ceb7827 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/videoalpha/display/html5_video.coffee @@ -0,0 +1,311 @@ +describe 'VideoAlpha HTML5Video', -> + playbackRates = [0.75, 1.0, 1.25, 1.5] + STATUS = window.YT.PlayerState + playerVars = + controls: 0 + wmode: 'transparent' + rel: 0 + showinfo: 0 + enablejsapi: 1 + modestbranding: 1 + html5: 1 + file = window.location.href.replace(/\/common(.*)$/, '') + '/test_root/data/videoalpha/gizmo' + html5Sources = + mp4: "#{file}.mp4" + webm: "#{file}.webm" + ogg: "#{file}.ogv" + onReady = jasmine.createSpy 'onReady' + onStateChange = jasmine.createSpy 'onStateChange' + + beforeEach -> + loadFixtures 'videoalpha_html5.html' + @el = $('#example').find('.video') + @player = new window.HTML5Video.Player @el, + playerVars: playerVars, + videoSources: html5Sources, + events: + onReady: onReady + onStateChange: onStateChange + + @videoEl = @el.find('.video-player video').get(0) + + it 'PlayerState', -> + expect(HTML5Video.PlayerState).toEqual STATUS + + describe 'constructor', -> + it 'create an html5 video element', -> + expect(@el.find('.video-player div')).toContain 'video' + + it 'check if sources are created in correct way', -> + sources = $(@videoEl).find('source') + videoTypes = [] + videoSources = [] + $.each html5Sources, (index, source) -> + videoTypes.push index + videoSources.push source + $.each sources, (index, source) -> + s = $(source) + expect($.inArray(s.attr('src'), videoSources)).not.toEqual -1 + expect($.inArray(s.attr('type').replace('video/', ''), videoTypes)) + .not.toEqual -1 + + it 'check if click event is handled on the player', -> + expect(@videoEl).toHandle 'click' + + # NOTE: According to + # + # https://github.com/ariya/phantomjs/wiki/Supported-Web-Standards#unsupported-features + # + # Video and Audio (due to the nature of PhantomJS) are not supported. After discussion + # with William Daly, some tests are disabled (Jenkins uses phantomjs for running tests + # and those tests fail). + # + # During code review, please enable the test below (change "xdescribe" to "describe" + # to enable the test). + xdescribe 'events:', -> + + beforeEach -> + spyOn(@player, 'callStateChangeCallback').andCallThrough() + + describe 'click', -> + describe 'when player is paused', -> + beforeEach -> + spyOn(@videoEl, 'play').andCallThrough() + @player.playerState = STATUS.PAUSED + $(@videoEl).trigger('click') + + it 'native play event was called', -> + expect(@videoEl.play).toHaveBeenCalled() + + it 'player state was changed', -> + expect(@player.playerState).toBe STATUS.PLAYING + + it 'callback was called', -> + expect(@player.callStateChangeCallback).toHaveBeenCalled() + + describe 'when player is played', -> + + beforeEach -> + spyOn(@videoEl, 'pause').andCallThrough() + @player.playerState = STATUS.PLAYING + $(@videoEl).trigger('click') + + it 'native pause event was called', -> + expect(@videoEl.pause).toHaveBeenCalled() + + it 'player state was changed', -> + expect(@player.playerState).toBe STATUS.PAUSED + + it 'callback was called', -> + expect(@player.callStateChangeCallback).toHaveBeenCalled() + + describe 'play', -> + + beforeEach -> + spyOn(@videoEl, 'play').andCallThrough() + @player.playerState = STATUS.PAUSED + @videoEl.play() + + it 'native event was called', -> + expect(@videoEl.play).toHaveBeenCalled() + + it 'player state was changed', -> + waitsFor ( -> + @player.playerState != HTML5Video.PlayerState.PAUSED + ), 'Player state should be changed', 1000 + + runs -> + expect(@player.playerState).toBe STATUS.PLAYING + + it 'callback was called', -> + waitsFor ( -> + @player.playerState != STATUS.PAUSED + ), 'Player state should be changed', 1000 + + runs -> + expect(@player.callStateChangeCallback).toHaveBeenCalled() + + describe 'pause', -> + + beforeEach -> + spyOn(@videoEl, 'pause').andCallThrough() + @videoEl.play() + @videoEl.pause() + + it 'native event was called', -> + expect(@videoEl.pause).toHaveBeenCalled() + + it 'player state was changed', -> + waitsFor ( -> + @player.playerState != STATUS.UNSTARTED + ), 'Player state should be changed', 1000 + + runs -> + expect(@player.playerState).toBe STATUS.PAUSED + + it 'callback was called', -> + waitsFor ( -> + @player.playerState != HTML5Video.PlayerState.UNSTARTED + ), 'Player state should be changed', 1000 + + runs -> + expect(@player.callStateChangeCallback).toHaveBeenCalled() + + describe 'canplay', -> + + beforeEach -> + waitsFor ( -> + @player.playerState != STATUS.UNSTARTED + ), 'Video cannot be played', 1000 + + it 'player state was changed', -> + runs -> + expect(@player.playerState).toBe STATUS.PAUSED + + it 'end property was defined', -> + runs -> + expect(@player.end).not.toBeNull() + + it 'start position was defined', -> + runs -> + expect(@videoEl.currentTime).toBe(@player.start) + + it 'callback was called', -> + runs -> + expect(@player.config.events.onReady).toHaveBeenCalled() + + describe 'ended', -> + beforeEach -> + waitsFor ( -> + @player.playerState != STATUS.UNSTARTED + ), 'Video cannot be played', 1000 + + it 'player state was changed', -> + runs -> + jasmine.fireEvent @videoEl, "ended" + expect(@player.playerState).toBe STATUS.ENDED + + it 'callback was called', -> + jasmine.fireEvent @videoEl, "ended" + expect(@player.callStateChangeCallback).toHaveBeenCalled() + + describe 'timeupdate', -> + + beforeEach -> + spyOn(@videoEl, 'pause').andCallThrough() + waitsFor ( -> + @player.playerState != STATUS.UNSTARTED + ), 'Video cannot be played', 1000 + + it 'player should be paused', -> + runs -> + @player.end = 3 + @videoEl.currentTime = 5 + jasmine.fireEvent @videoEl, "timeupdate" + expect(@videoEl.pause).toHaveBeenCalled() + + it 'end param should be re-defined', -> + runs -> + @player.end = 3 + @videoEl.currentTime = 5 + jasmine.fireEvent @videoEl, "timeupdate" + expect(@player.end).toBe @videoEl.duration + + # NOTE: According to + # + # https://github.com/ariya/phantomjs/wiki/Supported-Web-Standards#unsupported-features + # + # Video and Audio (due to the nature of PhantomJS) are not supported. After discussion + # with William Daly, some tests are disabled (Jenkins uses phantomjs for running tests + # and those tests fail). + # + # During code review, please enable the test below (change "xdescribe" to "describe" + # to enable the test). + xdescribe 'methods:', -> + + beforeEach -> + waitsFor ( -> + @volume = @videoEl.volume + @seek = @videoEl.currentTime + @player.playerState == STATUS.PAUSED + ), 'Video cannot be played', 1000 + + + it 'pauseVideo', -> + spyOn(@videoEl, 'pause').andCallThrough() + @player.pauseVideo() + expect(@videoEl.pause).toHaveBeenCalled() + + describe 'seekTo', -> + + it 'set new correct value', -> + runs -> + @player.seekTo(2) + expect(@videoEl.currentTime).toBe 2 + + it 'set new inccorrect values', -> + runs -> + @player.seekTo(-50) + expect(@videoEl.currentTime).toBe @seek + @player.seekTo('5') + expect(@videoEl.currentTime).toBe @seek + @player.seekTo(500000) + expect(@videoEl.currentTime).toBe @seek + + describe 'setVolume', -> + + it 'set new correct value', -> + runs -> + @player.setVolume(50) + expect(@videoEl.volume).toBe 50*0.01 + + it 'set new inccorrect values', -> + runs -> + @player.setVolume(-50) + expect(@videoEl.volume).toBe @volume + @player.setVolume('5') + expect(@videoEl.volume).toBe @volume + @player.setVolume(500000) + expect(@videoEl.volume).toBe @volume + + it 'getCurrentTime', -> + runs -> + @videoEl.currentTime = 3 + expect(@player.getCurrentTime()).toBe @videoEl.currentTime + + it 'playVideo', -> + runs -> + spyOn(@videoEl, 'play').andCallThrough() + @player.playVideo() + expect(@videoEl.play).toHaveBeenCalled() + + it 'getPlayerState', -> + runs -> + @player.playerState = STATUS.PLAYING + expect(@player.getPlayerState()).toBe STATUS.PLAYING + @player.playerState = STATUS.ENDED + expect(@player.getPlayerState()).toBe STATUS.ENDED + + it 'getVolume', -> + runs -> + @volume = @videoEl.volume = 0.5 + expect(@player.getVolume()).toBe @volume + + it 'getDuration', -> + runs -> + @duration = @videoEl.duration + expect(@player.getDuration()).toBe @duration + + describe 'setPlaybackRate', -> + it 'set a correct value', -> + @playbackRate = 1.5 + @player.setPlaybackRate @playbackRate + expect(@videoEl.playbackRate).toBe @playbackRate + + it 'set NaN value', -> + @playbackRate = NaN + @player.setPlaybackRate @playbackRate + expect(@videoEl.playbackRate).toBe 1.0 + + it 'getAvailablePlaybackRates', -> + expect(@player.getAvailablePlaybackRates()).toEqual playbackRates diff --git a/common/lib/xmodule/xmodule/js/spec/videoalpha/display/video_caption_spec.coffee b/common/lib/xmodule/xmodule/js/spec/videoalpha/display/video_caption_spec.coffee new file mode 100644 index 0000000000..4bd237b81d --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/videoalpha/display/video_caption_spec.coffee @@ -0,0 +1,373 @@ +describe 'VideoCaptionAlpha', -> + + beforeEach -> + spyOn(VideoCaptionAlpha.prototype, 'fetchCaption').andCallThrough() + spyOn($, 'ajaxWithPrefix').andCallThrough() + window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false + + afterEach -> + YT.Player = undefined + $.fn.scrollTo.reset() + $('.subtitles').remove() + + describe 'constructor', -> + + describe 'always', -> + + beforeEach -> + @player = jasmine.stubVideoPlayerAlpha @ + @caption = @player.caption + + it 'set the youtube id', -> + expect(@caption.youtubeId).toEqual 'normalSpeedYoutubeId' + + it 'create the caption element', -> + expect($('.video')).toContain 'ol.subtitles' + + it 'add caption control to video player', -> + expect($('.video')).toContain 'a.hide-subtitles' + + it 'fetch the caption', -> + expect(@caption.loaded).toBeTruthy() + expect(@caption.fetchCaption).toHaveBeenCalled() + expect($.ajaxWithPrefix).toHaveBeenCalledWith + url: @caption.captionURL() + notifyOnError: false + success: jasmine.any(Function) + + it 'bind window resize event', -> + expect($(window)).toHandleWith 'resize', @caption.resize + + it 'bind the hide caption button', -> + expect($('.hide-subtitles')).toHandleWith 'click', @caption.toggle + + it 'bind the mouse movement', -> + expect($('.subtitles')).toHandleWith 'mouseover', @caption.onMouseEnter + expect($('.subtitles')).toHandleWith 'mouseout', @caption.onMouseLeave + expect($('.subtitles')).toHandleWith 'mousemove', @caption.onMovement + expect($('.subtitles')).toHandleWith 'mousewheel', @caption.onMovement + expect($('.subtitles')).toHandleWith 'DOMMouseScroll', @caption.onMovement + + describe 'when on a non touch-based device', -> + + beforeEach -> + @player = jasmine.stubVideoPlayerAlpha @ + @caption = @player.caption + + it 'render the caption', -> + captionsData = jasmine.stubbedCaption + $('.subtitles li[data-index]').each (index, link) => + expect($(link)).toHaveData 'index', index + expect($(link)).toHaveData 'start', captionsData.start[index] + expect($(link)).toHaveText captionsData.text[index] + + it 'add a padding element to caption', -> + expect($('.subtitles li:first')).toBe '.spacing' + expect($('.subtitles li:last')).toBe '.spacing' + + it 'bind all the caption link', -> + $('.subtitles li[data-index]').each (index, link) => + expect($(link)).toHandleWith 'click', @caption.seekPlayer + + it 'set rendered to true', -> + expect(@caption.rendered).toBeTruthy() + + describe 'when on a touch-based device', -> + + beforeEach -> + window.onTouchBasedDevice.andReturn true + @player = jasmine.stubVideoPlayerAlpha @ + @caption = @player.caption + + it 'show explaination message', -> + expect($('.subtitles li')).toHaveHtml "Caption will be displayed when you start playing the video." + + it 'does not set rendered to true', -> + expect(@caption.rendered).toBeFalsy() + + describe 'mouse movement', -> + + beforeEach -> + @player = jasmine.stubVideoPlayerAlpha @ + @caption = @player.caption + window.setTimeout.andReturn(100) + spyOn window, 'clearTimeout' + + describe 'when cursor is outside of the caption box', -> + + beforeEach -> + $(window).trigger jQuery.Event 'mousemove' + + it 'does not set freezing timeout', -> + expect(@caption.frozen).toBeFalsy() + + describe 'when cursor is in the caption box', -> + + beforeEach -> + $('.subtitles').trigger jQuery.Event 'mouseenter' + + it 'set the freezing timeout', -> + expect(@caption.frozen).toEqual 100 + + describe 'when the cursor is moving', -> + beforeEach -> + $('.subtitles').trigger jQuery.Event 'mousemove' + + it 'reset the freezing timeout', -> + expect(window.clearTimeout).toHaveBeenCalledWith 100 + + describe 'when the mouse is scrolling', -> + beforeEach -> + $('.subtitles').trigger jQuery.Event 'mousewheel' + + it 'reset the freezing timeout', -> + expect(window.clearTimeout).toHaveBeenCalledWith 100 + + describe 'when cursor is moving out of the caption box', -> + beforeEach -> + @caption.frozen = 100 + $.fn.scrollTo.reset() + + describe 'always', -> + beforeEach -> + $('.subtitles').trigger jQuery.Event 'mouseout' + + it 'reset the freezing timeout', -> + expect(window.clearTimeout).toHaveBeenCalledWith 100 + + it 'unfreeze the caption', -> + expect(@caption.frozen).toBeNull() + + describe 'when the player is playing', -> + beforeEach -> + @caption.playing = true + $('.subtitles li[data-index]:first').addClass 'current' + $('.subtitles').trigger jQuery.Event 'mouseout' + + it 'scroll the caption', -> + expect($.fn.scrollTo).toHaveBeenCalled() + + describe 'when the player is not playing', -> + beforeEach -> + @caption.playing = false + $('.subtitles').trigger jQuery.Event 'mouseout' + + it 'does not scroll the caption', -> + expect($.fn.scrollTo).not.toHaveBeenCalled() + + describe 'search', -> + + beforeEach -> + @player = jasmine.stubVideoPlayerAlpha @ + @caption = @player.caption + + it 'return a correct caption index', -> + expect(@caption.search(0)).toEqual 0 + expect(@caption.search(9999)).toEqual 0 + expect(@caption.search(10000)).toEqual 1 + expect(@caption.search(15000)).toEqual 1 + expect(@caption.search(30000)).toEqual 3 + expect(@caption.search(30001)).toEqual 3 + + describe 'play', -> + describe 'when the caption was not rendered', -> + beforeEach -> + window.onTouchBasedDevice.andReturn true + @player = jasmine.stubVideoPlayerAlpha @ + @caption = @player.caption + @caption.play() + + it 'render the caption', -> + captionsData = jasmine.stubbedCaption + $('.subtitles li[data-index]').each (index, link) => + expect($(link)).toHaveData 'index', index + expect($(link)).toHaveData 'start', captionsData.start[index] + expect($(link)).toHaveText captionsData.text[index] + + it 'add a padding element to caption', -> + expect($('.subtitles li:first')).toBe '.spacing' + expect($('.subtitles li:last')).toBe '.spacing' + + it 'bind all the caption link', -> + $('.subtitles li[data-index]').each (index, link) => + expect($(link)).toHandleWith 'click', @caption.seekPlayer + + it 'set rendered to true', -> + expect(@caption.rendered).toBeTruthy() + + it 'set playing to true', -> + expect(@caption.playing).toBeTruthy() + + describe 'pause', -> + beforeEach -> + @player = jasmine.stubVideoPlayerAlpha @ + @caption = @player.caption + @caption.playing = true + @caption.pause() + + it 'set playing to false', -> + expect(@caption.playing).toBeFalsy() + + describe 'updatePlayTime', -> + + beforeEach -> + @player = jasmine.stubVideoPlayerAlpha @ + @caption = @player.caption + + describe 'when the video speed is 1.0x', -> + beforeEach -> + @caption.currentSpeed = '1.0' + @caption.updatePlayTime 25.000 + + it 'search the caption based on time', -> + expect(@caption.currentIndex).toEqual 2 + + describe 'when the video speed is not 1.0x', -> + beforeEach -> + @caption.currentSpeed = '0.75' + @caption.updatePlayTime 25.000 + + it 'search the caption based on 1.0x speed', -> + expect(@caption.currentIndex).toEqual 1 + + describe 'when the index is not the same', -> + beforeEach -> + @caption.currentIndex = 1 + $('.subtitles li[data-index=1]').addClass 'current' + @caption.updatePlayTime 25.000 + + it 'deactivate the previous caption', -> + expect($('.subtitles li[data-index=1]')).not.toHaveClass 'current' + + it 'activate new caption', -> + expect($('.subtitles li[data-index=2]')).toHaveClass 'current' + + it 'save new index', -> + expect(@caption.currentIndex).toEqual 2 + + it 'scroll caption to new position', -> + expect($.fn.scrollTo).toHaveBeenCalled() + + describe 'when the index is the same', -> + beforeEach -> + @caption.currentIndex = 1 + $('.subtitles li[data-index=1]').addClass 'current' + @caption.updatePlayTime 15.000 + + it 'does not change current subtitle', -> + expect($('.subtitles li[data-index=1]')).toHaveClass 'current' + + describe 'resize', -> + + beforeEach -> + @player = jasmine.stubVideoPlayerAlpha @ + @caption = @player.caption + $('.subtitles li[data-index=1]').addClass 'current' + @caption.resize() + + it 'set the height of caption container', -> + expect(parseInt($('.subtitles').css('maxHeight'))).toBeCloseTo $('.video-wrapper').height(), 2 + + it 'set the height of caption spacing', -> + firstSpacing = Math.abs(parseInt($('.subtitles .spacing:first').css('height'))) + lastSpacing = Math.abs(parseInt($('.subtitles .spacing:last').css('height'))) + + expect(firstSpacing - @caption.topSpacingHeight()).toBeLessThan 1 + expect(lastSpacing - @caption.bottomSpacingHeight()).toBeLessThan 1 + + it 'scroll caption to new position', -> + expect($.fn.scrollTo).toHaveBeenCalled() + + describe 'scrollCaption', -> + + beforeEach -> + @player = jasmine.stubVideoPlayerAlpha @ + @caption = @player.caption + + describe 'when frozen', -> + beforeEach -> + @caption.frozen = true + $('.subtitles li[data-index=1]').addClass 'current' + @caption.scrollCaption() + + it 'does not scroll the caption', -> + expect($.fn.scrollTo).not.toHaveBeenCalled() + + describe 'when not frozen', -> + beforeEach -> + @caption.frozen = false + + describe 'when there is no current caption', -> + beforeEach -> + @caption.scrollCaption() + + it 'does not scroll the caption', -> + expect($.fn.scrollTo).not.toHaveBeenCalled() + + describe 'when there is a current caption', -> + beforeEach -> + $('.subtitles li[data-index=1]').addClass 'current' + @caption.scrollCaption() + + it 'scroll to current caption', -> + offset = -0.5 * ($('.video-wrapper').height() - $('.subtitles .current:first').height()) + + expect($.fn.scrollTo).toHaveBeenCalledWith $('.subtitles .current:first', @caption.el), + offset: offset + + describe 'seekPlayer', -> + + beforeEach -> + @player = jasmine.stubVideoPlayerAlpha @ + @caption = @player.caption + $(@caption).bind 'seek', (event, time) => @time = time + + describe 'when the video speed is 1.0x', -> + beforeEach -> + @caption.currentSpeed = '1.0' + $('.subtitles li[data-start="30000"]').trigger('click') + + it 'trigger seek event with the correct time', -> + expect(@player.currentTime).toEqual 30.000 + + describe 'when the video speed is not 1.0x', -> + beforeEach -> + @caption.currentSpeed = '0.75' + $('.subtitles li[data-start="30000"]').trigger('click') + + it 'trigger seek event with the correct time', -> + expect(@player.currentTime).toEqual 40.000 + + describe 'toggle', -> + beforeEach -> + @player = jasmine.stubVideoPlayerAlpha @ + spyOn @video, 'log' + @caption = @player.caption + $('.subtitles li[data-index=1]').addClass 'current' + + describe 'when the caption is visible', -> + beforeEach -> + @caption.el.removeClass 'closed' + @caption.toggle jQuery.Event('click') + + it 'log the hide_transcript event', -> + expect(@video.log).toHaveBeenCalledWith 'hide_transcript', + currentTime: @player.currentTime + + it 'hide the caption', -> + expect(@caption.el).toHaveClass 'closed' + + describe 'when the caption is hidden', -> + beforeEach -> + @caption.el.addClass 'closed' + @caption.toggle jQuery.Event('click') + + it 'log the show_transcript event', -> + expect(@video.log).toHaveBeenCalledWith 'show_transcript', + currentTime: @player.currentTime + + it 'show the caption', -> + expect(@caption.el).not.toHaveClass 'closed' + + it 'scroll the caption', -> + expect($.fn.scrollTo).toHaveBeenCalled() diff --git a/common/lib/xmodule/xmodule/js/spec/videoalpha/display/video_control_spec.coffee b/common/lib/xmodule/xmodule/js/spec/videoalpha/display/video_control_spec.coffee new file mode 100644 index 0000000000..a4dc8739d8 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/videoalpha/display/video_control_spec.coffee @@ -0,0 +1,103 @@ +describe 'VideoControlAlpha', -> + beforeEach -> + window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false + loadFixtures 'videoalpha.html' + $('.video-controls').html '' + + describe 'constructor', -> + + it 'render the video controls', -> + @control = new window.VideoControlAlpha(el: $('.video-controls')) + expect($('.video-controls')).toContain + ['.slider', 'ul.vcr', 'a.play', '.vidtime', '.add-fullscreen'].join(',') + expect($('.video-controls').find('.vidtime')).toHaveText '0:00 / 0:00' + + it 'bind the playback button', -> + @control = new window.VideoControlAlpha(el: $('.video-controls')) + expect($('.video_control')).toHandleWith 'click', @control.togglePlayback + + describe 'when on a touch based device', -> + beforeEach -> + window.onTouchBasedDevice.andReturn true + @control = new window.VideoControlAlpha(el: $('.video-controls')) + + it 'does not add the play class to video control', -> + expect($('.video_control')).not.toHaveClass 'play' + expect($('.video_control')).not.toHaveHtml 'Play' + + + describe 'when on a non-touch based device', -> + + beforeEach -> + @control = new window.VideoControlAlpha(el: $('.video-controls')) + + it 'add the play class to video control', -> + expect($('.video_control')).toHaveClass 'play' + expect($('.video_control')).toHaveHtml 'Play' + + describe 'play', -> + + beforeEach -> + @control = new window.VideoControlAlpha(el: $('.video-controls')) + @control.play() + + it 'switch playback button to play state', -> + expect($('.video_control')).not.toHaveClass 'play' + expect($('.video_control')).toHaveClass 'pause' + expect($('.video_control')).toHaveHtml 'Pause' + + describe 'pause', -> + + beforeEach -> + @control = new window.VideoControlAlpha(el: $('.video-controls')) + @control.pause() + + it 'switch playback button to pause state', -> + expect($('.video_control')).not.toHaveClass 'pause' + expect($('.video_control')).toHaveClass 'play' + expect($('.video_control')).toHaveHtml 'Play' + + describe 'togglePlayback', -> + + beforeEach -> + @control = new window.VideoControlAlpha(el: $('.video-controls')) + + describe 'when the control does not have play or pause class', -> + beforeEach -> + $('.video_control').removeClass('play').removeClass('pause') + + describe 'when the video is playing', -> + beforeEach -> + $('.video_control').addClass('play') + spyOnEvent @control, 'pause' + @control.togglePlayback jQuery.Event('click') + + it 'does not trigger the pause event', -> + expect('pause').not.toHaveBeenTriggeredOn @control + + describe 'when the video is paused', -> + beforeEach -> + $('.video_control').addClass('pause') + spyOnEvent @control, 'play' + @control.togglePlayback jQuery.Event('click') + + it 'does not trigger the play event', -> + expect('play').not.toHaveBeenTriggeredOn @control + + describe 'when the video is playing', -> + beforeEach -> + spyOnEvent @control, 'pause' + $('.video_control').addClass 'pause' + @control.togglePlayback jQuery.Event('click') + + it 'trigger the pause event', -> + expect('pause').toHaveBeenTriggeredOn @control + + describe 'when the video is paused', -> + beforeEach -> + spyOnEvent @control, 'play' + $('.video_control').addClass 'play' + @control.togglePlayback jQuery.Event('click') + + it 'trigger the play event', -> + expect('play').toHaveBeenTriggeredOn @control diff --git a/common/lib/xmodule/xmodule/js/spec/videoalpha/display/video_player_spec.coffee b/common/lib/xmodule/xmodule/js/spec/videoalpha/display/video_player_spec.coffee new file mode 100644 index 0000000000..e9a5ca30b4 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/videoalpha/display/video_player_spec.coffee @@ -0,0 +1,561 @@ +describe 'VideoPlayerAlpha', -> + playerVars = + controls: 0 + wmode: 'transparent' + rel: 0 + showinfo: 0 + enablejsapi: 1 + modestbranding: 1 + html5: 1 + + beforeEach -> + window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false + # It tries to call methods of VideoProgressSlider on Spy + for part in ['VideoCaptionAlpha', 'VideoSpeedControlAlpha', 'VideoVolumeControlAlpha', 'VideoProgressSliderAlpha', 'VideoControlAlpha'] + spyOn(window[part].prototype, 'initialize').andCallThrough() + + + afterEach -> + YT.Player = undefined + + describe 'constructor', -> + beforeEach -> + $.fn.qtip.andCallFake -> + $(this).data('qtip', true) + + describe 'always', -> + beforeEach -> + jasmine.stubVideoPlayerAlpha @, [], false + $('.video').append $('
') + @player = new VideoPlayerAlpha video: @video + + it 'instanticate current time to zero', -> + expect(@player.currentTime).toEqual 0 + + it 'set the element', -> + expect(@player.el).toHaveId 'video_id' + + it 'create video control', -> + expect(window.VideoControlAlpha.prototype.initialize).toHaveBeenCalled() + expect(@player.control).toBeDefined() + expect(@player.control.el).toBe $('.video-controls', @player.el) + + it 'create video caption', -> + expect(window.VideoCaptionAlpha.prototype.initialize).toHaveBeenCalled() + expect(@player.caption).toBeDefined() + expect(@player.caption.el).toBe @player.el + expect(@player.caption.youtubeId).toEqual 'normalSpeedYoutubeId' + expect(@player.caption.currentSpeed).toEqual '1.0' + expect(@player.caption.captionAssetPath).toEqual '/static/subs/' + + it 'create video speed control', -> + expect(window.VideoSpeedControlAlpha.prototype.initialize).toHaveBeenCalled() + expect(@player.speedControl).toBeDefined() + expect(@player.speedControl.el).toBe $('.secondary-controls', @player.el) + expect(@player.speedControl.speeds).toEqual ['0.75', '1.0'] + expect(@player.speedControl.currentSpeed).toEqual '1.0' + + it 'create video progress slider', -> + expect(window.VideoSpeedControlAlpha.prototype.initialize).toHaveBeenCalled() + expect(@player.progressSlider).toBeDefined() + expect(@player.progressSlider.el).toBe $('.slider', @player.el) + + it 'bind to video control play event', -> + expect($(@player.control)).toHandleWith 'play', @player.play + + it 'bind to video control pause event', -> + expect($(@player.control)).toHandleWith 'pause', @player.pause + + it 'bind to video caption seek event', -> + expect($(@player.caption)).toHandleWith 'caption_seek', @player.onSeek + + it 'bind to video speed control speedChange event', -> + expect($(@player.speedControl)).toHandleWith 'speedChange', @player.onSpeedChange + + it 'bind to video progress slider seek event', -> + expect($(@player.progressSlider)).toHandleWith 'slide_seek', @player.onSeek + + it 'bind to video volume control volumeChange event', -> + expect($(@player.volumeControl)).toHandleWith 'volumeChange', @player.onVolumeChange + + it 'bind to key press', -> + expect($(document.documentElement)).toHandleWith 'keyup', @player.bindExitFullScreen + + it 'bind to fullscreen switching button', -> + expect($('.add-fullscreen')).toHandleWith 'click', @player.toggleFullScreen + + it 'create Youtube player', -> + jasmine.stubVideoPlayerAlpha @, [], false + $('.video').append $('
') + spyOn YT, 'Player' + @player = new VideoPlayerAlpha video: @video + expect(YT.Player).toHaveBeenCalledWith('id', { + playerVars: playerVars + videoId: 'normalSpeedYoutubeId' + events: + onReady: @player.onReady + onStateChange: @player.onStateChange + onPlaybackQualityChange: @player.onPlaybackQualityChange + }) + + it 'create HTML5 player', -> + jasmine.stubVideoPlayerAlpha @, [], false, true + spyOn HTML5Video, 'Player' + $('.video').append $('
') + @player = new VideoPlayerAlpha video: @video + expect(HTML5Video.Player).toHaveBeenCalledWith @video.el, + playerVars: playerVars + videoSources: @video.html5Sources + events: + onReady: @player.onReady + onStateChange: @player.onStateChange + + describe 'when not on a touch based device', -> + beforeEach -> + jasmine.stubVideoPlayerAlpha @, [], false + $('.video').append $('
') + $('.add-fullscreen, .hide-subtitles').removeData 'qtip' + @player = new VideoPlayerAlpha video: @video + + it 'add the tooltip to fullscreen and subtitle button', -> + expect($('.add-fullscreen')).toHaveData 'qtip' + expect($('.hide-subtitles')).toHaveData 'qtip' + + it 'create video volume control', -> + expect(window.VideoVolumeControlAlpha.prototype.initialize).toHaveBeenCalled() + expect(@player.volumeControl).toBeDefined() + expect(@player.volumeControl.el).toBe $('.secondary-controls', @player.el) + + describe 'when on a touch based device', -> + beforeEach -> + jasmine.stubVideoPlayerAlpha @, [], false + $('.video').append $('
') + window.onTouchBasedDevice.andReturn true + $('.add-fullscreen, .hide-subtitles').removeData 'qtip' + @player = new VideoPlayerAlpha video: @video + + it 'does not add the tooltip to fullscreen and subtitle button', -> + expect($('.add-fullscreen')).not.toHaveData 'qtip' + expect($('.hide-subtitles')).not.toHaveData 'qtip' + + it 'does not create video volume control', -> + expect(window.VideoVolumeControlAlpha.prototype.initialize).not.toHaveBeenCalled() + expect(@player.volumeControl).not.toBeDefined() + + describe 'onReady', -> + beforeEach -> + jasmine.stubVideoPlayerAlpha @, [], false + spyOn @video, 'log' + $('.video').append $('
') + @video.embed() + @player = @video.player + spyOnEvent @player, 'ready' + spyOnEvent @player, 'updatePlayTime' + @player.onReady() + + it 'log the load_video event', -> + expect(@video.log).toHaveBeenCalledWith 'load_video' + + describe 'when not on a touch based device', -> + beforeEach -> + spyOn @player, 'play' + @player.onReady() + + it 'autoplay the first video', -> + expect(@player.play).toHaveBeenCalled() + + describe 'when on a touch based device', -> + beforeEach -> + window.onTouchBasedDevice.andReturn true + spyOn @player, 'play' + @player.onReady() + + it 'does not autoplay the first video', -> + expect(@player.play).not.toHaveBeenCalled() + + describe 'onStateChange', -> + beforeEach -> + jasmine.stubVideoPlayerAlpha @, [], false + $('.video').append $('
') + + describe 'when the video is unstarted', -> + beforeEach -> + @player = new VideoPlayerAlpha video: @video + spyOn @player.control, 'pause' + @player.caption.pause = jasmine.createSpy('VideoCaptionAlpha.pause') + @player.onStateChange data: YT.PlayerState.UNSTARTED + + it 'pause the video control', -> + expect(@player.control.pause).toHaveBeenCalled() + + it 'pause the video caption', -> + expect(@player.caption.pause).toHaveBeenCalled() + + describe 'when the video is playing', -> + beforeEach -> + @anotherPlayer = jasmine.createSpyObj 'AnotherPlayer', ['onPause'] + window.OldVideoPlayerAlpha = @anotherPlayer + @player = new VideoPlayerAlpha video: @video + spyOn @video, 'log' + spyOn(window, 'setInterval').andReturn 100 + spyOn @player.control, 'play' + @player.caption.play = jasmine.createSpy('VideoCaptionAlpha.play') + @player.progressSlider.play = jasmine.createSpy('VideoProgressSliderAlpha.play') + @player.player.getVideoEmbedCode.andReturn 'embedCode' + @player.onStateChange data: YT.PlayerState.PLAYING + + it 'log the play_video event', -> + expect(@video.log).toHaveBeenCalledWith 'play_video', {currentTime: 0} + + it 'pause other video player', -> + expect(@anotherPlayer.onPause).toHaveBeenCalled() + + it 'set current video player as active player', -> + expect(window.OldVideoPlayerAlpha).toEqual @player + + it 'set update interval', -> + expect(window.setInterval).toHaveBeenCalledWith @player.update, 200 + expect(@player.player.interval).toEqual 100 + + it 'play the video control', -> + expect(@player.control.play).toHaveBeenCalled() + + it 'play the video caption', -> + expect(@player.caption.play).toHaveBeenCalled() + + it 'play the video progress slider', -> + expect(@player.progressSlider.play).toHaveBeenCalled() + + describe 'when the video is paused', -> + beforeEach -> + @player = new VideoPlayerAlpha video: @video + spyOn @video, 'log' + spyOn window, 'clearInterval' + spyOn @player.control, 'pause' + @player.caption.pause = jasmine.createSpy('VideoCaptionAlpha.pause') + @player.player.interval = 100 + @player.player.getVideoEmbedCode.andReturn 'embedCode' + @player.onStateChange data: YT.PlayerState.PAUSED + + it 'log the pause_video event', -> + expect(@video.log).toHaveBeenCalledWith 'pause_video', {currentTime: 0} + + it 'clear update interval', -> + expect(window.clearInterval).toHaveBeenCalledWith 100 + expect(@player.player.interval).toBeNull() + + it 'pause the video control', -> + expect(@player.control.pause).toHaveBeenCalled() + + it 'pause the video caption', -> + expect(@player.caption.pause).toHaveBeenCalled() + + describe 'when the video is ended', -> + beforeEach -> + @player = new VideoPlayerAlpha video: @video + spyOn @player.control, 'pause' + @player.caption.pause = jasmine.createSpy('VideoCaptionAlpha.pause') + @player.onStateChange data: YT.PlayerState.ENDED + + it 'pause the video control', -> + expect(@player.control.pause).toHaveBeenCalled() + + it 'pause the video caption', -> + expect(@player.caption.pause).toHaveBeenCalled() + + describe 'onSeek', -> + conf = [{ + desc : 'check if seek_video is logged with slide_seek type', + type: 'slide_seek', + obj: 'progressSlider' + },{ + desc : 'check if seek_video is logged with caption_seek type', + type: 'caption_seek', + obj: 'caption' + }] + + beforeEach -> + jasmine.stubVideoPlayerAlpha @, [], false + $('.video').append $('
') + @player = new VideoPlayerAlpha video: @video + spyOn window, 'clearInterval' + @player.player.interval = 100 + spyOn @player, 'updatePlayTime' + spyOn @video, 'log' + + $.each conf, (key, value) -> + it value.desc, -> + type = value.type + old_time = 0 + new_time = 60 + $(@player[value.obj]).trigger value.type, new_time + expect(@video.log).toHaveBeenCalledWith 'seek_video', + old_time: old_time + new_time: new_time + type: value.type + + it 'seek the player', -> + $(@player.progressSlider).trigger 'slide_seek', 60 + expect(@player.player.seekTo).toHaveBeenCalledWith 60, true + + it 'call updatePlayTime on player', -> + $(@player.progressSlider).trigger 'slide_seek', 60 + expect(@player.updatePlayTime).toHaveBeenCalledWith 60 + + describe 'when the player is playing', -> + beforeEach -> + $(@player.progressSlider).trigger 'slide_seek', 60 + @player.player.getPlayerState.andReturn YT.PlayerState.PLAYING + @player.onSeek {}, 60 + + it 'reset the update interval', -> + expect(window.clearInterval).toHaveBeenCalledWith 100 + + describe 'when the player is not playing', -> + beforeEach -> + $(@player.progressSlider).trigger 'slide_seek', 60 + @player.player.getPlayerState.andReturn YT.PlayerState.PAUSED + @player.onSeek {}, 60 + + it 'set the current time', -> + expect(@player.currentTime).toEqual 60 + + describe 'onSpeedChange', -> + beforeEach -> + jasmine.stubVideoPlayerAlpha @, [], false + $('.video').append $('
') + @player = new VideoPlayerAlpha video: @video + @player.currentTime = 60 + spyOn @player, 'updatePlayTime' + spyOn(@video, 'setSpeed').andCallThrough() + spyOn(@video, 'log') + + describe 'always', -> + beforeEach -> + @player.onSpeedChange {}, '0.75', false + + it 'check if speed_change_video is logged', -> + expect(@video.log).toHaveBeenCalledWith 'speed_change_video', + currentTime: @player.currentTime + old_speed: '1.0' + new_speed: '0.75' + + it 'convert the current time to the new speed', -> + expect(@player.currentTime).toEqual '80.000' + + it 'set video speed to the new speed', -> + expect(@video.setSpeed).toHaveBeenCalledWith '0.75', false + + it 'tell video caption that the speed has changed', -> + expect(@player.caption.currentSpeed).toEqual '0.75' + + describe 'when the video is playing', -> + beforeEach -> + @player.player.getPlayerState.andReturn YT.PlayerState.PLAYING + @player.onSpeedChange {}, '0.75' + + it 'load the video', -> + expect(@player.player.loadVideoById).toHaveBeenCalledWith 'slowerSpeedYoutubeId', '80.000' + + it 'trigger updatePlayTime event', -> + expect(@player.updatePlayTime).toHaveBeenCalledWith '80.000' + + describe 'when the video is not playing', -> + beforeEach -> + @player.player.getPlayerState.andReturn YT.PlayerState.PAUSED + @player.onSpeedChange {}, '0.75' + + it 'cue the video', -> + expect(@player.player.cueVideoById).toHaveBeenCalledWith 'slowerSpeedYoutubeId', '80.000' + + it 'trigger updatePlayTime event', -> + expect(@player.updatePlayTime).toHaveBeenCalledWith '80.000' + + describe 'onVolumeChange', -> + beforeEach -> + jasmine.stubVideoPlayerAlpha @, [], false + $('.video').append $('
') + @player = new VideoPlayerAlpha video: @video + @player.onVolumeChange undefined, 60 + + it 'set the volume on player', -> + expect(@player.player.setVolume).toHaveBeenCalledWith 60 + + describe 'update', -> + beforeEach -> + jasmine.stubVideoPlayerAlpha @, [], false + $('.video').append $('
') + @player = new VideoPlayerAlpha video: @video + spyOn @player, 'updatePlayTime' + + describe 'when the current time is unavailable from the player', -> + beforeEach -> + @player.player.getCurrentTime.andReturn undefined + @player.update() + + it 'does not trigger updatePlayTime event', -> + expect(@player.updatePlayTime).not.toHaveBeenCalled() + + describe 'when the current time is available from the player', -> + beforeEach -> + @player.player.getCurrentTime.andReturn 60 + @player.update() + + it 'trigger updatePlayTime event', -> + expect(@player.updatePlayTime).toHaveBeenCalledWith(60) + + describe 'updatePlayTime', -> + beforeEach -> + jasmine.stubVideoPlayerAlpha @, [], false + $('.video').append $('
') + @player = new VideoPlayerAlpha video: @video + spyOn(@video, 'getDuration').andReturn 1800 + @player.caption.updatePlayTime = jasmine.createSpy('VideoCaptionAlpha.updatePlayTime') + @player.progressSlider.updatePlayTime = jasmine.createSpy('VideoProgressSliderAlpha.updatePlayTime') + @player.updatePlayTime 60 + + it 'update the video playback time', -> + expect($('.vidtime')).toHaveHtml '1:00 / 30:00' + + it 'update the playback time on caption', -> + expect(@player.caption.updatePlayTime).toHaveBeenCalledWith 60 + + it 'update the playback time on progress slider', -> + expect(@player.progressSlider.updatePlayTime).toHaveBeenCalledWith 60, 1800 + + describe 'toggleFullScreen', -> + beforeEach -> + jasmine.stubVideoPlayerAlpha @, [], false + $('.video').append $('
') + @player = new VideoPlayerAlpha video: @video + @player.caption.resize = jasmine.createSpy('VideoCaptionAlpha.resize') + + describe 'when the video player is not full screen', -> + beforeEach -> + spyOn @video, 'log' + @player.el.removeClass 'fullscreen' + @player.toggleFullScreen(jQuery.Event("click")) + + it 'log the fullscreen event', -> + expect(@video.log).toHaveBeenCalledWith 'fullscreen', + currentTime: @player.currentTime + + it 'replace the full screen button tooltip', -> + expect($('.add-fullscreen')).toHaveAttr 'title', 'Exit fill browser' + + it 'add the fullscreen class', -> + expect(@player.el).toHaveClass 'fullscreen' + + it 'tell VideoCaption to resize', -> + expect(@player.caption.resize).toHaveBeenCalled() + + describe 'when the video player already full screen', -> + beforeEach -> + spyOn @video, 'log' + @player.el.addClass 'fullscreen' + @player.toggleFullScreen(jQuery.Event("click")) + + it 'log the not_fullscreen event', -> + expect(@video.log).toHaveBeenCalledWith 'not_fullscreen', + currentTime: @player.currentTime + + it 'replace the full screen button tooltip', -> + expect($('.add-fullscreen')).toHaveAttr 'title', 'Fill browser' + + it 'remove exit full screen button', -> + expect(@player.el).not.toContain 'a.exit' + + it 'remove the fullscreen class', -> + expect(@player.el).not.toHaveClass 'fullscreen' + + it 'tell VideoCaption to resize', -> + expect(@player.caption.resize).toHaveBeenCalled() + + describe 'play', -> + beforeEach -> + jasmine.stubVideoPlayerAlpha @, [], false + $('.video').append $('
') + @player = new VideoPlayerAlpha video: @video + + describe 'when the player is not ready', -> + beforeEach -> + @player.player.playVideo = undefined + @player.play() + + it 'does nothing', -> + expect(@player.player.playVideo).toBeUndefined() + + describe 'when the player is ready', -> + beforeEach -> + @player.player.playVideo.andReturn true + @player.play() + + it 'delegate to the Youtube player', -> + expect(@player.player.playVideo).toHaveBeenCalled() + + describe 'isPlaying', -> + beforeEach -> + jasmine.stubVideoPlayerAlpha @, [], false + $('.video').append $('
') + @player = new VideoPlayerAlpha video: @video + + describe 'when the video is playing', -> + beforeEach -> + @player.player.getPlayerState.andReturn YT.PlayerState.PLAYING + + it 'return true', -> + expect(@player.isPlaying()).toBeTruthy() + + describe 'when the video is not playing', -> + beforeEach -> + @player.player.getPlayerState.andReturn YT.PlayerState.PAUSED + + it 'return false', -> + expect(@player.isPlaying()).toBeFalsy() + + describe 'pause', -> + beforeEach -> + jasmine.stubVideoPlayerAlpha @, [], false + $('.video').append $('
') + @player = new VideoPlayerAlpha video: @video + @player.pause() + + it 'delegate to the Youtube player', -> + expect(@player.player.pauseVideo).toHaveBeenCalled() + + describe 'duration', -> + beforeEach -> + jasmine.stubVideoPlayerAlpha @, [], false + $('.video').append $('
') + @player = new VideoPlayerAlpha video: @video + spyOn @video, 'getDuration' + @player.duration() + + it 'delegate to the video', -> + expect(@video.getDuration).toHaveBeenCalled() + + describe 'currentSpeed', -> + beforeEach -> + jasmine.stubVideoPlayerAlpha @, [], false + $('.video').append $('
') + @player = new VideoPlayerAlpha video: @video + @video.speed = '3.0' + + it 'delegate to the video', -> + expect(@player.currentSpeed()).toEqual '3.0' + + describe 'volume', -> + beforeEach -> + jasmine.stubVideoPlayerAlpha @, [], false + $('.video').append $('
') + @player = new VideoPlayerAlpha video: @video + @player.player.getVolume.andReturn 42 + + describe 'without value', -> + it 'return current volume', -> + expect(@player.volume()).toEqual 42 + + describe 'with value', -> + it 'set player volume', -> + @player.volume(60) + expect(@player.player.setVolume).toHaveBeenCalledWith(60) diff --git a/common/lib/xmodule/xmodule/js/spec/videoalpha/display/video_progress_slider_spec.coffee b/common/lib/xmodule/xmodule/js/spec/videoalpha/display/video_progress_slider_spec.coffee new file mode 100644 index 0000000000..dd787aefbb --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/videoalpha/display/video_progress_slider_spec.coffee @@ -0,0 +1,165 @@ +describe 'VideoProgressSliderAlpha', -> + beforeEach -> + window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false + + describe 'constructor', -> + describe 'on a non-touch based device', -> + beforeEach -> + spyOn($.fn, 'slider').andCallThrough() + @player = jasmine.stubVideoPlayerAlpha @ + @progressSlider = @player.progressSlider + + it 'build the slider', -> + expect(@progressSlider.slider).toBe '.slider' + expect($.fn.slider).toHaveBeenCalledWith + range: 'min' + change: @progressSlider.onChange + slide: @progressSlider.onSlide + stop: @progressSlider.onStop + + it 'build the seek handle', -> + expect(@progressSlider.handle).toBe '.slider .ui-slider-handle' + expect($.fn.qtip).toHaveBeenCalledWith + content: "0:00" + position: + my: 'bottom center' + at: 'top center' + container: @progressSlider.handle + hide: + delay: 700 + style: + classes: 'ui-tooltip-slider' + widget: true + + describe 'on a touch-based device', -> + beforeEach -> + window.onTouchBasedDevice.andReturn true + spyOn($.fn, 'slider').andCallThrough() + @player = jasmine.stubVideoPlayerAlpha @ + @progressSlider = @player.progressSlider + + it 'does not build the slider', -> + expect(@progressSlider.slider).toBeUndefined + expect($.fn.slider).not.toHaveBeenCalled() + + describe 'play', -> + beforeEach -> + spyOn(VideoProgressSliderAlpha.prototype, 'buildSlider').andCallThrough() + @player = jasmine.stubVideoPlayerAlpha @ + @progressSlider = @player.progressSlider + + describe 'when the slider was already built', -> + + beforeEach -> + @progressSlider.play() + + it 'does not build the slider', -> + expect(@progressSlider.buildSlider.calls.length).toEqual 1 + + describe 'when the slider was not already built', -> + beforeEach -> + spyOn($.fn, 'slider').andCallThrough() + @progressSlider.slider = null + @progressSlider.play() + + it 'build the slider', -> + expect(@progressSlider.slider).toBe '.slider' + expect($.fn.slider).toHaveBeenCalledWith + range: 'min' + change: @progressSlider.onChange + slide: @progressSlider.onSlide + stop: @progressSlider.onStop + + it 'build the seek handle', -> + expect(@progressSlider.handle).toBe '.ui-slider-handle' + expect($.fn.qtip).toHaveBeenCalledWith + content: "0:00" + position: + my: 'bottom center' + at: 'top center' + container: @progressSlider.handle + hide: + delay: 700 + style: + classes: 'ui-tooltip-slider' + widget: true + + describe 'updatePlayTime', -> + beforeEach -> + @player = jasmine.stubVideoPlayerAlpha @ + @progressSlider = @player.progressSlider + + describe 'when frozen', -> + beforeEach -> + spyOn($.fn, 'slider').andCallThrough() + @progressSlider.frozen = true + @progressSlider.updatePlayTime 20, 120 + + it 'does not update the slider', -> + expect($.fn.slider).not.toHaveBeenCalled() + + describe 'when not frozen', -> + beforeEach -> + spyOn($.fn, 'slider').andCallThrough() + @progressSlider.frozen = false + @progressSlider.updatePlayTime 20, 120 + + it 'update the max value of the slider', -> + expect($.fn.slider).toHaveBeenCalledWith 'option', 'max', 120 + + it 'update current value of the slider', -> + expect($.fn.slider).toHaveBeenCalledWith 'value', 20 + + describe 'onSlide', -> + beforeEach -> + @player = jasmine.stubVideoPlayerAlpha @ + @progressSlider = @player.progressSlider + spyOnEvent @progressSlider, 'slide_seek' + @progressSlider.onSlide {}, value: 20 + + it 'freeze the slider', -> + expect(@progressSlider.frozen).toBeTruthy() + + it 'update the tooltip', -> + expect($.fn.qtip).toHaveBeenCalled() + + it 'trigger seek event', -> + expect('slide_seek').toHaveBeenTriggeredOn @progressSlider + expect(@player.currentTime).toEqual 20 + + describe 'onChange', -> + beforeEach -> + @player = jasmine.stubVideoPlayerAlpha @ + @progressSlider = @player.progressSlider + @progressSlider.onChange {}, value: 20 + + it 'update the tooltip', -> + expect($.fn.qtip).toHaveBeenCalled() + + describe 'onStop', -> + beforeEach -> + @player = jasmine.stubVideoPlayerAlpha @ + @progressSlider = @player.progressSlider + spyOnEvent @progressSlider, 'slide_seek' + @progressSlider.onStop {}, value: 20 + + it 'freeze the slider', -> + expect(@progressSlider.frozen).toBeTruthy() + + it 'trigger seek event', -> + expect('slide_seek').toHaveBeenTriggeredOn @progressSlider + expect(@player.currentTime).toEqual 20 + + it 'set timeout to unfreeze the slider', -> + expect(window.setTimeout).toHaveBeenCalledWith jasmine.any(Function), 200 + window.setTimeout.mostRecentCall.args[0]() + expect(@progressSlider.frozen).toBeFalsy() + + describe 'updateTooltip', -> + beforeEach -> + @player = jasmine.stubVideoPlayerAlpha @ + @progressSlider = @player.progressSlider + @progressSlider.updateTooltip 90 + + it 'set the tooltip value', -> + expect($.fn.qtip).toHaveBeenCalledWith 'option', 'content.text', '1:30' diff --git a/common/lib/xmodule/xmodule/js/spec/videoalpha/display/video_speed_control_spec.coffee b/common/lib/xmodule/xmodule/js/spec/videoalpha/display/video_speed_control_spec.coffee new file mode 100644 index 0000000000..ca4bfe815a --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/videoalpha/display/video_speed_control_spec.coffee @@ -0,0 +1,91 @@ +describe 'VideoSpeedControlAlpha', -> + beforeEach -> + window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false + jasmine.stubVideoPlayerAlpha @ + $('.speeds').remove() + + describe 'constructor', -> + describe 'always', -> + beforeEach -> + @speedControl = new VideoSpeedControlAlpha el: $('.secondary-controls'), speeds: @video.speeds, currentSpeed: '1.0' + + it 'add the video speed control to player', -> + secondaryControls = $('.secondary-controls') + li = secondaryControls.find('.video_speeds li') + expect(secondaryControls).toContain '.speeds' + expect(secondaryControls).toContain '.video_speeds' + expect(secondaryControls.find('p.active').text()).toBe '1.0x' + expect(li.filter('.active')).toHaveData 'speed', @speedControl.currentSpeed + expect(li.length).toBe @speedControl.speeds.length + $.each li.toArray().reverse(), (index, link) => + expect($(link)).toHaveData 'speed', @speedControl.speeds[index] + expect($(link).find('a').text()).toBe @speedControl.speeds[index] + 'x' + + it 'bind to change video speed link', -> + expect($('.video_speeds a')).toHandleWith 'click', @speedControl.changeVideoSpeed + + describe 'when running on touch based device', -> + beforeEach -> + window.onTouchBasedDevice.andReturn true + $('.speeds').removeClass 'open' + @speedControl = new VideoSpeedControlAlpha el: $('.secondary-controls'), speeds: @video.speeds, currentSpeed: '1.0' + + it 'open the speed toggle on click', -> + $('.speeds').click() + expect($('.speeds')).toHaveClass 'open' + $('.speeds').click() + expect($('.speeds')).not.toHaveClass 'open' + + describe 'when running on non-touch based device', -> + beforeEach -> + $('.speeds').removeClass 'open' + @speedControl = new VideoSpeedControlAlpha el: $('.secondary-controls'), speeds: @video.speeds, currentSpeed: '1.0' + + it 'open the speed toggle on hover', -> + $('.speeds').mouseenter() + expect($('.speeds')).toHaveClass 'open' + $('.speeds').mouseleave() + expect($('.speeds')).not.toHaveClass 'open' + + it 'close the speed toggle on mouse out', -> + $('.speeds').mouseenter().mouseleave() + expect($('.speeds')).not.toHaveClass 'open' + + it 'close the speed toggle on click', -> + $('.speeds').mouseenter().click() + expect($('.speeds')).not.toHaveClass 'open' + + describe 'changeVideoSpeed', -> + beforeEach -> + @speedControl = new VideoSpeedControlAlpha el: $('.secondary-controls'), speeds: @video.speeds, currentSpeed: '1.0' + @video.setSpeed '1.0' + + describe 'when new speed is the same', -> + beforeEach -> + spyOnEvent @speedControl, 'speedChange' + $('li[data-speed="1.0"] a').click() + + it 'does not trigger speedChange event', -> + expect('speedChange').not.toHaveBeenTriggeredOn @speedControl + + describe 'when new speed is not the same', -> + beforeEach -> + @newSpeed = null + $(@speedControl).bind 'speedChange', (event, newSpeed) => @newSpeed = newSpeed + spyOnEvent @speedControl, 'speedChange' + $('li[data-speed="0.75"] a').click() + + it 'trigger speedChange event', -> + expect('speedChange').toHaveBeenTriggeredOn @speedControl + expect(@newSpeed).toEqual 0.75 + + describe 'onSpeedChange', -> + beforeEach -> + @speedControl = new VideoSpeedControlAlpha el: $('.secondary-controls'), speeds: @video.speeds, currentSpeed: '1.0' + $('li[data-speed="1.0"] a').addClass 'active' + @speedControl.setSpeed '0.75' + + it 'set the new speed as active', -> + expect($('.video_speeds li[data-speed="1.0"]')).not.toHaveClass 'active' + expect($('.video_speeds li[data-speed="0.75"]')).toHaveClass 'active' + expect($('.speeds p.active')).toHaveHtml '0.75x' diff --git a/common/lib/xmodule/xmodule/js/spec/videoalpha/display/video_volume_control_spec.coffee b/common/lib/xmodule/xmodule/js/spec/videoalpha/display/video_volume_control_spec.coffee new file mode 100644 index 0000000000..4bb9f1cbf8 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/videoalpha/display/video_volume_control_spec.coffee @@ -0,0 +1,94 @@ +describe 'VideoVolumeControlAlpha', -> + beforeEach -> + jasmine.stubVideoPlayerAlpha @ + $('.volume').remove() + + describe 'constructor', -> + beforeEach -> + spyOn($.fn, 'slider') + @volumeControl = new VideoVolumeControlAlpha el: $('.secondary-controls') + + it 'initialize currentVolume to 100', -> + expect(@volumeControl.currentVolume).toEqual 100 + + it 'render the volume control', -> + expect($('.secondary-controls').html()).toContain """ +
+ +
+
+
+
+ """ + + it 'create the slider', -> + expect($.fn.slider).toHaveBeenCalledWith + orientation: "vertical" + range: "min" + min: 0 + max: 100 + value: 100 + change: @volumeControl.onChange + slide: @volumeControl.onChange + + it 'bind the volume control', -> + expect($('.volume>a')).toHandleWith 'click', @volumeControl.toggleMute + + expect($('.volume')).not.toHaveClass 'open' + $('.volume').mouseenter() + expect($('.volume')).toHaveClass 'open' + $('.volume').mouseleave() + expect($('.volume')).not.toHaveClass 'open' + + describe 'onChange', -> + beforeEach -> + spyOnEvent @volumeControl, 'volumeChange' + @newVolume = undefined + @volumeControl = new VideoVolumeControlAlpha el: $('.secondary-controls') + $(@volumeControl).bind 'volumeChange', (event, volume) => @newVolume = volume + + describe 'when the new volume is more than 0', -> + beforeEach -> + @volumeControl.onChange undefined, value: 60 + + it 'set the player volume', -> + expect(@newVolume).toEqual 60 + + it 'remote muted class', -> + expect($('.volume')).not.toHaveClass 'muted' + + describe 'when the new volume is 0', -> + beforeEach -> + @volumeControl.onChange undefined, value: 0 + + it 'set the player volume', -> + expect(@newVolume).toEqual 0 + + it 'add muted class', -> + expect($('.volume')).toHaveClass 'muted' + + describe 'toggleMute', -> + beforeEach -> + @newVolume = undefined + @volumeControl = new VideoVolumeControlAlpha el: $('.secondary-controls') + $(@volumeControl).bind 'volumeChange', (event, volume) => @newVolume = volume + + describe 'when the current volume is more than 0', -> + beforeEach -> + @volumeControl.currentVolume = 60 + @volumeControl.toggleMute() + + it 'save the previous volume', -> + expect(@volumeControl.previousVolume).toEqual 60 + + it 'set the player volume', -> + expect(@newVolume).toEqual 0 + + describe 'when the current volume is 0', -> + beforeEach -> + @volumeControl.currentVolume = 0 + @volumeControl.previousVolume = 60 + @volumeControl.toggleMute() + + it 'set the player volume to previous volume', -> + expect(@newVolume).toEqual 60 diff --git a/common/lib/xmodule/xmodule/js/spec/videoalpha/display_spec.coffee b/common/lib/xmodule/xmodule/js/spec/videoalpha/display_spec.coffee new file mode 100644 index 0000000000..3715c3d813 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/videoalpha/display_spec.coffee @@ -0,0 +1,286 @@ +describe 'VideoAlpha', -> + metadata = + slowerSpeedYoutubeId: + id: @slowerSpeedYoutubeId + duration: 300 + normalSpeedYoutubeId: + id: @normalSpeedYoutubeId + duration: 200 + + beforeEach -> + jasmine.stubRequests() + window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false + @videosDefinition = '0.75:slowerSpeedYoutubeId,1.0:normalSpeedYoutubeId' + @slowerSpeedYoutubeId = 'slowerSpeedYoutubeId' + @normalSpeedYoutubeId = 'normalSpeedYoutubeId' + + afterEach -> + window.OldVideoPlayerAlpha = undefined + window.onYouTubePlayerAPIReady = undefined + window.onHTML5PlayerAPIReady = undefined + + describe 'constructor', -> + describe 'YT', -> + beforeEach -> + loadFixtures 'videoalpha.html' + @stubVideoPlayerAlpha = jasmine.createSpy('VideoPlayerAlpha') + $.cookie.andReturn '0.75' + + describe 'by default', -> + beforeEach -> + spyOn(window.VideoAlpha.prototype, 'fetchMetadata').andCallFake -> + @metadata = metadata + @video = new VideoAlpha '#example', @videosDefinition + + it 'check videoType', -> + expect(@video.videoType).toEqual('youtube') + + it 'reset the current video player', -> + expect(window.OldVideoPlayerAlpha).toBeUndefined() + + it 'set the elements', -> + expect(@video.el).toBe '#video_id' + + it 'parse the videos', -> + expect(@video.videos).toEqual + '0.75': @slowerSpeedYoutubeId + '1.0': @normalSpeedYoutubeId + + it 'fetch the video metadata', -> + expect(@video.fetchMetadata).toHaveBeenCalled + expect(@video.metadata).toEqual metadata + + it 'parse available video speeds', -> + expect(@video.speeds).toEqual ['0.75', '1.0'] + + it 'set current video speed via cookie', -> + expect(@video.speed).toEqual '0.75' + + it 'store a reference for this video player in the element', -> + expect($('.video').data('video')).toEqual @video + + describe 'when the Youtube API is already available', -> + beforeEach -> + @originalYT = window.YT + window.YT = { Player: true } + spyOn(window, 'VideoPlayerAlpha').andReturn(@stubVideoPlayerAlpha) + @video = new VideoAlpha '#example', @videosDefinition + + afterEach -> + window.YT = @originalYT + + it 'create the Video Player', -> + expect(window.VideoPlayerAlpha).toHaveBeenCalledWith(video: @video) + expect(@video.player).toEqual @stubVideoPlayerAlpha + + describe 'when the Youtube API is not ready', -> + beforeEach -> + @originalYT = window.YT + window.YT = {} + @video = new VideoAlpha '#example', @videosDefinition + + afterEach -> + window.YT = @originalYT + + it 'set the callback on the window object', -> + expect(window.onYouTubePlayerAPIReady).toEqual jasmine.any(Function) + + describe 'when the Youtube API becoming ready', -> + beforeEach -> + @originalYT = window.YT + window.YT = {} + spyOn(window, 'VideoPlayerAlpha').andReturn(@stubVideoPlayerAlpha) + @video = new VideoAlpha '#example', @videosDefinition + window.onYouTubePlayerAPIReady() + + afterEach -> + window.YT = @originalYT + + it 'create the Video Player for all video elements', -> + expect(window.VideoPlayerAlpha).toHaveBeenCalledWith(video: @video) + expect(@video.player).toEqual @stubVideoPlayerAlpha + + describe 'HTML5', -> + beforeEach -> + loadFixtures 'videoalpha_html5.html' + @stubVideoPlayerAlpha = jasmine.createSpy('VideoPlayerAlpha') + $.cookie.andReturn '0.75' + + describe 'by default', -> + beforeEach -> + @originalHTML5 = window.HTML5Video.Player + window.HTML5Video.Player = undefined + @video = new VideoAlpha '#example', @videosDefinition + + afterEach -> + window.HTML5Video.Player = @originalHTML5 + + it 'check videoType', -> + expect(@video.videoType).toEqual('html5') + + it 'reset the current video player', -> + expect(window.OldVideoPlayerAlpha).toBeUndefined() + + it 'set the elements', -> + expect(@video.el).toBe '#video_id' + + it 'parse the videos if subtitles exist', -> + sub = 'test_name_of_the_subtitles' + expect(@video.videos).toEqual + '0.75': sub + '1.0': sub + '1.25': sub + '1.5': sub + + it 'parse the videos if subtitles doesn\'t exist', -> + $('#example').find('.video').data('sub', '') + @video = new VideoAlpha '#example', @videosDefinition + sub = '' + expect(@video.videos).toEqual + '0.75': sub + '1.0': sub + '1.25': sub + '1.5': sub + + it 'parse Html5 sources', -> + html5Sources = + mp4: 'test.mp4' + webm: 'test.webm' + ogg: 'test.ogv' + expect(@video.html5Sources).toEqual html5Sources + + it 'parse available video speeds', -> + speeds = jasmine.stubbedHtml5Speeds + expect(@video.speeds).toEqual speeds + + it 'set current video speed via cookie', -> + expect(@video.speed).toEqual '0.75' + + it 'store a reference for this video player in the element', -> + expect($('.video').data('video')).toEqual @video + + describe 'when the HTML5 API is already available', -> + beforeEach -> + @originalHTML5Video = window.HTML5Video + window.HTML5Video = { Player: true } + spyOn(window, 'VideoPlayerAlpha').andReturn(@stubVideoPlayerAlpha) + @video = new VideoAlpha '#example', @videosDefinition + + afterEach -> + window.HTML5Video = @originalHTML5Video + + it 'create the Video Player', -> + expect(window.VideoPlayerAlpha).toHaveBeenCalledWith(video: @video) + expect(@video.player).toEqual @stubVideoPlayerAlpha + + describe 'when the HTML5 API is not ready', -> + beforeEach -> + @originalHTML5Video = window.HTML5Video + window.HTML5Video = {} + @video = new VideoAlpha '#example', @videosDefinition + + afterEach -> + window.HTML5Video = @originalHTML5Video + + it 'set the callback on the window object', -> + expect(window.onHTML5PlayerAPIReady).toEqual jasmine.any(Function) + + describe 'when the HTML5 API becoming ready', -> + beforeEach -> + @originalHTML5Video = window.HTML5Video + window.HTML5Video = {} + spyOn(window, 'VideoPlayerAlpha').andReturn(@stubVideoPlayerAlpha) + @video = new VideoAlpha '#example', @videosDefinition + window.onHTML5PlayerAPIReady() + + afterEach -> + window.HTML5Video = @originalHTML5Video + + it 'create the Video Player for all video elements', -> + expect(window.VideoPlayerAlpha).toHaveBeenCalledWith(video: @video) + expect(@video.player).toEqual @stubVideoPlayerAlpha + + describe 'youtubeId', -> + beforeEach -> + loadFixtures 'videoalpha.html' + $.cookie.andReturn '1.0' + @video = new VideoAlpha '#example', @videosDefinition + + describe 'with speed', -> + it 'return the video id for given speed', -> + expect(@video.youtubeId('0.75')).toEqual @slowerSpeedYoutubeId + expect(@video.youtubeId('1.0')).toEqual @normalSpeedYoutubeId + + describe 'without speed', -> + it 'return the video id for current speed', -> + expect(@video.youtubeId()).toEqual @normalSpeedYoutubeId + + describe 'setSpeed', -> + describe 'YT', -> + beforeEach -> + loadFixtures 'videoalpha.html' + @video = new VideoAlpha '#example', @videosDefinition + + describe 'when new speed is available', -> + beforeEach -> + @video.setSpeed '0.75' + + it 'set new speed', -> + expect(@video.speed).toEqual '0.75' + + it 'save setting for new speed', -> + expect($.cookie).toHaveBeenCalledWith 'video_speed', '0.75', expires: 3650, path: '/' + + describe 'when new speed is not available', -> + beforeEach -> + @video.setSpeed '1.75' + + it 'set speed to 1.0x', -> + expect(@video.speed).toEqual '1.0' + + describe 'HTML5', -> + beforeEach -> + loadFixtures 'videoalpha_html5.html' + @video = new VideoAlpha '#example', @videosDefinition + + describe 'when new speed is available', -> + beforeEach -> + @video.setSpeed '0.75' + + it 'set new speed', -> + expect(@video.speed).toEqual '0.75' + + it 'save setting for new speed', -> + expect($.cookie).toHaveBeenCalledWith 'video_speed', '0.75', expires: 3650, path: '/' + + describe 'when new speed is not available', -> + beforeEach -> + @video.setSpeed '1.75' + + it 'set speed to 1.0x', -> + expect(@video.speed).toEqual '1.0' + + describe 'getDuration', -> + beforeEach -> + loadFixtures 'videoalpha.html' + @video = new VideoAlpha '#example', @videosDefinition + + it 'return duration for current video', -> + expect(@video.getDuration()).toEqual 200 + + describe 'log', -> + beforeEach -> + loadFixtures 'videoalpha.html' + @video = new VideoAlpha '#example', @videosDefinition + spyOn Logger, 'log' + @video.log 'someEvent', { + currentTime: 25, + speed: '1.0' + } + + it 'call the logger with valid extra parameters', -> + expect(Logger.log).toHaveBeenCalledWith 'someEvent', + id: 'id' + code: @normalSpeedYoutubeId + currentTime: 25 + speed: '1.0' diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_caption.coffee b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_caption.coffee index ff61a9a459..317979bb4d 100644 --- a/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_caption.coffee +++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_caption.coffee @@ -37,7 +37,7 @@ class @VideoCaptionAlpha extends SubviewAlpha @loaded = true if onTouchBasedDevice() - $('.subtitles li').html "Caption will be displayed when you start playing the video." + $('.subtitles').html "
  • Caption will be displayed when you start playing the video.
  • " else @renderCaption() diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_player.coffee b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_player.coffee index 31dd115fa6..7019386e04 100644 --- a/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_player.coffee +++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_player.coffee @@ -6,7 +6,7 @@ class @VideoPlayerAlpha extends SubviewAlpha # we must pause the player (stop setInterval() method). if (window.OldVideoPlayerAlpha) and (window.OldVideoPlayerAlpha.onPause) window.OldVideoPlayerAlpha.onPause() - window.OldVideoPlayerAlpha = this + window.OldVideoPlayerAlpha = @ if @video.videoType is 'youtube' @PlayerState = YT.PlayerState @@ -29,7 +29,7 @@ class @VideoPlayerAlpha extends SubviewAlpha $(@progressSlider).bind('slide_seek', @onSeek) if @volumeControl $(@volumeControl).bind('volumeChange', @onVolumeChange) - $(document).keyup @bindExitFullScreen + $(document.documentElement).keyup @bindExitFullScreen @$('.add-fullscreen').click @toggleFullScreen @addToolTip() unless onTouchBasedDevice() @@ -114,7 +114,7 @@ class @VideoPlayerAlpha extends SubviewAlpha @video.log 'load_video' if @video.videoType is 'html5' @player.setPlaybackRate @video.speed - if not onTouchBasedDevice() and $('.video:first').data('autoplay') is 'True' + if not onTouchBasedDevice() and $('.video:first').data('autoplay') isnt 'False' $('.video-load-complete:first').data('video').player.play() onStateChange: (event) => @@ -308,7 +308,7 @@ class @VideoPlayerAlpha extends SubviewAlpha @player.pauseVideo() if @player.pauseVideo duration: -> - duration = @player.getDuration() + duration = @player.getDuration() if @player.getDuration if isFinite(duration) is false duration = @video.getDuration() duration diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_progress_slider.coffee b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_progress_slider.coffee index e9ed9923b0..5197c4938f 100644 --- a/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_progress_slider.coffee +++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_progress_slider.coffee @@ -12,7 +12,7 @@ class @VideoProgressSliderAlpha extends SubviewAlpha @buildHandle() buildHandle: -> - @handle = @$('.slider .ui-slider-handle') + @handle = @$('.ui-slider-handle') @handle.qtip content: "#{Time.format(@slider.slider('value'))}" position: @@ -43,7 +43,7 @@ class @VideoProgressSliderAlpha extends SubviewAlpha onStop: (event, ui) => @frozen = true - $(@).trigger('seek', ui.value) + $(@).trigger('slide_seek', ui.value) setTimeout (=> @frozen = false), 200 updateTooltip: (value)-> diff --git a/common/lib/xmodule/xmodule/templates/videoalpha/default.yaml b/common/lib/xmodule/xmodule/templates/videoalpha/default.yaml index dba8bbd0b4..1c25b272a3 100644 --- a/common/lib/xmodule/xmodule/templates/videoalpha/default.yaml +++ b/common/lib/xmodule/xmodule/templates/videoalpha/default.yaml @@ -1,6 +1,6 @@ --- metadata: - display_name: Video Alpha 1 + display_name: Video Alpha version: 1 data: | diff --git a/common/lib/xmodule/xmodule/tests/test_fields.py b/common/lib/xmodule/xmodule/tests/test_fields.py index 884f218d6d..f0eb082dcf 100644 --- a/common/lib/xmodule/xmodule/tests/test_fields.py +++ b/common/lib/xmodule/xmodule/tests/test_fields.py @@ -3,6 +3,8 @@ import datetime import unittest from django.utils.timezone import UTC from xmodule.fields import Date, Timedelta +from xmodule.timeinfo import TimeInfo +import time class DateTest(unittest.TestCase): @@ -52,6 +54,18 @@ class DateTest(unittest.TestCase): self.assertEqual( datetime.datetime(current.year, 12, 4, 16, 30, tzinfo=UTC()), DateTest.date.from_json("December 4 16:30")) + self.assertIsNone(DateTest.date.from_json("12 12:00")) + + def test_non_std_from_json(self): + """ + Test the non-standard args being passed to from_json + """ + now = datetime.datetime.now(UTC()) + delta = now - datetime.datetime.fromtimestamp(0, UTC()) + self.assertEqual(DateTest.date.from_json(delta.total_seconds() * 1000), + now) + yesterday = datetime.datetime.now(UTC()) - datetime.timedelta(days=-1) + self.assertEqual(DateTest.date.from_json(yesterday), yesterday) def test_to_json(self): ''' @@ -90,3 +104,12 @@ class TimedeltaTest(unittest.TestCase): '1 days 46799 seconds', TimedeltaTest.delta.to_json(datetime.timedelta(days=1, hours=12, minutes=59, seconds=59)) ) + + +class TimeInfoTest(unittest.TestCase): + def test_time_info(self): + due_date = datetime.datetime(2000, 4, 14, 10, tzinfo=UTC()) + grace_pd_string = '1 day 12 hours 59 minutes 59 seconds' + timeinfo = TimeInfo(due_date, grace_pd_string) + self.assertEqual(timeinfo.close_date, + due_date + Timedelta().from_json(grace_pd_string)) diff --git a/common/lib/xmodule/xmodule/tests/test_logic.py b/common/lib/xmodule/xmodule/tests/test_logic.py index ff17f88dfc..6fb331b3cf 100644 --- a/common/lib/xmodule/xmodule/tests/test_logic.py +++ b/common/lib/xmodule/xmodule/tests/test_logic.py @@ -1,15 +1,14 @@ # -*- coding: utf-8 -*- +# pylint: disable=W0232 """Test for Xmodule functional logic.""" import json import unittest -from lxml import etree - from xmodule.poll_module import PollDescriptor from xmodule.conditional_module import ConditionalDescriptor from xmodule.word_cloud_module import WordCloudDescriptor -from xmodule.videoalpha_module import VideoAlphaDescriptor +from xmodule.tests import test_system class PostData: """Class which emulate postdata.""" @@ -17,6 +16,7 @@ class PostData: self.dict_data = dict_data def getlist(self, key): + """Get data by key from `self.dict_data`.""" return self.dict_data.get(key) @@ -27,9 +27,10 @@ class LogicTest(unittest.TestCase): def setUp(self): class EmptyClass: + """Empty object.""" pass - self.system = None + self.system = test_system() self.descriptor = EmptyClass() self.xmodule_class = self.descriptor_class.module_class @@ -40,10 +41,12 @@ class LogicTest(unittest.TestCase): ) def ajax_request(self, dispatch, get): + """Call Xmodule.handle_ajax.""" return json.loads(self.xmodule.handle_ajax(dispatch, get)) class PollModuleTest(LogicTest): + """Logic tests for Poll Xmodule.""" descriptor_class = PollDescriptor raw_model_data = { 'poll_answers': {'Yes': 1, 'Dont_know': 0, 'No': 0}, @@ -69,6 +72,7 @@ class PollModuleTest(LogicTest): class ConditionalModuleTest(LogicTest): + """Logic tests for Conditional Xmodule.""" descriptor_class = ConditionalDescriptor def test_ajax_request(self): @@ -83,6 +87,7 @@ class ConditionalModuleTest(LogicTest): class WordCloudModuleTest(LogicTest): + """Logic tests for Word Cloud Xmodule.""" descriptor_class = WordCloudDescriptor raw_model_data = { 'all_words': {'cat': 10, 'dog': 5, 'mom': 1, 'dad': 2}, @@ -91,8 +96,6 @@ class WordCloudModuleTest(LogicTest): } def test_bad_ajax_request(self): - - # TODO: move top global test. Formalize all our Xmodule errors. response = self.ajax_request('bad_dispatch', {}) self.assertDictEqual(response, { 'status': 'fail', @@ -118,34 +121,6 @@ class WordCloudModuleTest(LogicTest): {'text': 'cat', 'size': 12, 'percent': 54.0}] ) - self.assertEqual(100.0, sum(i['percent'] for i in response['top_words']) ) - - -class VideoAlphaModuleTest(LogicTest): - descriptor_class = VideoAlphaDescriptor - - raw_model_data = { - 'data': '' - } - - def test_get_timeframe_no_parameters(self): - xmltree = etree.fromstring('test') - output = self.xmodule._get_timeframe(xmltree) - self.assertEqual(output, ('', '')) - - def test_get_timeframe_with_one_parameter(self): - xmltree = etree.fromstring( - 'test' - ) - output = self.xmodule._get_timeframe(xmltree) - self.assertEqual(output, (247, '')) - - def test_get_timeframe_with_two_parameters(self): - xmltree = etree.fromstring( - '''test''' - ) - output = self.xmodule._get_timeframe(xmltree) - self.assertEqual(output, (247, 47079)) + self.assertEqual( + 100.0, + sum(i['percent'] for i in response['top_words'])) diff --git a/common/lib/xmodule/xmodule/timeinfo.py b/common/lib/xmodule/xmodule/timeinfo.py index 9a63c0477d..8f4d99506a 100644 --- a/common/lib/xmodule/xmodule/timeinfo.py +++ b/common/lib/xmodule/xmodule/timeinfo.py @@ -1,6 +1,5 @@ -from .timeparse import parse_timedelta - import logging +from xmodule.fields import Timedelta log = logging.getLogger(__name__) class TimeInfo(object): @@ -14,6 +13,7 @@ class TimeInfo(object): self.close_date - the real due date """ + _delta_standin = Timedelta() def __init__(self, due_date, grace_period_string): if due_date is not None: self.display_due_date = due_date @@ -23,7 +23,7 @@ class TimeInfo(object): if grace_period_string is not None and self.display_due_date: try: - self.grace_period = parse_timedelta(grace_period_string) + self.grace_period = TimeInfo._delta_standin.from_json(grace_period_string) self.close_date = self.display_due_date + self.grace_period except: log.error("Error parsing the grace period {0}".format(grace_period_string)) diff --git a/common/lib/xmodule/xmodule/timeparse.py b/common/lib/xmodule/xmodule/timeparse.py deleted file mode 100644 index b189262761..0000000000 --- a/common/lib/xmodule/xmodule/timeparse.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Helper functions for handling time in the format we like. -""" -import re -from datetime import timedelta, datetime - -TIME_FORMAT = "%Y-%m-%dT%H:%M" - -TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?) hour(?:s?))?(\s)?((?P\d+?) minute(?:s)?)?(\s)?((?P\d+?) second(?:s)?)?$') - -def parse_time(time_str): - """ - Takes a time string in TIME_FORMAT - - Returns it as a time_struct. - - Raises ValueError if the string is not in the right format. - """ - return datetime.strptime(time_str, TIME_FORMAT) - - -def stringify_time(dt): - """ - Convert a datetime struct to a string - """ - return dt.isoformat() - -def parse_timedelta(time_str): - """ - time_str: A string with the following components: - day[s] (optional) - hour[s] (optional) - minute[s] (optional) - second[s] (optional) - - Returns a datetime.timedelta parsed from the string - """ - parts = TIMEDELTA_REGEX.match(time_str) - if not parts: - return - parts = parts.groupdict() - time_params = {} - for (name, param) in parts.iteritems(): - if param: - time_params[name] = int(param) - return timedelta(**time_params) diff --git a/common/lib/xmodule/xmodule/videoalpha_module.py b/common/lib/xmodule/xmodule/videoalpha_module.py index 43de021799..a64e094a58 100644 --- a/common/lib/xmodule/xmodule/videoalpha_module.py +++ b/common/lib/xmodule/xmodule/videoalpha_module.py @@ -1,3 +1,15 @@ +# pylint: disable=W0223 +"""VideoAlpha is ungraded Xmodule for support video content. +It's new improved video module, which support additional feature: + +- Can play non-YouTube video sources via in-browser HTML5 video player. +- YouTube defaults to HTML5 mode from the start. +- Speed changes in both YouTube and non-YouTube videos happen via +in-browser HTML5 video method (when in HTML5 mode). +- Navigational subtitles can be disabled altogether via an attribute +in XML. +""" + import json import logging @@ -21,6 +33,7 @@ log = logging.getLogger(__name__) class VideoAlphaFields(object): + """Fields for `VideoAlphaModule` and `VideoAlphaDescriptor`.""" data = String(help="XML data for the problem", scope=Scope.content) position = Integer(help="Current position in the video", scope=Scope.user_state, default=0) display_name = String(help="Display name for this module", scope=Scope.settings) @@ -68,7 +81,7 @@ class VideoAlphaModule(VideoAlphaFields, XModule): 'ogv': self._get_source(xmltree, ['ogv']), } self.track = self._get_track(xmltree) - self.start_time, self.end_time = self._get_timeframe(xmltree) + self.start_time, self.end_time = self.get_timeframe(xmltree) def _get_source(self, xmltree, exts=None): """Find the first valid source, which ends with one of `exts`.""" @@ -77,7 +90,7 @@ class VideoAlphaModule(VideoAlphaFields, XModule): return self._get_first_external(xmltree, 'source', condition) def _get_track(self, xmltree): - # find the first valid track + """Find the first valid track.""" return self._get_first_external(xmltree, 'track') def _get_first_external(self, xmltree, tag, condition=bool): @@ -93,39 +106,33 @@ class VideoAlphaModule(VideoAlphaFields, XModule): break return result - def _get_timeframe(self, xmltree): + def get_timeframe(self, xmltree): """ Converts 'start_time' and 'end_time' parameters in video tag to seconds. If there are no parameters, returns empty string. """ - def parse_time(s): + def parse_time(str_time): """Converts s in '12:34:45' format to seconds. If s is None, returns empty string""" - if s is None: + if str_time is None: return '' else: - x = time.strptime(s, '%H:%M:%S') + obj_time = time.strptime(str_time, '%H:%M:%S') return datetime.timedelta( - hours=x.tm_hour, - minutes=x.tm_min, - seconds=x.tm_sec + hours=obj_time.tm_hour, + minutes=obj_time.tm_min, + seconds=obj_time.tm_sec ).total_seconds() return parse_time(xmltree.get('start_time')), parse_time(xmltree.get('end_time')) def handle_ajax(self, dispatch, get): - """Handle ajax calls to this video. - TODO (vshnayder): This is not being called right now, so the - position is not being saved. - """ + """This is not being called right now and we raise 404 error.""" log.debug(u"GET {0}".format(get)) log.debug(u"DISPATCH {0}".format(dispatch)) - if dispatch == 'goto_position': - self.position = int(float(get['position'])) - log.info(u"NEW POSITION {0}".format(self.position)) - return json.dumps({'success': True}) raise Http404() def get_instance_state(self): + """Return information about state (position).""" return json.dumps({'position': self.position}) def get_html(self): @@ -143,7 +150,8 @@ class VideoAlphaModule(VideoAlphaFields, XModule): 'sources': self.sources, 'track': self.track, 'display_name': self.display_name_with_default, - # TODO (cpennington): This won't work when we move to data that isn't on the filesystem + # This won't work when we move to data that + # isn't on the filesystem 'data_dir': getattr(self, 'data_dir', None), 'caption_asset_path': caption_asset_path, 'show_captions': self.show_captions, @@ -154,5 +162,6 @@ class VideoAlphaModule(VideoAlphaFields, XModule): class VideoAlphaDescriptor(VideoAlphaFields, RawDescriptor): + """Descriptor for `VideoAlphaModule`.""" module_class = VideoAlphaModule template_dir_name = "videoalpha" diff --git a/lms/djangoapps/courseware/features/navigation.feature b/lms/djangoapps/courseware/features/navigation.feature new file mode 100644 index 0000000000..8fd8b54c1a --- /dev/null +++ b/lms/djangoapps/courseware/features/navigation.feature @@ -0,0 +1,25 @@ +Feature: Navigate Course + As a student in an edX course + In order to view the course properly + I want to be able to navigate through the content + + Scenario: I can navigate to a section + Given I am viewing a course with multiple sections + When I click on section "2" + Then I should see the content of section "2" + + Scenario: I can navigate to subsections + Given I am viewing a section with multiple subsections + When I click on subsection "2" + Then I should see the content of subsection "2" + + Scenario: I can navigate to sequences + Given I am viewing a section with multiple sequences + When I click on sequence "2" + Then I should see the content of sequence "2" + + Scenario: I can go back to where I was after I log out and back in + Given I am viewing a course with multiple sections + When I click on section "2" + And I return later + Then I should see that I was most recently in section "2" diff --git a/lms/djangoapps/courseware/features/navigation.py b/lms/djangoapps/courseware/features/navigation.py new file mode 100644 index 0000000000..edd748e46f --- /dev/null +++ b/lms/djangoapps/courseware/features/navigation.py @@ -0,0 +1,186 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from django.contrib.auth.models import User +from lettuce.django import django_url +from student.models import CourseEnrollment +from common import course_id, course_location +from problems_setup import PROBLEM_DICT + +TEST_COURSE_ORG = 'edx' +TEST_COURSE_NAME = 'Test Course' +TEST_SECTION_NAME = 'Test Section' +TEST_SUBSECTION_NAME = 'Test Subsection' + + +@step(u'I am viewing a course with multiple sections') +def view_course_multiple_sections(step): + create_course() + # Add a section to the course to contain problems + section1 = world.ItemFactory.create(parent_location=course_location('model_course'), + display_name=section_name(1)) + + # Add a section to the course to contain problems + section2 = world.ItemFactory.create(parent_location=course_location('model_course'), + display_name=section_name(2)) + + place1 = world.ItemFactory.create(parent_location=section1.location, + template='i4x://edx/templates/sequential/Empty', + display_name=subsection_name(1)) + + place2 = world.ItemFactory.create(parent_location=section2.location, + template='i4x://edx/templates/sequential/Empty', + display_name=subsection_name(2)) + + add_problem_to_course_section('model_course', 'multiple choice', place1.location) + add_problem_to_course_section('model_course', 'drop down', place2.location) + + create_user_and_visit_course() + + +@step(u'I am viewing a section with multiple subsections') +def view_course_multiple_subsections(step): + create_course() + + # Add a section to the course to contain problems + section1 = world.ItemFactory.create(parent_location=course_location('model_course'), + display_name=section_name(1)) + + place1 = world.ItemFactory.create(parent_location=section1.location, + template='i4x://edx/templates/sequential/Empty', + display_name=subsection_name(1)) + + place2 = world.ItemFactory.create(parent_location=section1.location, + display_name=subsection_name(2)) + + add_problem_to_course_section('model_course', 'multiple choice', place1.location) + add_problem_to_course_section('model_course', 'drop down', place2.location) + + create_user_and_visit_course() + + +@step(u'I am viewing a section with multiple sequences') +def view_course_multiple_sequences(step): + create_course() + # Add a section to the course to contain problems + section1 = world.ItemFactory.create(parent_location=course_location('model_course'), + display_name=section_name(1)) + + place1 = world.ItemFactory.create(parent_location=section1.location, + template='i4x://edx/templates/sequential/Empty', + display_name=subsection_name(1)) + + add_problem_to_course_section('model_course', 'multiple choice', place1.location) + add_problem_to_course_section('model_course', 'drop down', place1.location) + + create_user_and_visit_course() + + +@step(u'I click on section "([^"]*)"$') +def click_on_section(step, section): + section_css = 'h3[tabindex="-1"]' + world.css_click(section_css) + + subid = "ui-accordion-accordion-panel-" + str(int(section) - 1) + subsection_css = 'ul[id="%s"]> li > a' % subid + #for some reason needed to do it in two steps + world.css_find(subsection_css).click() + + +@step(u'I click on subsection "([^"]*)"$') +def click_on_subsection(step, subsection): + subsection_css = 'ul[id="ui-accordion-accordion-panel-0"]> li > a' + world.css_find(subsection_css)[int(subsection) - 1].click() + + +@step(u'I click on sequence "([^"]*)"$') +def click_on_sequence(step, sequence): + sequence_css = 'a[data-element="%s"]' % sequence + world.css_click(sequence_css) + + +@step(u'I should see the content of (?:sub)?section "([^"]*)"$') +def see_section_content(step, section): + if section == "2": + text = 'The correct answer is Option 2' + elif section == "1": + text = 'The correct answer is Choice 3' + step.given('I should see "' + text + '" somewhere on the page') + + +@step(u'I should see the content of sequence "([^"]*)"$') +def see_sequence_content(step, sequence): + step.given('I should see the content of section "2"') + + +@step(u'I return later') +def return_to_course(step): + step.given('I visit the homepage') + world.click_link("View Course") + world.click_link("Courseware") + + +@step(u'I should see that I was most recently in section "([^"]*)"$') +def see_recent_section(step, section): + step.given('I should see "You were most recently in %s" somewhere on the page' % subsection_name(int(section))) + +##################### +# HELPERS +##################### + + +def section_name(section): + return TEST_SECTION_NAME + str(section) + + +def subsection_name(section): + return TEST_SUBSECTION_NAME + str(section) + + +def create_course(): + world.clear_courses() + + world.CourseFactory.create(org=TEST_COURSE_ORG, + number="model_course", + display_name=TEST_COURSE_NAME) + + +def create_user_and_visit_course(): + world.create_user('robot') + u = User.objects.get(username='robot') + + CourseEnrollment.objects.get_or_create(user=u, course_id=course_id("model_course")) + + world.log_in('robot', 'test') + chapter_name = (TEST_SECTION_NAME + "1").replace(" ", "_") + section_name = (TEST_SUBSECTION_NAME + "1").replace(" ", "_") + url = django_url('/courses/edx/model_course/Test_Course/courseware/%s/%s' % + (chapter_name, section_name)) + + world.browser.visit(url) + + +def add_problem_to_course_section(course, problem_type, parent_location, extraMeta=None): + ''' + Add a problem to the course we have created using factories. + ''' + + assert(problem_type in PROBLEM_DICT) + + # Generate the problem XML using capa.tests.response_xml_factory + factory_dict = PROBLEM_DICT[problem_type] + problem_xml = factory_dict['factory'].build_xml(**factory_dict['kwargs']) + metadata = {'rerandomize': 'always'} if not 'metadata' in factory_dict else factory_dict['metadata'] + if extraMeta: + metadata = dict(metadata, **extraMeta) + + # Create a problem item using our generated XML + # We set rerandomize=always in the metadata so that the "Reset" button + # will appear. + template_name = "i4x://edx/templates/problem/Blank_Common_Problem" + world.ItemFactory.create(parent_location=parent_location, + template=template_name, + display_name=str(problem_type), + data=problem_xml, + metadata=metadata) diff --git a/lms/djangoapps/courseware/tests/__init__.py b/lms/djangoapps/courseware/tests/__init__.py index cc53bf735a..1cb403018c 100644 --- a/lms/djangoapps/courseware/tests/__init__.py +++ b/lms/djangoapps/courseware/tests/__init__.py @@ -25,8 +25,8 @@ class BaseTestXmodule(ModuleStoreTestCase): """Base class for testing Xmodules with mongo store. This class prepares course and users for tests: - 1. create test course - 2. create, enrol and login users for this course + 1. create test course; + 2. create, enrol and login users for this course; Any xmodule should overwrite only next parameters for test: 1. TEMPLATE_NAME diff --git a/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py b/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py new file mode 100644 index 0000000000..a6bff60acf --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +"""Video xmodule tests in mongo.""" + +from . import BaseTestXmodule +from .test_videoalpha_xml import SOURCE_XML +from django.conf import settings + + +class TestVideo(BaseTestXmodule): + """Integration tests: web client + mongo.""" + + TEMPLATE_NAME = "i4x://edx/templates/videoalpha/Video_Alpha" + DATA = SOURCE_XML + MODEL_DATA = { + 'data': DATA + } + + def test_handle_ajax_dispatch(self): + responses = { + user.username: self.clients[user.username].post( + self.get_url('whatever'), + {}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + for user in self.users + } + + self.assertEqual( + set([ + response.status_code + for _, response in responses.items() + ]).pop(), + 404) + + def test_videoalpha_constructor(self): + """Make sure that all parameters extracted correclty from xml""" + + # `get_html` return only context, cause we + # overwrite `system.render_template` + context = self.item_module.get_html() + expected_context = { + 'data_dir': getattr(self, 'data_dir', None), + 'caption_asset_path': '/c4x/MITx/999/asset/subs_', + 'show_captions': self.item_module.show_captions, + 'display_name': self.item_module.display_name_with_default, + 'end': self.item_module.end_time, + 'id': self.item_module.location.html_id(), + 'sources': self.item_module.sources, + 'start': self.item_module.start_time, + 'sub': self.item_module.sub, + 'track': self.item_module.track, + 'youtube_streams': self.item_module.youtube_streams, + 'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True) + } + self.assertDictEqual(context, expected_context) diff --git a/lms/djangoapps/courseware/tests/test_videoalpha_xml.py b/lms/djangoapps/courseware/tests/test_videoalpha_xml.py new file mode 100644 index 0000000000..44e0a7811a --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_videoalpha_xml.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +"""Test for VideoAlpha Xmodule functional logic. +These tests data readed from xml, not from mongo. + +We have a ModuleStoreTestCase class defined in +common/lib/xmodule/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 XModuleItemFactory as defined in +common/lib/xmodule/xmodule/modulestore/tests/factories.py to create the +course, section, subsection, unit, etc. +""" + +import json +import unittest +from mock import Mock +from lxml import etree + +from django.conf import settings + +from xmodule.videoalpha_module import VideoAlphaDescriptor, VideoAlphaModule +from xmodule.modulestore import Location +from xmodule.tests import test_system +from xmodule.tests.test_logic import LogicTest + + +SOURCE_XML = """ + + + + + +""" + + +class VideoAlphaFactory(object): + """A helper class to create videoalpha modules with various parameters + for testing. + """ + + # tag that uses youtube videos + sample_problem_xml_youtube = SOURCE_XML + + @staticmethod + def create(): + """Method return VideoAlpha Xmodule instance.""" + location = Location(["i4x", "edX", "videoalpha", "default", + "SampleProblem1"]) + model_data = {'data': VideoAlphaFactory.sample_problem_xml_youtube} + + descriptor = Mock(weight="1") + + system = test_system() + system.render_template = lambda template, context: context + VideoAlphaModule.location = location + module = VideoAlphaModule(system, descriptor, model_data) + + return module + + +class VideoAlphaModuleTest(LogicTest): + """Tests for logic of VideoAlpha Xmodule.""" + + descriptor_class = VideoAlphaDescriptor + + raw_model_data = { + 'data': '' + } + + def test_get_timeframe_no_parameters(self): + xmltree = etree.fromstring('test') + output = self.xmodule.get_timeframe(xmltree) + self.assertEqual(output, ('', '')) + + def test_get_timeframe_with_one_parameter(self): + xmltree = etree.fromstring( + 'test' + ) + output = self.xmodule.get_timeframe(xmltree) + self.assertEqual(output, (247, '')) + + def test_get_timeframe_with_two_parameters(self): + xmltree = etree.fromstring( + '''test''' + ) + output = self.xmodule.get_timeframe(xmltree) + self.assertEqual(output, (247, 47079)) + + +class VideoAlphaModuleUnitTest(unittest.TestCase): + """Unit tests for VideoAlpha Xmodule.""" + + def test_videoalpha_constructor(self): + """Make sure that all parameters extracted correclty from xml""" + module = VideoAlphaFactory.create() + + # `get_html` return only context, cause we + # overwrite `system.render_template` + context = module.get_html() + expected_context = { + 'caption_asset_path': '/static/subs/', + 'sub': module.sub, + 'data_dir': getattr(self, 'data_dir', None), + 'display_name': module.display_name_with_default, + 'end': module.end_time, + 'start': module.start_time, + 'id': module.location.html_id(), + 'show_captions': module.show_captions, + 'sources': module.sources, + 'youtube_streams': module.youtube_streams, + 'track': module.track, + 'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True) + } + self.assertDictEqual(context, expected_context) + + self.assertDictEqual( + json.loads(module.get_instance_state()), + {'position': 0}) diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index 6e9f6c1f71..496c834950 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -1,9 +1,9 @@ +import pytz from collections import defaultdict import logging import urllib from datetime import datetime -from courseware.module_render import get_module from django.contrib.auth.models import User from django.core.urlresolvers import reverse from django.db import connection @@ -169,7 +169,9 @@ def initialize_discussion_info(course): category = " / ".join([x.strip() for x in category.split("/")]) last_category = category.split("/")[-1] discussion_id_map[id] = {"location": module.location, "title": last_category + " / " + title} - unexpanded_category_map[category].append({"title": title, "id": id, "sort_key": sort_key, "start_date": module.lms.start}) + #Handle case where module.lms.start is None + entry_start_date = module.lms.start if module.lms.start else datetime.max.replace(tzinfo=pytz.UTC) + unexpanded_category_map[category].append({"title": title, "id": id, "sort_key": sort_key, "start_date": entry_start_date}) category_map = {"entries": defaultdict(dict), "subcategories": defaultdict(dict)} for category_path, entries in unexpanded_category_map.items(): diff --git a/lms/envs/aws.py b/lms/envs/aws.py index b9d3f58e8f..c8c49c2b1e 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -179,7 +179,7 @@ with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file: AUTH_TOKENS = json.load(auth_file) ############### Mixed Related(Secure/Not-Secure) Items ########## -# If segment.io key specified, load it and turn on segment IO if the feature flag is set +# If Segment.io key specified, load it and enable Segment.io if the feature flag is set SEGMENT_IO_LMS_KEY = AUTH_TOKENS.get('SEGMENT_IO_LMS_KEY') if SEGMENT_IO_LMS_KEY: MITX_FEATURES['SEGMENT_IO_LMS'] = ENV_TOKENS.get('SEGMENT_IO_LMS', False) diff --git a/test_root/data/videoalpha/gizmo.mp4 b/test_root/data/videoalpha/gizmo.mp4 new file mode 100644 index 0000000000..1fc478842f Binary files /dev/null and b/test_root/data/videoalpha/gizmo.mp4 differ diff --git a/test_root/data/videoalpha/gizmo.ogv b/test_root/data/videoalpha/gizmo.ogv new file mode 100644 index 0000000000..2c4a447f1f Binary files /dev/null and b/test_root/data/videoalpha/gizmo.ogv differ diff --git a/test_root/data/videoalpha/gizmo.webm b/test_root/data/videoalpha/gizmo.webm new file mode 100644 index 0000000000..95d5031a86 Binary files /dev/null and b/test_root/data/videoalpha/gizmo.webm differ