+
diff --git a/common/lib/xmodule/xmodule/js/spec/helper.js b/common/lib/xmodule/xmodule/js/spec/helper.js
index 1013faf367..2d81328d62 100644
--- a/common/lib/xmodule/xmodule/js/spec/helper.js
+++ b/common/lib/xmodule/xmodule/js/spec/helper.js
@@ -240,12 +240,19 @@
'setParams',
'setMode'
],
- obj = {};
+ obj = {},
+ delta = {
+ add: jasmine.createSpy().andReturn(obj),
+ substract: jasmine.createSpy().andReturn(obj),
+ reset: jasmine.createSpy().andReturn(obj)
+ };
$.each(methods, function (index, method) {
obj[method] = jasmine.createSpy(method).andReturn(obj);
});
+ obj.delta = delta;
+
return obj;
}());
diff --git a/common/lib/xmodule/xmodule/js/spec/video/general_spec.js b/common/lib/xmodule/xmodule/js/spec/video/general_spec.js
index 42bd385802..800108d91a 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/general_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/general_spec.js
@@ -75,32 +75,8 @@
expect(state.el).toBe('#video_id');
});
- it('parse the videos if subtitles exist', function () {
- var sub = 'Z5KLxerq05Y';
-
- expect(state.videos).toEqual({
- '0.75': sub,
- '1.0': sub,
- '1.25': sub,
- '1.50': sub
- });
- });
-
- it(
- 'parse the videos if subtitles do not exist',
- function ()
- {
- var sub = '';
-
- $('#example').find('.video').data('sub', '');
- state = new window.Video('#example');
-
- expect(state.videos).toEqual({
- '0.75': sub,
- '1.0': sub,
- '1.25': sub,
- '1.50': sub
- });
+ it('doesn\'t have `videos` dictionary', function () {
+ expect(state.videos).toBeUndefined();
});
it('parse Html5 sources', function () {
diff --git a/common/lib/xmodule/xmodule/js/spec/video/resizer_spec.js b/common/lib/xmodule/xmodule/js/spec/video/resizer_spec.js
index ea296bfa81..a09508670e 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/resizer_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/resizer_spec.js
@@ -18,7 +18,7 @@ function (Resizer) {
'',
''
].join(''),
- config, container, element, originalConsoleLog;
+ config, container, element;
beforeEach(function () {
setFixtures(html);
@@ -30,14 +30,9 @@ function (Resizer) {
element: element
};
- originalConsoleLog = window.console.log;
spyOn(console, 'log');
});
- afterEach(function () {
- window.console.log = originalConsoleLog;
- });
-
it('When Initialize without required parameters, log message is shown',
function () {
new Resizer({ });
@@ -134,7 +129,7 @@ function (Resizer) {
expect(spiesList[0].calls.length).toEqual(1);
});
- it('All callbacks are removed', function () {
+ it('all callbacks are removed', function () {
$.each(spiesList, function (index, spy) {
resizer.callbacks.add(spy);
});
@@ -147,7 +142,7 @@ function (Resizer) {
});
});
- it('Specific callback is removed', function () {
+ it('specific callback is removed', function () {
$.each(spiesList, function (index, spy) {
resizer.callbacks.add(spy);
});
@@ -176,9 +171,86 @@ function (Resizer) {
});
});
-
});
+ describe('Delta', function () {
+ var resizer;
+
+ beforeEach(function () {
+ resizer = new Resizer(config);
+ });
+
+ it('adding delta align correctly by height', function () {
+ var delta = 100,
+ expectedHeight = container.height() + delta,
+ realHeight;
+
+ resizer
+ .delta.add(delta, 'height')
+ .setMode('height');
+
+ realHeight = element.height();
+
+ expect(realHeight).toBe(expectedHeight);
+ });
+
+ it('adding delta align correctly by width', function () {
+ var delta = 100,
+ expectedWidth = container.width() + delta,
+ realWidth;
+
+ resizer
+ .delta.add(delta, 'width')
+ .setMode('width');
+
+ realWidth = element.width();
+
+ expect(realWidth).toBe(expectedWidth);
+ });
+
+ it('substract delta align correctly by height', function () {
+ var delta = 100,
+ expectedHeight = container.height() - delta,
+ realHeight;
+
+ resizer
+ .delta.substract(delta, 'height')
+ .setMode('height');
+
+ realHeight = element.height();
+
+ expect(realHeight).toBe(expectedHeight);
+ });
+
+ it('substract delta align correctly by width', function () {
+ var delta = 100,
+ expectedWidth = container.width() - delta,
+ realWidth;
+
+ resizer
+ .delta.substract(delta, 'width')
+ .setMode('width');
+
+ realWidth = element.width();
+
+ expect(realWidth).toBe(expectedWidth);
+ });
+
+ it('reset delta', function () {
+ var delta = 100,
+ expectedWidth = container.width(),
+ realWidth;
+
+ resizer
+ .delta.substract(delta, 'width')
+ .delta.reset()
+ .setMode('width');
+
+ realWidth = element.width();
+
+ expect(realWidth).toBe(expectedWidth);
+ });
+ });
});
});
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_accessible_menu_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_accessible_menu_spec.js
new file mode 100644
index 0000000000..feb332122c
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_accessible_menu_spec.js
@@ -0,0 +1,307 @@
+(function (undefined) {
+ describe('Video Accessible Menu', function () {
+ var state;
+
+ afterEach(function () {
+ $('source').remove();
+ state.storage.clear();
+ });
+
+ describe('constructor', function () {
+ describe('always', function () {
+ var videoTracks, container, button, menu, menuItems,
+ menuItemsLinks;
+
+ beforeEach(function () {
+ state = jasmine.initializePlayer();
+ videoTracks = $('li.video-tracks');
+ container = videoTracks.children('div.a11y-menu-container');
+ button = container.children('a.a11y-menu-button');
+ menuList = container.children('ol.a11y-menu-list');
+ menuItems = menuList.children('li.a11y-menu-item');
+ menuItemsLinks = menuItems.children('a.a11y-menu-item-link');
+ });
+
+ it('add the accessible menu', function () {
+ var activeMenuItem;
+ // Make sure we have the expected HTML structure:
+ // Menu container exists
+ expect(container.length).toBe(1);
+ // Only one button and one menu list per menu container.
+ expect(button.length).toBe(1);
+ expect(menuList.length).toBe(1);
+ // At least one menu item and one menu link per menu
+ // container. Exact length test?
+ expect(menuItems.length).toBeGreaterThan(0);
+ expect(menuItemsLinks.length).toBeGreaterThan(0);
+ expect(menuItems.length).toBe(menuItemsLinks.length);
+ // And one menu item is active
+ activeMenuItem = menuItems.filter('.active');
+ expect(activeMenuItem.length).toBe(1);
+
+ expect(activeMenuItem.children('a.a11y-menu-item-link'))
+ .toHaveData('value', 'srt');
+
+ expect(activeMenuItem.children('a.a11y-menu-item-link'))
+ .toHaveHtml('SubRip (.srt) file');
+
+ /* TO DO: Check that all the anchors contain correct text.
+ $.each(li.toArray().reverse(), function (index, link) {
+ expect($(link)).toHaveData(
+ 'speed', state.videoSpeedControl.speeds[index]
+ );
+ expect($(link).find('a').text()).toBe(
+ state.videoSpeedControl.speeds[index] + 'x'
+ );
+ });
+ */
+ });
+
+ it('add ARIA attributes to button, menu, and menu items links',
+ function () {
+ expect(button).toHaveAttrs({
+ 'role': 'button',
+ 'title': '.srt',
+ 'aria-disabled': 'false'
+ });
+
+ expect(menuList).toHaveAttr('role', 'menu');
+
+ menuItemsLinks.each(function(){
+ expect($(this)).toHaveAttrs({
+ 'role': 'menuitem',
+ 'aria-disabled': 'false'
+ });
+ });
+ });
+ });
+
+ describe('when running', function () {
+ var videoTracks, container, button, menu, menuItems,
+ menuItemsLinks, KEY = $.ui.keyCode,
+
+ keyPressEvent = function(key) {
+ return $.Event('keydown', {keyCode: key});
+ },
+
+ tabBackPressEvent = function() {
+ return $.Event('keydown',
+ {keyCode: KEY.TAB, shiftKey: true});
+ },
+
+ tabForwardPressEvent = function() {
+ return $.Event('keydown',
+ {keyCode: KEY.TAB, shiftKey: false});
+ },
+
+ // Get previous element in array or cyles back to the last
+ // if it is the first.
+ previousSpeed = function(index) {
+ return speedEntries.eq(index < 1 ?
+ speedEntries.length - 1 :
+ index - 1);
+ },
+
+ // Get next element in array or cyles back to the first if
+ // it is the last.
+ nextSpeed = function(index) {
+ return speedEntries.eq(index >= speedEntries.length-1 ?
+ 0 :
+ index + 1);
+ };
+
+ beforeEach(function () {
+ state = jasmine.initializePlayer();
+ videoTracks = $('li.video-tracks');
+ container = videoTracks.children('div.a11y-menu-container');
+ button = container.children('a.a11y-menu-button');
+ menuList = container.children('ol.a11y-menu-list');
+ menuItems = menuList.children('li.a11y-menu-item');
+ menuItemsLinks = menuItems.children('a.a11y-menu-item-link');
+ spyOn($.fn, 'focus').andCallThrough();
+ });
+
+ it('open/close the menu on mouseenter/mouseleave', function () {
+ container.mouseenter();
+ expect(container).toHaveClass('open');
+ container.mouseleave();
+ expect(container).not.toHaveClass('open');
+ });
+
+ it('do not close the menu on mouseleave if a menu item has ' +
+ 'focus', function () {
+ // Open menu. Focus is on last menu item.
+ container.trigger(keyPressEvent(KEY.ENTER));
+ container.mouseenter().mouseleave();
+ expect(container).toHaveClass('open');
+ });
+
+ it('close the menu on click', function () {
+ container.mouseenter().click();
+ expect(container).not.toHaveClass('open');
+ });
+
+ it('close the menu on outside click', function () {
+ container.trigger(keyPressEvent(KEY.ENTER));
+ $(window).click();
+ expect(container).not.toHaveClass('open');
+ });
+
+ it('open the menu on ENTER keydown', function () {
+ container.trigger(keyPressEvent(KEY.ENTER));
+ expect(container).toHaveClass('open');
+ expect(menuItemsLinks.last().focus).toHaveBeenCalled();
+ });
+
+ it('open the menu on SPACE keydown', function () {
+ container.trigger(keyPressEvent(KEY.SPACE));
+ expect(container).toHaveClass('open');
+ expect(menuItemsLinks.last().focus).toHaveBeenCalled();
+ });
+
+ it('open the menu on UP keydown', function () {
+ container.trigger(keyPressEvent(KEY.UP));
+ expect(container).toHaveClass('open');
+ expect(menuItemsLinks.last().focus).toHaveBeenCalled();
+ });
+
+ it('close the menu on ESCAPE keydown', function () {
+ container.trigger(keyPressEvent(KEY.ESCAPE));
+ expect(container).not.toHaveClass('open');
+ });
+
+ it('UP and DOWN keydown function as expected on menu items',
+ function () {
+ // Iterate through list in both directions and check if
+ // things wrap up correctly.
+ var lastEntry = menuItemsLinks.length-1, i;
+
+ // First open menu
+ container.trigger(keyPressEvent(KEY.UP));
+
+ // Iterate with UP key until we have looped.
+ for (i = lastEntry; i >= 0; i--) {
+ menuItemsLinks.eq(i).trigger(keyPressEvent(KEY.UP));
+ }
+
+ // Iterate with DOWN key until we have looped.
+ for (i = 0; i <= lastEntry; i++) {
+ menuItemsLinks.eq(i).trigger(keyPressEvent(KEY.DOWN));
+ }
+
+ // Test if each element has been called twice.
+ expect($.fn.focus.calls.length)
+ .toEqual(2*menuItemsLinks.length+1);
+ });
+
+ it('ESC keydown on menu item closes menu', function () {
+ // First open menu. Focus is on last speed entry.
+ container.trigger(keyPressEvent(KEY.UP));
+ menuItemsLinks.last().trigger(keyPressEvent(KEY.ESCAPE));
+
+ // Menu is closed and focus has been returned to speed
+ // control.
+ expect(container).not.toHaveClass('open');
+ expect(container.focus).toHaveBeenCalled();
+ });
+
+ it('ENTER keydown on menu item selects its data and closes menu',
+ function () {
+ // First open menu.
+ container.trigger(keyPressEvent(KEY.UP));
+ // Focus on '.txt'
+ menuItemsLinks.eq(0).focus();
+ menuItemsLinks.eq(0).trigger(keyPressEvent(KEY.ENTER));
+
+ // Menu is closed, focus has been returned to container
+ // and file format is '.txt'.
+ /* TO DO
+ expect(container.focus).toHaveBeenCalled();
+ expect($('.video_speeds li[data-speed="1.50"]'))
+ .toHaveClass('active');
+ expect($('.speeds p.active')).toHaveHtml('1.50x');
+ */
+ });
+
+ it('SPACE keydown on menu item selects its data and closes menu',
+ function () {
+ // First open menu.
+ container.trigger(keyPressEvent(KEY.UP));
+ // Focus on '.txt'
+ menuItemsLinks.eq(0).focus();
+ menuItemsLinks.eq(0).trigger(keyPressEvent(KEY.SPACE));
+
+ // Menu is closed, focus has been returned to container
+ // and file format is '.txt'.
+ /* TO DO
+ expect(speedControl.focus).toHaveBeenCalled();
+ expect($('.video_speeds li[data-speed="1.50"]'))
+ .toHaveClass('active');
+ expect($('.speeds p.active')).toHaveHtml('1.50x');
+ */
+ });
+
+ // TO DO? No such behavior implemented.
+ xit('TAB + SHIFT keydown on speed entry closes menu and gives ' +
+ 'focus to Play/Pause control', function () {
+ // First open menu. Focus is on last speed entry.
+ speedControl.trigger(keyPressEvent(KEY.UP));
+ speedEntries.last().trigger(tabBackPressEvent());
+
+ // Menu is closed and focus has been given to Play/Pause
+ // control.
+ expect(state.videoControl.playPauseEl.focus)
+ .toHaveBeenCalled();
+ });
+
+ // TO DO? No such behavior implemented.
+ xit('TAB keydown on speed entry closes menu and gives focus ' +
+ 'to Volume control', function () {
+ // First open menu. Focus is on last speed entry.
+ speedControl.trigger(keyPressEvent(KEY.UP));
+ speedEntries.last().trigger(tabForwardPressEvent());
+
+ // Menu is closed and focus has been given to Volume
+ // control.
+ expect(state.videoVolumeControl.buttonEl.focus)
+ .toHaveBeenCalled();
+ });
+ });
+ });
+
+ // TODO
+ xdescribe('change file format', function () {
+ describe('when new file format is not the same', function () {
+ beforeEach(function () {
+ state = jasmine.initializePlayer();
+ state.videoSpeedControl.setSpeed(1.0);
+ spyOn(state.videoPlayer, 'onSpeedChange').andCallThrough();
+
+ $('li[data-speed="0.75"] a').click();
+ });
+
+ it('trigger speedChange event', function () {
+ expect(state.videoPlayer.onSpeedChange).toHaveBeenCalled();
+ expect(state.videoSpeedControl.currentSpeed).toEqual(0.75);
+ });
+ });
+ });
+
+ // TODO
+ xdescribe('onSpeedChange', function () {
+ beforeEach(function () {
+ state = jasmine.initializePlayer();
+ $('li[data-speed="1.0"] a').addClass('active');
+ state.videoSpeedControl.setSpeed(0.75);
+ });
+
+ it('set the new speed as active', function () {
+ 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');
+ });
+ });
+ });
+}).call(this);
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js
index a9e5236c93..f57cde4d1c 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js
@@ -7,8 +7,6 @@
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice')
.andReturn(null);
- state = jasmine.initializePlayer();
- videoControl = state.videoControl;
$.fn.scrollTo.reset();
});
@@ -29,18 +27,20 @@
describe('always', function () {
beforeEach(function () {
spyOn($, 'ajaxWithPrefix').andCallThrough();
- state = jasmine.initializePlayer();
});
it('create the caption element', function () {
+ state = jasmine.initializePlayer();
expect($('.video')).toContain('ol.subtitles');
});
it('add caption control to video player', function () {
+ state = jasmine.initializePlayer();
expect($('.video')).toContain('a.hide-subtitles');
});
it('add ARIA attributes to caption control', function () {
+ state = jasmine.initializePlayer();
var captionControl = $('a.hide-subtitles');
expect(captionControl).toHaveAttrs({
'role': 'button',
@@ -49,7 +49,11 @@
});
});
- it('fetch the caption', function () {
+ it('fetch the caption in HTML5 mode', function () {
+ runs(function () {
+ state = jasmine.initializePlayer();
+ });
+
waitsFor(function () {
if (state.videoCaption.loaded === true) {
return true;
@@ -62,29 +66,55 @@
expect($.ajaxWithPrefix).toHaveBeenCalledWith({
url: '/transcript/translation',
notifyOnError: false,
- data: {
- videoId: 'Z5KLxerq05Y',
- language: 'en'
- },
+ data: jasmine.any(Object),
success: jasmine.any(Function),
error: jasmine.any(Function)
});
+ expect($.ajaxWithPrefix.mostRecentCall.args[0].data)
+ .toEqual({
+ language: 'en'
+ });
});
});
- it('bind window resize event', function () {
- expect($(window)).toHandleWith(
- 'resize', state.videoCaption.resize
- );
+ it('fetch the caption in Youtube mode', function () {
+ runs(function () {
+ state = jasmine.initializePlayerYouTube();
+ });
+
+ waitsFor(function () {
+ if (state.videoCaption.loaded === true) {
+ return true;
+ }
+
+ return false;
+ }, 'Expect captions to be loaded.', WAIT_TIMEOUT);
+
+ runs(function () {
+ expect($.ajaxWithPrefix).toHaveBeenCalledWith({
+ url: '/transcript/translation',
+ notifyOnError: false,
+ data: jasmine.any(Object),
+ success: jasmine.any(Function),
+ error: jasmine.any(Function)
+ });
+ expect($.ajaxWithPrefix.mostRecentCall.args[0].data)
+ .toEqual({
+ language: 'en',
+ videoId: 'abcdefghijkl'
+ });
+ });
});
it('bind the hide caption button', function () {
+ state = jasmine.initializePlayer();
expect($('.hide-subtitles')).toHandleWith(
'click', state.videoCaption.toggle
);
});
it('bind the mouse movement', function () {
+ state = jasmine.initializePlayer();
expect($('.subtitles')).toHandleWith(
'mouseover', state.videoCaption.onMouseEnter
);
@@ -103,8 +133,9 @@
});
it('bind the scroll', function () {
- expect($('.subtitles'))
- .toHandleWith('scroll', state.videoControl.showControls);
+ state = jasmine.initializePlayer();
+ expect($('.subtitles'))
+ .toHandleWith('scroll', state.videoControl.showControls);
});
});
@@ -284,7 +315,8 @@
describe('when no captions file was specified', function () {
beforeEach(function () {
state = jasmine.initializePlayer('video_all.html', {
- 'sub': ''
+ 'sub': '',
+ 'transcriptLanguages': {},
});
});
@@ -395,6 +427,8 @@
});
it('reRenderCaption', function () {
+ state = jasmine.initializePlayer();
+
var Caption = state.videoCaption,
li;
@@ -426,14 +460,6 @@
spyOn(state, 'youtubeId').andReturn('Z5KLxerq05Y');
});
- it('do not fetch captions, if 1.0 speed is absent', function () {
- state.youtubeId.andReturn(void(0));
- Caption.fetchCaption();
-
- expect($.ajaxWithPrefix).not.toHaveBeenCalled();
- expect(Caption.hideCaptions).not.toHaveBeenCalled();
- });
-
it('show caption on language change', function () {
Caption.loaded = true;
Caption.fetchCaption();
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_control_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_control_spec.js
index ac442e391d..cdc43a7ae8 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_control_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_control_spec.js
@@ -549,6 +549,17 @@
});
});
+ it('Controls height is actual on switch to fullscreen', function () {
+ spyOn($.fn, 'height').andCallFake(function (val) {
+ return _.isUndefined(val) ? 100: this;
+ });
+
+ state = jasmine.initializePlayer();
+ $(state.el).trigger('fullscreen');
+
+ expect(state.videoControl.height).toBe(150);
+ });
+
describe('play', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js
index db75ea75e7..55c1cc4a53 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js
@@ -45,7 +45,6 @@ function (VideoPlayer) {
it('create video caption', function () {
expect(state.videoCaption).toBeDefined();
- expect(state.youtubeId('1.0')).toEqual('Z5KLxerq05Y');
expect(state.speed).toEqual('1.50');
expect(state.config.transcriptTranslationUrl)
.toEqual('/transcript/translation');
@@ -712,6 +711,7 @@ function (VideoPlayer) {
state.videoEl = $('video, iframe');
spyOn(state.videoCaption, 'resize').andCallThrough();
+ spyOn($.fn, 'trigger').andCallThrough();
state.videoControl.toggleFullScreen(jQuery.Event('click'));
});
@@ -726,7 +726,8 @@ function (VideoPlayer) {
it('tell VideoCaption to resize', function () {
expect(state.videoCaption.resize).toHaveBeenCalled();
- expect(state.resizer.setMode).toHaveBeenCalled();
+ expect(state.resizer.setMode).toHaveBeenCalledWith('both');
+ expect(state.resizer.delta.substract).toHaveBeenCalled();
});
});
@@ -759,6 +760,7 @@ function (VideoPlayer) {
expect(state.videoCaption.resize).toHaveBeenCalled();
expect(state.resizer.setMode)
.toHaveBeenCalledWith('width');
+ expect(state.resizer.delta.reset).toHaveBeenCalled();
});
});
});
diff --git a/common/lib/xmodule/xmodule/js/src/video/00_resizer.js b/common/lib/xmodule/xmodule/js/src/video/00_resizer.js
index 0fd34968a6..edff5a9163 100644
--- a/common/lib/xmodule/xmodule/js/src/video/00_resizer.js
+++ b/common/lib/xmodule/xmodule/js/src/video/00_resizer.js
@@ -13,20 +13,24 @@ function () {
elementRatio: null
},
callbacksList = [],
+ delta = {
+ height: 0,
+ width: 0
+ },
module = {},
mode = null,
config;
var initialize = function (params) {
- if (config) {
- config = $.extend(true, config, params);
- } else {
- config = $.extend(true, {}, defaults, params);
+ if (!config) {
+ config = defaults;
}
+ config = $.extend(true, {}, config, params);
+
if (!config.element) {
console.log(
- '[Video info]: Required parameter `element` is not passed.'
+ 'Required parameter `element` is not passed.'
);
}
@@ -35,8 +39,8 @@ function () {
var getData = function () {
var container = $(config.container),
- containerWidth = container.width(),
- containerHeight = container.height(),
+ containerWidth = container.width() + delta.width,
+ containerHeight = container.height() + delta.height,
containerRatio = config.containerRatio,
element = $(config.element),
@@ -74,7 +78,6 @@ function () {
default:
if (data.containerRatio >= data.elementRatio) {
alignByHeightOnly();
-
} else {
alignByWidthOnly();
}
@@ -142,7 +145,7 @@ function () {
addCallback(decorator);
} else {
- console.error('[Video info]: TypeError: Argument is not a function.');
+ console.error('TypeError: Argument is not a function.');
}
return module;
@@ -168,6 +171,29 @@ function () {
}
};
+ var cleanDelta = function () {
+ delta['height'] = 0;
+ delta['width'] = 0;
+
+ return module;
+ };
+
+ var addDelta = function (value, side) {
+ if (_.isNumber(value) && _.isNumber(delta[side])) {
+ delta[side] += value;
+ }
+
+ return module;
+ };
+
+ var substractDelta = function (value, side) {
+ if (_.isNumber(value) && _.isNumber(delta[side])) {
+ delta[side] -= value;
+ }
+
+ return module;
+ };
+
initialize.apply(module, arguments);
return $.extend(true, module, {
@@ -181,6 +207,11 @@ function () {
once: addOnceCallback,
remove: removeCallback,
removeAll: removeCallbacks
+ },
+ delta: {
+ add: addDelta,
+ substract: substractDelta,
+ reset: cleanDelta
}
});
};
diff --git a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js
index 7cea30aa9e..8be4bf9e06 100644
--- a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js
+++ b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js
@@ -202,12 +202,6 @@ function (VideoPlayer, VideoStorage) {
);
state.speeds = ['0.75', '1.0', '1.25', '1.50'];
- state.videos = {
- '0.75': state.config.sub,
- '1.0': state.config.sub,
- '1.25': state.config.sub,
- '1.50': state.config.sub
- };
// We must have at least one non-YouTube video source available.
// Otherwise, return a negative.
diff --git a/common/lib/xmodule/xmodule/js/src/video/035_video_accessible_menu.js b/common/lib/xmodule/xmodule/js/src/video/035_video_accessible_menu.js
new file mode 100644
index 0000000000..53e54f817c
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/video/035_video_accessible_menu.js
@@ -0,0 +1,308 @@
+(function (requirejs, require, define) {
+
+// VideoAccessibleMenu module.
+define(
+'video/035_video_accessible_menu.js',
+[],
+function () {
+
+ // VideoAccessibleMenu() function - what this module "exports".
+ return function (state) {
+ var dfd = $.Deferred();
+
+ if (state.el.find('li.video-tracks') === 0) {
+ dfd.resolve();
+ return dfd.promise();
+ }
+
+ state.videoAccessibleMenu = {
+ value: state.storage.getItem('transcript_download_format')
+ };
+
+ _initialize(state);
+ dfd.resolve();
+ return dfd.promise();
+ };
+
+ // ***************************************************************
+ // Private functions start here.
+ // ***************************************************************
+
+ function _initialize(state) {
+ _makeFunctionsPublic(state);
+ _renderElements(state);
+ _addAriaAttributes(state);
+ _bindHandlers(state);
+ }
+
+ // function _makeFunctionsPublic(state)
+ //
+ // Functions which will be accessible via 'state' object. When called,
+ // these functions will get the 'state' object as a context.
+ function _makeFunctionsPublic(state) {
+ var methodsDict = {
+ changeFileType: changeFileType,
+ setValue: setValue
+ };
+
+ state.bindTo(methodsDict, state.videoAccessibleMenu, state);
+ }
+
+ // function _renderElements(state)
+ //
+ // Create any necessary DOM elements, attach them, and set their
+ // initial configuration. Also make the created DOM elements available
+ // via the 'state' object. Much easier to work this way - you don't
+ // have to do repeated jQuery element selects.
+ function _renderElements(state) {
+
+ // For the time being, we assume that the menu structure is present in
+ // the template HTML. In the future accessible menu plugin, everything
+ // inside will be generated in this
+ // file.
+ var container = state.el.find('li.video-tracks>div.a11y-menu-container'),
+ button = container.children('a.a11y-menu-button'),
+ menuList = container.children('ol.a11y-menu-list'),
+ menuItems = menuList.children('li.a11y-menu-item'),
+ menuItemsLinks = menuItems.children('a.a11y-menu-item-link'),
+ value = (function (val, activeElement) {
+ return val || activeElement.find('a').data('value') || 'srt';
+ }(state.videoAccessibleMenu.value, menuItems.filter('.active'))),
+ msg = '.' + value;
+
+ $.extend(state.videoAccessibleMenu, {
+ container: container,
+ button: button,
+ menuList: menuList,
+ menuItems: menuItems,
+ menuItemsLinks: menuItemsLinks
+ });
+
+ if (value) {
+ state.videoAccessibleMenu.setValue(value);
+ button.text(gettext(msg));
+ }
+ }
+
+ function _addAriaAttributes(state) {
+ var menu = state.videoAccessibleMenu;
+
+ menu.button.attr({
+ 'role': 'button',
+ 'aria-disabled': 'false'
+ });
+
+ menu.menuList.attr('role', 'menu');
+
+ menu.menuItemsLinks.each(function(){
+ $(this).attr({
+ 'role': 'menuitem',
+ 'aria-disabled': 'false'
+ });
+ });
+ }
+
+ // Get previous element in array or cyles back to the last if it is the
+ // first.
+ function _previousMenuItemLink(links, index) {
+ return $(links.eq(index < 1 ? links.length - 1 : index - 1));
+ }
+
+ // Get next element in array or cyles back to the first if it is the last.
+ function _nextMenuItemLink(links, index) {
+ return $(links.eq(index >= links.length - 1 ? 0 : index + 1));
+ }
+
+ function _menuItemsLinksFocused(menu) {
+ return menu.menuItemsLinks.is(':focus');
+ }
+
+ function _openMenu(menu, without_handler) {
+ // When menu items have focus, the menu stays open on
+ // mouseleave. A _closeMenuHandler is added to the window
+ // element to have clicks close the menu when they happen
+ // outside of it. We namespace the click event to easily remove it (and
+ // only it) in _closeMenu.
+ menu.container.addClass('open');
+ menu.button.text('...');
+ if (!without_handler) {
+ $(window).on('click.currentMenu', _closeMenuHandler.bind(menu));
+ }
+
+ // @TODO: onOpen callback
+ }
+
+ function _closeMenu(menu, without_handler) {
+ // Remove the previously added clickHandler from window element.
+ var msg = '.' + menu.value;
+
+ menu.container.removeClass('open');
+ menu.button.text(gettext(msg));
+ if (!without_handler) {
+ $(window).off('click.currentMenu');
+ }
+
+ // @TODO: onClose callback
+ }
+
+ function _openMenuHandler(event) {
+ _openMenu(this, true);
+
+ return false;
+ }
+
+ function _closeMenuHandler(event) {
+ // Only close the menu if no menu item link has focus or `click` event.
+ if (!_menuItemsLinksFocused(this) || event.type == 'click') {
+ _closeMenu(this, true);
+ }
+
+ return false;
+ }
+
+ function _toggleMenuHandler(event) {
+ if (this.container.hasClass('open')) {
+ _closeMenu(this, true);
+ } else {
+ _openMenu(this, true);
+ }
+
+ return false;
+ }
+
+ // Various event handlers. They all return false to stop propagation and
+ // prevent default behavior.
+ function _clickHandler(event) {
+ var target = $(event.currentTarget);
+
+ this.changeFileType.call(this, event);
+ _closeMenu(this, true);
+
+ return false;
+ }
+
+ function _keyDownHandler(event) {
+ var KEY = $.ui.keyCode,
+ keyCode = event.keyCode,
+ target = $(event.currentTarget),
+ index;
+
+ if (target.is('a.a11y-menu-item-link')) {
+
+ index = target.parent().index();
+
+ switch (keyCode) {
+ // Scroll up menu, wrapping at the top. Keep menu open.
+ case KEY.UP:
+ _previousMenuItemLink(this.menuItemsLinks, index).focus();
+ break;
+ // Scroll down menu, wrapping at the bottom. Keep menu
+ // open.
+ case KEY.DOWN:
+ _nextMenuItemLink(this.menuItemsLinks, index).focus();
+ break;
+ // Close menu.
+ case KEY.TAB:
+ _closeMenu(this);
+ // TODO
+ // What has to happen here? In speed menu, tabbing backward
+ // will give focus to Play/Pause button and tabbing
+ // forward to Volume button.
+ break;
+ // Close menu, give focus to button and change
+ // file type.
+ case KEY.ENTER:
+ case KEY.SPACE:
+ this.button.focus();
+ this.changeFileType.call(this, event);
+ _closeMenu(this);
+ break;
+ // Close menu and give focus to speed control.
+ case KEY.ESCAPE:
+ _closeMenu(this);
+ this.button.focus();
+ break;
+ }
+ return false;
+ }
+ else {
+ switch(keyCode) {
+ // Open menu and focus on last element of list above it.
+ case KEY.ENTER:
+ case KEY.SPACE:
+ case KEY.UP:
+ _openMenu(this);
+ this.menuItemsLinks.last().focus();
+ break;
+ // Close menu.
+ case KEY.ESCAPE:
+ _closeMenu(this);
+ break;
+ }
+ // We do not stop propagation and default behavior on a TAB
+ // keypress.
+ return event.keyCode === KEY.TAB;
+ }
+ }
+
+ /**
+ * @desc Bind any necessary function callbacks to DOM events (click,
+ * mousemove, etc.).
+ *
+ * @type {function}
+ * @access private
+ *
+ * @param {object} state The object containg the state of the video player.
+ * All other modules, their parameters, public variables, etc. are
+ * available via this object.
+ *
+ * @this {object} The global window object.
+ *
+ * @returns {undefined}
+ */
+ function _bindHandlers(state) {
+ var menu = state.videoAccessibleMenu;
+
+ // Attach various events handlers to menu container.
+ menu.container.on({
+ 'mouseenter': _openMenuHandler.bind(menu),
+ 'mouseleave': _closeMenuHandler.bind(menu),
+ 'click': _toggleMenuHandler.bind(menu),
+ 'keydown': _keyDownHandler.bind(menu)
+ });
+
+ // Attach click and keydown event handlers to individual menu items.
+ menu.menuItems
+ .on('click', 'a.a11y-menu-item-link', _clickHandler.bind(menu))
+ .on('keydown', 'a.a11y-menu-item-link', _keyDownHandler.bind(menu));
+ }
+
+ function setValue(value) {
+ var menu = this.videoAccessibleMenu;
+
+ menu.value = value;
+ menu.menuItems
+ .removeClass('active')
+ .find("a[data-value='" + value + "']")
+ .parent()
+ .addClass('active');
+ }
+
+ // ***************************************************************
+ // Public functions start here.
+ // These are available via the 'state' object. Their context ('this'
+ // keyword) is the 'state' object. The magic private function that makes
+ // them available and sets up their context is makeFunctionsPublic().
+ // ***************************************************************
+
+ function changeFileType(event) {
+ var fileType = $(event.currentTarget).data('value');
+
+ this.videoAccessibleMenu.setValue(fileType);
+ this.saveState(true, {'transcript_download_format': fileType});
+ this.storage.setItem('transcript_download_format', fileType);
+ }
+
+});
+
+}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
diff --git a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js
index 41333de7d4..7678a2db1e 100644
--- a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js
+++ b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js
@@ -221,7 +221,7 @@ function (HTML5Video, Resizer) {
state.resizer = new Resizer({
element: state.videoEl,
elementRatio: videoWidth/videoHeight,
- container: state.videoEl.parent()
+ container: state.container
})
.callbacks.once(function() {
state.trigger('videoCaption.resize', null);
@@ -235,7 +235,11 @@ function (HTML5Video, Resizer) {
});
}
- $(window).bind('resize', _.debounce(state.resizer.align, 100));
+ $(window).on('resize', _.debounce(function () {
+ state.trigger('videoControl.updateControlsHeight', null);
+ state.trigger('videoCaption.resize', null);
+ state.resizer.align();
+ }, 100));
}
// function _restartUsingFlash(state)
@@ -461,7 +465,7 @@ function (HTML5Video, Resizer) {
this.videoPlayer.log(
'pause_video',
{
- 'currentTime': this.videoPlayer.currentTime
+ currentTime: this.videoPlayer.currentTime
}
);
@@ -482,7 +486,7 @@ function (HTML5Video, Resizer) {
this.videoPlayer.log(
'play_video',
{
- 'currentTime': this.videoPlayer.currentTime
+ currentTime: this.videoPlayer.currentTime
}
);
@@ -863,8 +867,7 @@ function (HTML5Video, Resizer) {
// Default parameters that always get logged.
logInfo = {
- 'id': this.id,
- 'code': this.youtubeId()
+ id: this.id
};
// If extra parameters were passed to the log.
diff --git a/common/lib/xmodule/xmodule/js/src/video/04_video_control.js b/common/lib/xmodule/xmodule/js/src/video/04_video_control.js
index 8c21568a0f..205d062551 100644
--- a/common/lib/xmodule/xmodule/js/src/video/04_video_control.js
+++ b/common/lib/xmodule/xmodule/js/src/video/04_video_control.js
@@ -40,6 +40,7 @@ function () {
showPlayPlaceholder: showPlayPlaceholder,
toggleFullScreen: toggleFullScreen,
togglePlayback: togglePlayback,
+ updateControlsHeight: updateControlsHeight,
updateVcrVidTime: updateVcrVidTime
};
@@ -83,6 +84,8 @@ function () {
'role': 'slider',
'title': gettext('Video slider')
});
+
+ state.videoControl.updateControlsHeight();
}
// function _bindHandlers(state)
@@ -91,6 +94,23 @@ function () {
function _bindHandlers(state) {
state.videoControl.playPauseEl.on('click', state.videoControl.togglePlayback);
state.videoControl.fullScreenEl.on('click', state.videoControl.toggleFullScreen);
+ state.el.on('fullscreen', function (event, isFullScreen) {
+ var height = state.videoControl.updateControlsHeight();
+
+ if (isFullScreen) {
+ state.resizer
+ .delta
+ .substract(height, 'height')
+ .setMode('both');
+
+ } else {
+ state.resizer
+ .delta
+ .reset()
+ .setMode('width');
+ }
+ });
+
$(document).on('keyup', state.videoControl.exitFullScreen);
if ((state.videoType === 'html5') && (state.config.autohideHtml5)) {
@@ -110,12 +130,22 @@ function () {
});
}
}
+ function _getControlsHeight(control) {
+ return control.el.height() + 0.5 * control.sliderEl.height();
+ }
// ***************************************************************
// Public functions start here.
// These are available via the 'state' object. Their context ('this' keyword) is the 'state' object.
// The magic private function that makes them available and sets up their context is makeFunctionsPublic().
// ***************************************************************
+
+ function updateControlsHeight () {
+ this.videoControl.height = _getControlsHeight(this.videoControl);
+
+ return this.videoControl.height;
+ }
+
function show() {
this.videoControl.el.removeClass('is-hidden');
this.el.trigger('controls:show', arguments);
@@ -234,13 +264,6 @@ function () {
this.videoControl.fullScreenState = this.isFullScreen = false;
fullScreenClassNameEl.removeClass('video-fullscreen');
text = gettext('Fill browser');
-
- this.resizer
- .setParams({
- container: this.videoEl.parent()
- })
- .setMode('width');
-
win.scrollTop(this.scrollPos);
} else {
this.scrollPos = win.scrollTop();
@@ -248,13 +271,6 @@ function () {
this.videoControl.fullScreenState = this.isFullScreen = true;
fullScreenClassNameEl.addClass('video-fullscreen');
text = gettext('Exit full browser');
-
- this.resizer
- .setParams({
- container: window
- })
- .setMode('both');
-
}
this.videoControl.fullScreenEl
@@ -262,6 +278,7 @@ function () {
.text(text);
this.trigger('videoCaption.resize', null);
+ this.el.trigger('fullscreen', [this.isFullScreen]);
}
function exitFullScreen(event) {
diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
index 06ff56e180..13917defd2 100644
--- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
+++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
@@ -135,7 +135,6 @@ function () {
var self = this,
Caption = this.videoCaption;
- $(window).bind('resize', Caption.resize);
Caption.hideSubtitlesEl.on({
'click': Caption.toggle
});
@@ -226,14 +225,10 @@ function () {
*/
function fetchCaption() {
var self = this,
- Caption = self.videoCaption;
- // Check whether the captions file was specified. This is the point
- // where we either stop with the caption panel (so that a white empty
- // panel to the right of the video will not be shown), or carry on
- // further.
- if (!this.youtubeId('1.0')) {
- return false;
- }
+ Caption = self.videoCaption,
+ data = {
+ language: this.getCurrentLanguage()
+ };
if (Caption.loaded) {
Caption.hideCaptions(false);
@@ -245,15 +240,16 @@ function () {
Caption.fetchXHR.abort();
}
+ if (this.videoType === 'youtube') {
+ data.videoId = this.youtubeId();
+ }
+
// Fetch the captions file. If no file was specified, or if an error
// occurred, then we hide the captions panel, and the "CC" button
Caption.fetchXHR = $.ajaxWithPrefix({
url: self.config.transcriptTranslationUrl,
notifyOnError: false,
- data: {
- videoId: this.youtubeId(),
- language: this.getCurrentLanguage()
- },
+ data: data,
success: function (captions) {
Caption.captions = captions.text;
Caption.start = captions.start;
@@ -757,8 +753,12 @@ function () {
});
}
- if (this.resizer && !this.isFullScreen) {
- this.resizer.alignByWidthOnly();
+ if (this.resizer) {
+ if (this.isFullScreen) {
+ this.resizer.setMode('both');
+ } else {
+ this.resizer.alignByWidthOnly();
+ }
}
this.videoCaption.setSubtitlesHeight();
@@ -772,17 +772,8 @@ function () {
}
function captionHeight() {
- var paddingTop;
-
if (this.isFullScreen) {
- paddingTop = parseInt(
- this.videoCaption.subtitlesEl.css('padding-top'), 10
- );
-
- return $(window).height() -
- this.videoControl.el.height() -
- 0.5 * this.videoControl.sliderEl.height() -
- 2 * paddingTop;
+ return this.container.height() - this.videoControl.height;
} else {
return this.container.height();
}
diff --git a/common/lib/xmodule/xmodule/js/src/video/10_main.js b/common/lib/xmodule/xmodule/js/src/video/10_main.js
index 774bee8640..2721cd2a43 100644
--- a/common/lib/xmodule/xmodule/js/src/video/10_main.js
+++ b/common/lib/xmodule/xmodule/js/src/video/10_main.js
@@ -42,6 +42,7 @@ require(
[
'video/01_initialize.js',
'video/025_focus_grabber.js',
+ 'video/035_video_accessible_menu.js',
'video/04_video_control.js',
'video/05_video_quality_control.js',
'video/06_video_progress_slider.js',
@@ -52,6 +53,7 @@ require(
function (
Initialize,
FocusGrabber,
+ VideoAccessibleMenu,
VideoControl,
VideoQualityControl,
VideoProgressSlider,
@@ -87,6 +89,7 @@ function (
state.modules = [
FocusGrabber,
+ VideoAccessibleMenu,
VideoControl,
VideoQualityControl,
VideoProgressSlider,
diff --git a/common/lib/xmodule/xmodule/partitions/test_partitions.py b/common/lib/xmodule/xmodule/partitions/test_partitions.py
index 81d04abd53..0ff667d34e 100644
--- a/common/lib/xmodule/xmodule/partitions/test_partitions.py
+++ b/common/lib/xmodule/xmodule/partitions/test_partitions.py
@@ -5,7 +5,7 @@ Test the partitions and partitions service
from collections import defaultdict
from unittest import TestCase
-from mock import Mock, MagicMock
+from mock import Mock
from xmodule.partitions.partitions import Group, UserPartition
from xmodule.partitions.partitions_service import PartitionService
diff --git a/common/lib/xmodule/xmodule/split_test_module.py b/common/lib/xmodule/xmodule/split_test_module.py
index 8d3298006b..fad577198d 100644
--- a/common/lib/xmodule/xmodule/split_test_module.py
+++ b/common/lib/xmodule/xmodule/split_test_module.py
@@ -40,7 +40,7 @@ class SplitTestFields(object):
)
-@XBlock.needs('user_tags')
+@XBlock.needs('user_tags') # pylint: disable=abstract-method
@XBlock.needs('partitions')
class SplitTestModule(SplitTestFields, XModule):
"""
@@ -196,7 +196,7 @@ class SplitTestModule(SplitTestFields, XModule):
return progress
-@XBlock.needs('user_tags')
+@XBlock.needs('user_tags') # pylint: disable=abstract-method
@XBlock.needs('partitions')
class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
# the editing interface can be the same as for sequences -- just a container
@@ -223,4 +223,3 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
makes it use module.get_child_descriptors().
"""
return True
-
diff --git a/common/lib/xmodule/xmodule/tests/test_split_module.py b/common/lib/xmodule/xmodule/tests/test_split_module.py
index 17abb1d489..48a640382e 100644
--- a/common/lib/xmodule/xmodule/tests/test_split_module.py
+++ b/common/lib/xmodule/xmodule/tests/test_split_module.py
@@ -44,9 +44,10 @@ class SplitTestModuleTest(XModuleXmlImportTest):
self.module_system = get_test_system()
def get_module(descriptor):
+ """Mocks module_system get_module function"""
module_system = get_test_system()
module_system.get_module = get_module
- descriptor.bind_for_student(module_system, descriptor._field_data)
+ descriptor.bind_for_student(module_system, descriptor._field_data) # pylint: disable=protected-access
return descriptor
self.module_system.get_module = get_module
@@ -67,8 +68,7 @@ class SplitTestModuleTest(XModuleXmlImportTest):
self.module_system._services['partitions'] = self.partitions_service # pylint: disable=protected-access
self.split_test_module = course_seq.get_children()[0]
- self.split_test_module.bind_for_student(self.module_system, self.split_test_module._field_data)
-
+ self.split_test_module.bind_for_student(self.module_system, self.split_test_module._field_data) # pylint: disable=protected-access
@ddt.data(('0', 'split_test_cond0'), ('1', 'split_test_cond1'))
@ddt.unpack
@@ -83,7 +83,7 @@ class SplitTestModuleTest(XModuleXmlImportTest):
@ddt.data(('0',), ('1',))
@ddt.unpack
- def test_child_old_tag_value(self, user_tag):
+ def test_child_old_tag_value(self, _user_tag):
# If user_tag has a stale value, we should still get back a valid child url
self.tags_service.set_tag(
self.tags_service.COURSE_SCOPE,
@@ -109,13 +109,13 @@ class SplitTestModuleTest(XModuleXmlImportTest):
@ddt.data(('0',), ('1',))
@ddt.unpack
- def test_child_missing_tag_value(self, user_tag):
+ def test_child_missing_tag_value(self, _user_tag):
# If user_tag has a missing value, we should still get back a valid child url
self.assertIn(self.split_test_module.child_descriptor.url_name, ['split_test_cond0', 'split_test_cond1'])
@ddt.data(('100',), ('200',), ('300',), ('400',), ('500',), ('600',), ('700',), ('800',), ('900',), ('1000',))
@ddt.unpack
- def test_child_persist_new_tag_value_when_tag_missing(self, user_tag):
+ def test_child_persist_new_tag_value_when_tag_missing(self, _user_tag):
# If a user_tag has a missing value, a group should be saved/persisted for that user.
# So, we check that we get the same url_name when we call on the url_name twice.
# We run the test ten times so that, if our storage is failing, we'll be most likely to notice it.
diff --git a/common/lib/xmodule/xmodule/tests/xml/factories.py b/common/lib/xmodule/xmodule/tests/xml/factories.py
index e8aafdaf5c..24d81b4699 100644
--- a/common/lib/xmodule/xmodule/tests/xml/factories.py
+++ b/common/lib/xmodule/xmodule/tests/xml/factories.py
@@ -146,6 +146,7 @@ class SequenceFactory(XmlImportFactory):
"""Factory for nodes"""
tag = 'sequential'
+
class VerticalFactory(XmlImportFactory):
"""Factory for nodes"""
tag = 'vertical'
diff --git a/common/lib/xmodule/xmodule/video_module/video_module.py b/common/lib/xmodule/xmodule/video_module/video_module.py
index 467b912ea0..c60dc42dcc 100644
--- a/common/lib/xmodule/xmodule/video_module/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module/video_module.py
@@ -10,10 +10,10 @@ in-browser HTML5 video method (when in HTML5 mode).
in XML.
"""
-import os
import json
import logging
from operator import itemgetter
+from HTMLParser import HTMLParser
from lxml import etree
from pkg_resources import resource_string
@@ -156,6 +156,15 @@ class VideoFields(object):
scope=Scope.preferences,
default="en"
)
+ transcript_download_format = String(
+ help="Transcript file format to download by user.",
+ scope=Scope.preferences,
+ values=[
+ {"display_name": "SubRip (.srt) file", "value": "srt"},
+ {"display_name": "Text (.txt) file", "value": "txt"}
+ ],
+ default='srt',
+ )
speed = Float(
help="The last speed that was explicitly set by user for the video.",
scope=Scope.user_state,
@@ -194,6 +203,7 @@ class VideoModule(VideoFields, XModule):
resource_string(module, 'js/src/video/025_focus_grabber.js'),
resource_string(module, 'js/src/video/02_html5_video.js'),
resource_string(module, 'js/src/video/03_video_player.js'),
+ resource_string(module, 'js/src/video/035_video_accessible_menu.js'),
resource_string(module, 'js/src/video/04_video_control.js'),
resource_string(module, 'js/src/video/05_video_quality_control.js'),
resource_string(module, 'js/src/video/06_video_progress_slider.js'),
@@ -203,20 +213,33 @@ class VideoModule(VideoFields, XModule):
resource_string(module, 'js/src/video/10_main.js')
]
}
- css = {'scss': [resource_string(module, 'css/video/display.scss')]}
+ css = {'scss': [
+ resource_string(module, 'css/video/display.scss'),
+ resource_string(module, 'css/video/accessible_menu.scss'),
+ ]}
js_module_name = "Video"
def handle_ajax(self, dispatch, data):
- accepted_keys = ['speed', 'saved_video_position', 'transcript_language']
- if dispatch == 'save_user_state':
+ accepted_keys = [
+ 'speed', 'saved_video_position', 'transcript_language',
+ 'transcript_download_format',
+ ]
+ conversions = {
+ 'speed': json.loads,
+ 'saved_video_position': lambda v: RelativeTime.isotime_to_timedelta(v),
+ }
+
+ if dispatch == 'save_user_state':
for key in data:
if hasattr(self, key) and key in accepted_keys:
- if key == 'saved_video_position':
- relative_position = RelativeTime.isotime_to_timedelta(data[key])
- self.saved_video_position = relative_position
+ if key in conversions:
+ value = conversions[key](data[key])
else:
- setattr(self, key, json.loads(data[key]))
+ value = data[key]
+
+ setattr(self, key, value)
+
if key == 'speed':
self.global_speed = self.speed
@@ -229,6 +252,7 @@ class VideoModule(VideoFields, XModule):
def get_html(self):
track_url = None
+ transcript_download_format = self.transcript_download_format
get_ext = lambda filename: filename.rpartition('.')[-1]
sources = {get_ext(src): src for src in self.html5_sources}
@@ -242,7 +266,8 @@ class VideoModule(VideoFields, XModule):
if self.download_track:
if self.track:
track_url = self.track
- elif self.sub:
+ transcript_download_format = None
+ elif self.sub or self.transcripts:
track_url = self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/download'
if not self.transcripts:
@@ -290,13 +315,15 @@ class VideoModule(VideoFields, XModule):
# configuration setting field.
'yt_test_timeout': 1500,
'yt_test_url': settings.YOUTUBE_TEST_URL,
+ 'transcript_download_format': transcript_download_format,
+ 'transcript_download_formats_list': self.descriptor.fields['transcript_download_format'].values,
'transcript_language': transcript_language,
'transcript_languages': json.dumps(sorted_languages),
'transcript_translation_url': self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/translation',
'transcript_available_translations_url': self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/available_translations',
})
- def get_transcript(self):
+ def get_transcript(self, format='srt'):
"""
Returns transcript in *.srt format.
@@ -308,12 +335,18 @@ class VideoModule(VideoFields, XModule):
lang = self.transcript_language
subs_id = self.sub if lang == 'en' else self.youtube_id_1_0
data = asset(self.location, subs_id, lang).data
- str_subs = generate_srt_from_sjson(json.loads(data), speed=1.0)
+ if format == 'txt':
+ text = json.loads(data)['text']
+ str_subs = HTMLParser().unescape("\n".join(text))
+ mime_type = 'text/plain'
+ else:
+ str_subs = generate_srt_from_sjson(json.loads(data), speed=1.0)
+ mime_type = 'application/x-subrip'
if not str_subs:
log.debug('generate_srt_from_sjson produces no subtitles')
raise ValueError
- return str_subs
+ return str_subs, format, mime_type
@XBlock.handler
def transcript(self, request, dispatch):
@@ -329,7 +362,7 @@ class VideoModule(VideoFields, XModule):
`available_translations`: returns list of languages, for which SRT files exist. For 'en' check if SJSON exists.
"""
if dispatch == 'translation':
- if 'language' not in request.GET or 'videoId' not in request.GET:
+ if 'language' not in request.GET:
log.info("Invalid /transcript GET parameters.")
return Response(status=400)
@@ -341,7 +374,7 @@ class VideoModule(VideoFields, XModule):
self.transcript_language = lang
try:
- transcript = self.translation(request.GET.get('videoId'))
+ transcript = self.translation(request.GET.get('videoId', None))
except (TranscriptException, NotFoundError) as ex:
log.info(ex.message)
response = Response(status=404)
@@ -351,7 +384,7 @@ class VideoModule(VideoFields, XModule):
elif dispatch == 'download':
try:
- subs = self.get_transcript()
+ subs, format, mime_type = self.get_transcript(format=self.transcript_download_format)
except (NotFoundError, ValueError, KeyError):
log.debug("Video@download exception")
response = Response(status=404)
@@ -359,10 +392,13 @@ class VideoModule(VideoFields, XModule):
response = Response(
subs,
headerlist=[
- ('Content-Disposition', 'attachment; filename="{0}.srt"'.format(self.transcript_language)),
+ ('Content-Disposition', 'attachment; filename="{filename}.{format}"'.format(
+ filename=self.transcript_language,
+ format=format,
+ )),
]
)
- response.content_type = "application/x-subrip"
+ response.content_type = mime_type
elif dispatch == 'available_translations':
available_translations = []
@@ -390,26 +426,30 @@ class VideoModule(VideoFields, XModule):
return response
- def translation(self, subs_id):
+ def translation(self, youtube_id):
"""
This is called to get transcript file for specific language.
- subs_id: str: must be on of: self.sub or one of youtube_ids.
+ youtube_id: str: must be one of youtube_ids or None if HTML video
Logic flow:
- If english -> give back `sub` subtitles:
- Return what we have in contentstore for given subs_id,
- We should not regenerate needed transcripts, if, for example, they present for youtube 1.0 speed,
- and we need for other speeds. Such generation should be done in transcripts workflow.
- If non-english:
- a) extract subs_id from srt file name
- if non-youtube:
- b) try to find sjson by subs_id and return if sucessful
- c) otherwise generate sjson from srt and return it.
- if youtube:
- b) try to find sjson by subs_id and return if sucessful
- c) generate sjson from srt for all youtube speeds
+ If youtube_id doesn't exist, we have a video in HTML5 mode. Otherwise,
+ video video in Youtube or Flash modes.
+
+ if youtube:
+ If english -> give back youtube_id subtitles:
+ Return what we have in contentstore for given youtube_id.
+ If non-english:
+ a) extract youtube_id from srt file name.
+ b) try to find sjson by youtube_id and return if successful.
+ c) generate sjson from srt for all youtube speeds.
+ if non-youtube:
+ If english -> give back `sub` subtitles:
+ Return what we have in contentstore for given subs_if that is stored in self.sub.
+ If non-english:
+ a) try to find previously generated sjson.
+ b) otherwise generate sjson from srt and return it.
Filenames naming:
en: subs_videoid.srt.sjson
@@ -418,28 +458,36 @@ class VideoModule(VideoFields, XModule):
Raises:
NotFoundError if for 'en' subtitles no asset is uploaded.
"""
- if self.transcript_language == 'en':
- return asset(self.location, subs_id).data
- if not self.youtube_id_1_0: # Non-youtube (HTML5) case:
- return get_or_create_sjson(self)
+ if youtube_id:
+ # Youtube case:
+ if self.transcript_language == 'en':
+ return asset(self.location, youtube_id).data
+
+ youtube_ids = youtube_speed_dict(self)
+ assert youtube_id in youtube_ids
+
+ try:
+ sjson_transcript = asset(self.location, youtube_id, self.transcript_language).data
+ except (NotFoundError):
+ log.info("Can't find content in storage for %s transcript: generating.", youtube_id)
+ generate_sjson_for_all_speeds(
+ self,
+ self.transcripts[self.transcript_language],
+ {speed: youtube_id for youtube_id, speed in youtube_ids.iteritems()},
+ self.transcript_language
+ )
+ sjson_transcript = asset(self.location, youtube_id, self.transcript_language).data
+
+ return sjson_transcript
+ else:
+ # HTML5 case
+ if self.transcript_language == 'en':
+ return asset(self.location, self.sub).data
+ else:
+ return get_or_create_sjson(self)
- # Youtube case:
- youtube_ids = youtube_speed_dict(self)
- assert subs_id in youtube_ids
- try:
- sjson_transcript = asset(self.location, subs_id, self.transcript_language).data
- except (NotFoundError):
- log.info("Can't find content in storage for %s transcript: generating.", subs_id)
- generate_sjson_for_all_speeds(
- self,
- self.transcripts[self.transcript_language],
- {speed: subs_id for subs_id, speed in youtube_ids.iteritems()},
- self.transcript_language
- )
- sjson_transcript = asset(self.location, subs_id, self.transcript_language).data
- return sjson_transcript
class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor):
diff --git a/common/test/acceptance/pages/xblock/acid.py b/common/test/acceptance/pages/xblock/acid.py
index a3ae6d4885..b3c692d57b 100644
--- a/common/test/acceptance/pages/xblock/acid.py
+++ b/common/test/acceptance/pages/xblock/acid.py
@@ -34,6 +34,13 @@ class AcidView(PageObject):
selector = '{} .acid-block {} .pass'.format(self.context_selector, test_selector)
return bool(self.q(css=selector).execute(try_interval=0.1, timeout=3))
+ def child_test_passed(self, test_selector):
+ """
+ Return whether a particular :class:`.AcidParentBlock` test passed.
+ """
+ selector = '{} .acid-parent-block {} .pass'.format(self.context_selector, test_selector)
+ return bool(self.q(css=selector).execute(try_interval=0.1, timeout=3))
+
@property
def init_fn_passed(self):
"""
@@ -47,8 +54,8 @@ class AcidView(PageObject):
Whether the tests of children passed
"""
return all([
- self.test_passed('.child-counts-match'),
- self.test_passed('.child-values-match')
+ self.child_test_passed('.child-counts-match'),
+ self.child_test_passed('.child-values-match')
])
@property
diff --git a/common/test/acceptance/tests/test_lms.py b/common/test/acceptance/tests/test_lms.py
index 621d1ac988..dc60ec99e5 100644
--- a/common/test/acceptance/tests/test_lms.py
+++ b/common/test/acceptance/tests/test_lms.py
@@ -359,6 +359,19 @@ class XBlockAcidBase(UniqueCourseTest):
self.course_info_page = CourseInfoPage(self.browser, self.course_id)
self.tab_nav = TabNavPage(self.browser)
+
+ def validate_acid_block_view(self, acid_block):
+ """
+ Verify that the LMS view for the Acid Block is correct
+ """
+ self.assertTrue(acid_block.init_fn_passed)
+ self.assertTrue(acid_block.resource_url_passed)
+ self.assertTrue(acid_block.scope_passed('user_state'))
+ self.assertTrue(acid_block.scope_passed('user_state_summary'))
+ self.assertTrue(acid_block.scope_passed('preferences'))
+ self.assertTrue(acid_block.scope_passed('user_info'))
+
+
def test_acid_block(self):
"""
Verify that all expected acid block tests pass in the lms.
@@ -368,13 +381,7 @@ class XBlockAcidBase(UniqueCourseTest):
self.tab_nav.go_to_tab('Courseware')
acid_block = AcidView(self.browser, '.xblock-student_view[data-block-type=acid]')
- self.assertTrue(acid_block.init_fn_passed)
- self.assertTrue(acid_block.child_tests_passed)
- self.assertTrue(acid_block.resource_url_passed)
- self.assertTrue(acid_block.scope_passed('user_state'))
- self.assertTrue(acid_block.scope_passed('user_state_summary'))
- self.assertTrue(acid_block.scope_passed('preferences'))
- self.assertTrue(acid_block.scope_passed('user_info'))
+ self.validate_acid_block_view(acid_block)
class XBlockAcidNoChildTest(XBlockAcidBase):
@@ -420,7 +427,7 @@ class XBlockAcidChildTest(XBlockAcidBase):
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
- XBlockFixtureDesc('acid', 'Acid Block').add_children(
+ XBlockFixtureDesc('acid_parent', 'Acid Parent Block').add_children(
XBlockFixtureDesc('acid', 'First Acid Child', metadata={'name': 'first'}),
XBlockFixtureDesc('acid', 'Second Acid Child', metadata={'name': 'second'}),
XBlockFixtureDesc('html', 'Html Child', data="Contents"),
@@ -430,6 +437,10 @@ class XBlockAcidChildTest(XBlockAcidBase):
)
).install()
+ def validate_acid_block_view(self, acid_block):
+ super(XBlockAcidChildTest, self).validate_acid_block_view()
+ self.assertTrue(acid_block.child_tests_passed)
+
# This will fail until we fix support of children in pure XBlocks
@expectedFailure
def test_acid_block(self):
diff --git a/common/test/acceptance/tests/test_studio.py b/common/test/acceptance/tests/test_studio.py
index 4bcaaa84c9..0fc9da84fd 100644
--- a/common/test/acceptance/tests/test_studio.py
+++ b/common/test/acceptance/tests/test_studio.py
@@ -147,6 +147,17 @@ class XBlockAcidBase(WebAppTest):
self.auth_page.visit()
+ def validate_acid_block_preview(self, acid_block):
+ """
+ Validate the Acid Block's preview
+ """
+ self.assertTrue(acid_block.init_fn_passed)
+ self.assertTrue(acid_block.resource_url_passed)
+ self.assertTrue(acid_block.scope_passed('user_state'))
+ self.assertTrue(acid_block.scope_passed('user_state_summary'))
+ self.assertTrue(acid_block.scope_passed('preferences'))
+ self.assertTrue(acid_block.scope_passed('user_info'))
+
def test_acid_block_preview(self):
"""
Verify that all expected acid block tests pass in studio preview
@@ -155,22 +166,13 @@ class XBlockAcidBase(WebAppTest):
self.outline.visit()
subsection = self.outline.section('Test Section').subsection('Test Subsection')
unit = subsection.toggle_expand().unit('Test Unit').go_to()
- container = unit.components[0].go_to_container()
- acid_block = AcidView(self.browser, container.xblocks[0].preview_selector)
- self.assertTrue(acid_block.init_fn_passed)
- self.assertTrue(acid_block.child_tests_passed)
- self.assertTrue(acid_block.resource_url_passed)
- self.assertTrue(acid_block.scope_passed('user_state'))
- self.assertTrue(acid_block.scope_passed('user_state_summary'))
- self.assertTrue(acid_block.scope_passed('preferences'))
- self.assertTrue(acid_block.scope_passed('user_info'))
+ acid_block = AcidView(self.browser, unit.components[0].preview_selector)
+ self.validate_acid_block_preview(acid_block)
- # This will fail until we support editing on the container page
- @expectedFailure
def test_acid_block_editor(self):
"""
- Verify that all expected acid block tests pass in studio preview
+ Verify that all expected acid block tests pass in studio editor
"""
self.outline.visit()
@@ -181,7 +183,6 @@ class XBlockAcidBase(WebAppTest):
acid_block = AcidView(self.browser, unit.components[0].edit().editor_selector)
self.assertTrue(acid_block.init_fn_passed)
- self.assertTrue(acid_block.child_tests_passed)
self.assertTrue(acid_block.resource_url_passed)
self.assertTrue(acid_block.scope_passed('content'))
self.assertTrue(acid_block.scope_passed('settings'))
@@ -213,7 +214,36 @@ class XBlockAcidNoChildTest(XBlockAcidBase):
).install()
-class XBlockAcidChildTest(XBlockAcidBase):
+class XBlockAcidParentBase(XBlockAcidBase):
+ """
+ Base class for tests that verify that parent XBlock integration is working correctly
+ """
+ __test__ = False
+
+ def validate_acid_block_preview(self, acid_block):
+ super(XBlockAcidParentBase, self).validate_acid_block_preview(acid_block)
+ self.assertTrue(acid_block.child_tests_passed)
+
+ def test_acid_block_preview(self):
+ """
+ Verify that all expected acid block tests pass in studio preview
+ """
+
+ self.outline.visit()
+ subsection = self.outline.section('Test Section').subsection('Test Subsection')
+ unit = subsection.toggle_expand().unit('Test Unit').go_to()
+ container = unit.components[0].go_to_container()
+
+ acid_block = AcidView(self.browser, container.xblocks[0].preview_selector)
+ self.validate_acid_block_preview(acid_block)
+
+ # This will fail until the container page supports editing
+ @expectedFailure
+ def test_acid_block_editor(self):
+ super(XBlockAcidParentBase, self).test_acid_block_editor()
+
+
+class XBlockAcidEmptyParentTest(XBlockAcidParentBase):
"""
Tests of an AcidBlock with children
"""
@@ -232,7 +262,34 @@ class XBlockAcidChildTest(XBlockAcidBase):
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
- XBlockFixtureDesc('acid', 'Acid Block').add_children(
+ XBlockFixtureDesc('acid_parent', 'Acid Parent Block').add_children(
+ )
+ )
+ )
+ )
+ ).install()
+
+
+class XBlockAcidChildTest(XBlockAcidParentBase):
+ """
+ Tests of an AcidBlock with children
+ """
+ __test__ = True
+
+ def setup_fixtures(self):
+
+ course_fix = CourseFixture(
+ self.course_info['org'],
+ self.course_info['number'],
+ self.course_info['run'],
+ self.course_info['display_name']
+ )
+
+ course_fix.add_children(
+ XBlockFixtureDesc('chapter', 'Test Section').add_children(
+ XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
+ XBlockFixtureDesc('vertical', 'Test Unit').add_children(
+ XBlockFixtureDesc('acid_parent', 'Acid Parent Block').add_children(
XBlockFixtureDesc('acid', 'First Acid Child', metadata={'name': 'first'}),
XBlockFixtureDesc('acid', 'Second Acid Child', metadata={'name': 'second'}),
XBlockFixtureDesc('html', 'Html Child', data="Contents"),
diff --git a/common/test/data/uploads/subs_OEoXaMPEzfM.srt.sjson b/common/test/data/uploads/subs_OEoXaMPEzfM.srt.sjson
index c07fa0636f..3c62b7f76e 100644
--- a/common/test/data/uploads/subs_OEoXaMPEzfM.srt.sjson
+++ b/common/test/data/uploads/subs_OEoXaMPEzfM.srt.sjson
@@ -94,7 +94,7 @@
114220
],
"text": [
- "LILA FISHER: Hi, welcome to Edx.",
+ "Hi, welcome to Edx.",
"I'm Lila Fisher, an Edx fellow helping to put",
"together these courses.",
"As you know, our courses are entirely online.",
diff --git a/docs/en_us/course_authors/source/discussions.rst b/docs/en_us/course_authors/source/discussions.rst
index 107edccfa5..77dc4252a7 100644
--- a/docs/en_us/course_authors/source/discussions.rst
+++ b/docs/en_us/course_authors/source/discussions.rst
@@ -100,11 +100,11 @@ Assign discussion administration roles
You can designate a team of people to help you run course discussions. Different options for working with discussion posts are available to discussion administrators with these roles:
-* Forum moderators can edit and delete posts, review posts flagged for misuse, close and reopen threads, pin posts and endorse responses, and, if the course is cohorted, see posts from all cohorts. Responses and comments made by moderators are marked as "Staff".
+* Discussion moderators can edit and delete posts, review posts flagged for misuse, close and reopen threads, pin posts and endorse responses, and, if the course is cohorted, see posts from all cohorts. Responses and comments made by moderators are marked as "Staff".
-* Forum community TAs have the same options for working with discussions as moderators. Responses and comments made by community TAs are marked as "Community TA".
+* Discussion community TAs have the same options for working with discussions as moderators. Responses and comments made by community TAs are marked as "Community TA".
-* Forum admins have the same options for working with discussions as moderators. Admins can also assign these discussion management roles to more people while your course is running, or remove a role from a user whenever necessary. Responses and comments made by admins are marked as "Staff".
+* Discussion admins have the same options for working with discussions as moderators. Admins can also assign these discussion management roles to more people while your course is running, or remove a role from a user whenever necessary. Responses and comments made by admins are marked as "Staff".
**Note**: Discussion responses and comments made by course staff and instructors are also marked as "Staff".
@@ -123,7 +123,7 @@ To assign a role:
#. Click **Membership**.
-#. In the Administration List Management section, use the drop-down list to select Forum Admins, Forum Moderators, or Forum Community TAs.
+#. In the Administration List Management section, use the drop-down list to select Discussion Admins, Discussion Moderators, or Discussion Community TAs.
#. Under the list of users who currently have that role, enter an email address and click **Add** for the role type.
diff --git a/lms/djangoapps/courseware/features/video.feature b/lms/djangoapps/courseware/features/video.feature
index 6ea5c5c9b2..721e4630a7 100644
--- a/lms/djangoapps/courseware/features/video.feature
+++ b/lms/djangoapps/courseware/features/video.feature
@@ -2,7 +2,7 @@
Feature: LMS Video component
As a student, I want to view course videos in LMS
- # 0
+ # 1
Scenario: Video component stores position correctly when page is reloaded
Given the course has a Video component in Youtube mode
Then when I view the video it has rendered in Youtube mode
@@ -13,51 +13,51 @@ Feature: LMS Video component
And I click video button "play"
Then I see video starts playing from "0:10" position
- # 1
+ # 2
Scenario: Video component is fully rendered in the LMS in HTML5 mode
Given the course has a Video component in HTML5 mode
Then when I view the video it has rendered in HTML5 mode
And all sources are correct
- # 2
+ # 3
# Firefox doesn't have HTML5 (only mp4 - fix here)
@skip_firefox
Scenario: Autoplay is disabled in LMS for a Video component
Given the course has a Video component in HTML5 mode
Then when I view the video it does not have autoplay enabled
- # 3
+ # 4
# Youtube testing
Scenario: Video component is fully rendered in the LMS in Youtube mode with HTML5 sources
Given youtube server is up and response time is 0.4 seconds
And the course has a Video component in Youtube_HTML5 mode
Then when I view the video it has rendered in Youtube mode
- # 4
+ # 5
Scenario: Video component is not rendered in the LMS in Youtube mode with HTML5 sources
Given youtube server is up and response time is 2 seconds
And the course has a Video component in Youtube_HTML5 mode
Then when I view the video it has rendered in HTML5 mode
- # 5
+ # 6
Scenario: Video component is rendered in the LMS in Youtube mode without HTML5 sources
Given youtube server is up and response time is 2 seconds
And the course has a Video component in Youtube mode
Then when I view the video it has rendered in Youtube mode
- # 6
+ # 7
Scenario: Video component is rendered in the LMS in Youtube mode with HTML5 sources that doesn't supported by browser
Given youtube server is up and response time is 2 seconds
And the course has a Video component in Youtube_HTML5_Unsupported_Video mode
Then when I view the video it has rendered in Youtube mode
- # 7
+ # 8
Scenario: Video component is rendered in the LMS in HTML5 mode with HTML5 sources that doesn't supported by browser
Given the course has a Video component in HTML5_Unsupported_Video mode
Then error message is shown
And error message has correct text
- # 8
+ # 9
Scenario: Video component stores speed correctly when each video is in separate sequence
Given I am registered for the course "test_course"
And it has a video "A" in "Youtube" mode in position "1" of sequential
@@ -79,14 +79,100 @@ Feature: LMS Video component
When I open video "C"
Then video "C" should start playing at speed "1.0"
- # 9
- Scenario: Language menu in Video component works correctly
+ # 10
+ Scenario: Language menu works correctly in Video component
Given the course has a Video component in Youtube mode:
| transcripts | sub |
- | {"zh": "OEoXaMPEzfM"} | OEoXaMPEzfM |
+ | {"zh": "chinese_transcripts.srt"} | OEoXaMPEzfM |
And I make sure captions are closed
And I see video menu "language" with correct items
And I select language with code "zh"
Then I see "好 各位同学" text in the captions
And I select language with code "en"
And I see "Hi, welcome to Edx." text in the captions
+
+ # 11
+ Scenario: CC button works correctly w/o english transcript in HTML5 mode of Video component
+ Given the course has a Video component in HTML5 mode:
+ | transcripts |
+ | {"zh": "chinese_transcripts.srt"} |
+ And I make sure captions are opened
+ Then I see "好 各位同学" text in the captions
+
+ # 12
+ Scenario: CC button works correctly only w/ english transcript in HTML5 mode of Video component
+ Given I am registered for the course "test_course"
+ And I have a "subs_OEoXaMPEzfM.srt.sjson" transcript file in assets
+ And it has a video in "HTML5" mode:
+ | sub |
+ | OEoXaMPEzfM |
+ And I make sure captions are opened
+ Then I see "Hi, welcome to Edx." text in the captions
+
+ # 13
+ Scenario: CC button works correctly w/o english transcript in Youtube mode of Video component
+ Given the course has a Video component in Youtube mode:
+ | transcripts |
+ | {"zh": "chinese_transcripts.srt"} |
+ And I make sure captions are opened
+ Then I see "好 各位同学" text in the captions
+
+ # 14
+ Scenario: CC button works correctly if transcripts and sub fields are empty, but transcript file exists is assets (Youtube mode of Video component)
+ Given I am registered for the course "test_course"
+ And I have a "subs_OEoXaMPEzfM.srt.sjson" transcript file in assets
+ And it has a video in "Youtube" mode
+ And I make sure captions are opened
+ Then I see "Hi, welcome to Edx." text in the captions
+
+ # 15
+ Scenario: CC button is hidden if no translations
+ Given the course has a Video component in Youtube mode
+ Then button "CC" is hidden
+
+ # 16
+ Scenario: Video is aligned correctly if transcript is visible in fullscreen mode
+ Given the course has a Video component in HTML5 mode:
+ | sub |
+ | OEoXaMPEzfM |
+ And I make sure captions are opened
+ And I click video button "fullscreen"
+ Then I see video aligned correctly with enabled transcript
+
+ # 17
+ Scenario: Video is aligned correctly if transcript is hidden in fullscreen mode
+ Given the course has a Video component in Youtube mode
+ And I click video button "fullscreen"
+ Then I see video aligned correctly without enabled transcript
+
+ # 18
+ Scenario: Video is aligned correctly on transcript toggle in fullscreen mode
+ Given the course has a Video component in Youtube mode:
+ | sub |
+ | OEoXaMPEzfM |
+ And I make sure captions are opened
+ And I click video button "fullscreen"
+ Then I see video aligned correctly with enabled transcript
+ And I click video button "CC"
+ Then I see video aligned correctly without enabled transcript
+
+ # 19
+ Scenario: Download Transcript button works correctly in Video component
+ Given I am registered for the course "test_course"
+ And it has a video "A" in "Youtube" mode in position "1" of sequential:
+ | sub | download_track |
+ | OEoXaMPEzfM | true |
+ And a video "B" in "Youtube" mode in position "2" of sequential:
+ | sub | download_track |
+ | OEoXaMPEzfM | true |
+ And a video "C" in "Youtube" mode in position "3" of sequential:
+ | track | download_track |
+ | http://example.org/ | true |
+ And I open the section with videos
+ And I can download transcript in "srt" format
+ And I select the transcript format "txt"
+ And I can download transcript in "txt" format
+ When I open video "B"
+ Then I can download transcript in "txt" format
+ When I open video "C"
+ Then menu "download_transcript" doesn't exist
diff --git a/lms/djangoapps/courseware/features/video.py b/lms/djangoapps/courseware/features/video.py
index 986d811684..2cd36b5641 100644
--- a/lms/djangoapps/courseware/features/video.py
+++ b/lms/djangoapps/courseware/features/video.py
@@ -3,13 +3,13 @@
from lettuce import world, step
import json
+import os
+import requests
from common import i_am_registered_for_the_course, section_location, visit_scenario_item
from django.utils.translation import ugettext as _
from django.conf import settings
from cache_toolbox.core import del_cached_content
from xmodule.contentstore.content import StaticContent
-import os
-from functools import partial
from xmodule.contentstore.django import contentstore
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
LANGUAGES = settings.ALL_LANGUAGES
@@ -22,72 +22,67 @@ HTML5_SOURCES = [
'https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.webm',
'https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.ogv',
]
+
HTML5_SOURCES_INCORRECT = [
'https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.mp99',
]
-VIDEO_BUTTONS = {
- 'CC': '.hide-subtitles',
- 'volume': '.volume',
- 'play': '.video_control.play',
- 'pause': '.video_control.pause',
-}
-VIDEO_MENUS = {
- 'language': '.lang .menu',
- 'speed': '.speed .menu',
-}
VIDEO_BUTTONS = {
'CC': '.hide-subtitles',
'volume': '.volume',
'play': '.video_control.play',
'pause': '.video_control.pause',
+ 'fullscreen': '.add-fullscreen',
+ 'download_transcript': '.video-tracks > a',
+}
+
+VIDEO_MENUS = {
+ 'language': '.lang .menu',
+ 'speed': '.speed .menu',
+ 'download_transcript': '.video-tracks .a11y-menu-list',
}
coursenum = 'test_course'
sequence = {}
-@step('when I view the (.*) it does not have autoplay enabled$')
-def does_not_autoplay(_step, video_type):
- assert(world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False')
+class ReuqestHandlerWithSessionId(object):
+ def get(self, url):
+ """
+ Sends a request.
+ """
+ kwargs = dict()
-@step('the course has a Video component in (.*) mode(?:\:)?$')
-def view_video(_step, player_mode):
+ session_id = [{i['name']:i['value']} for i in world.browser.cookies.all() if i['name']==u'sessionid']
+ if session_id:
+ kwargs.update({
+ 'cookies': session_id[0]
+ })
- i_am_registered_for_the_course(_step, coursenum)
+ response = requests.get(url, **kwargs)
+ self.response = response
+ self.status_code = response.status_code
+ self.headers = response.headers
+ self.content = response.content
- # Make sure we have a video
- add_video_to_course(coursenum, player_mode.lower(), _step.hashes)
- visit_scenario_item('SECTION')
+ return self
+ def is_success(self):
+ """
+ Returns `True` if the response was succeed, otherwise, returns `False`.
+ """
+ if self.status_code < 400:
+ return True
+ return False
-@step('a video "([^"]*)" in "([^"]*)" mode in position "([^"]*)" of sequential(?:\:)?$')
-def add_video(_step, player_id, player_mode, position):
- sequence[player_id] = position
- add_video_to_course(coursenum, player_mode.lower(), _step.hashes, display_name=player_id)
-
-
-@step('I open the section with videos$')
-def visit_video_section(_step):
- visit_scenario_item('SECTION')
-
-
-@step('I select the "([^"]*)" speed on video "([^"]*)"$')
-def change_video_speed(_step, speed, player_id):
- _navigate_to_an_item_in_a_sequence(sequence[player_id])
- _change_video_speed(speed)
-
-
-@step('I open video "([^"]*)"$')
-def open_video(_step, player_id):
- _navigate_to_an_item_in_a_sequence(sequence[player_id])
-
-
-@step('video "([^"]*)" should start playing at speed "([^"]*)"$')
-def check_video_speed(_step, player_id, speed):
- speed_css = '.speeds p.active'
- assert world.css_has_text(speed_css, '{0}x'.format(speed))
-
+ def check_header(self, name, value):
+ """
+ Returns `True` if the response header exist and has appropriate value,
+ otherwise, returns `False`.
+ """
+ if value in self.headers.get(name, ''):
+ return True
+ return False
def add_video_to_course(course, player_mode, hashes, display_name='Video'):
category = 'video'
@@ -126,19 +121,139 @@ def add_video_to_course(course, player_mode, hashes, display_name='Video'):
if hashes:
kwargs['metadata'].update(hashes[0])
+ course_location =world.scenario_dict['COURSE'].location
+
+ conversions = {
+ 'transcripts': json.loads,
+ 'download_track': json.loads,
+ 'download_video': json.loads,
+ }
+
+ for key in kwargs['metadata']:
+ if key in conversions:
+ kwargs['metadata'][key] = conversions[key](kwargs['metadata'][key])
+
+ if 'sub' in kwargs['metadata']:
+ filename = _get_sjson_filename(kwargs['metadata']['sub'], 'en')
+ _upload_file(filename, course_location)
if 'transcripts' in kwargs['metadata']:
- kwargs['metadata']['transcripts'] = json.loads(kwargs['metadata']['transcripts'])
-
- if 'sub' in kwargs['metadata']:
- _upload_file(kwargs['metadata']['sub'], 'en', world.scenario_dict['COURSE'].location)
-
- for lang, videoId in kwargs['metadata']['transcripts'].items():
- _upload_file(videoId, lang, world.scenario_dict['COURSE'].location)
+ for lang, filename in kwargs['metadata']['transcripts'].items():
+ _upload_file(filename, course_location)
world.scenario_dict['VIDEO'] = world.ItemFactory.create(**kwargs)
+def _get_sjson_filename(videoId, lang):
+ if lang == 'en':
+ return 'subs_{0}.srt.sjson'.format(videoId)
+ else:
+ return '{0}_subs_{1}.srt.sjson'.format(lang, videoId)
+
+
+def _upload_file(filename, location):
+ path = os.path.join(TEST_ROOT, 'uploads/', filename)
+ f = open(os.path.abspath(path))
+ mime_type = "application/json"
+
+ content_location = StaticContent.compute_location(
+ location.org, location.course, filename
+ )
+ content = StaticContent(content_location, filename, mime_type, f.read())
+ contentstore().save(content)
+ del_cached_content(content.location)
+
+
+def _navigate_to_an_item_in_a_sequence(number):
+ sequence_css = '#sequence-list a[data-element="{0}"]'.format(number)
+ world.css_click(sequence_css)
+
+
+def _change_video_speed(speed):
+ world.browser.execute_script("$('.speeds').addClass('open')")
+ speed_css = 'li[data-speed="{0}"] a'.format(speed)
+ world.css_click(speed_css)
+
+
+def _open_menu(menu):
+ world.browser.execute_script("$('{selector}').parent().addClass('open')".format(
+ selector=VIDEO_MENUS[menu]
+ ))
+
+
+def _get_all_dimensions():
+ video = _get_dimensions('.video-player iframe, .video-player video')
+ wrapper = _get_dimensions('.tc-wrapper')
+ controls = _get_dimensions('.video-controls')
+ progress_slider = _get_dimensions('.video-controls > .slider')
+
+ expected = dict(wrapper)
+ expected['height'] -= controls['height'] + 0.5 * progress_slider['height']
+
+ return (video, expected)
+
+
+def _get_dimensions(selector):
+ element = world.css_find(selector).first
+ return element._element.size
+
+
+def _get_window_dimensions():
+ return world.browser.driver.get_window_size()
+
+
+def _set_window_dimensions(width, height):
+ world.browser.driver.set_window_size(width, height)
+ # Wait 200 ms when JS finish resizing
+ world.wait(0.2)
+
+
+@step('when I view the (.*) it does not have autoplay enabled$')
+def does_not_autoplay(_step, video_type):
+ assert(world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False')
+
+
+@step('the course has a Video component in (.*) mode(?:\:)?$')
+def view_video(_step, player_mode):
+ i_am_registered_for_the_course(_step, coursenum)
+ add_video_to_course(coursenum, player_mode.lower(), _step.hashes)
+ visit_scenario_item('SECTION')
+
+
+@step('a video in "([^"]*)" mode(?:\:)?$')
+def add_video(_step, player_mode):
+ add_video_to_course(coursenum, player_mode.lower(), _step.hashes)
+ visit_scenario_item('SECTION')
+
+
+@step('a video "([^"]*)" in "([^"]*)" mode in position "([^"]*)" of sequential(?:\:)?$')
+def add_video_in_position(_step, player_id, player_mode, position):
+ sequence[player_id] = position
+ add_video_to_course(coursenum, player_mode.lower(), _step.hashes, display_name=player_id)
+
+
+@step('I open the section with videos$')
+def visit_video_section(_step):
+ visit_scenario_item('SECTION')
+
+
+@step('I select the "([^"]*)" speed on video "([^"]*)"$')
+def change_video_speed(_step, speed, player_id):
+ _navigate_to_an_item_in_a_sequence(sequence[player_id])
+ _change_video_speed(speed)
+
+
+@step('I open video "([^"]*)"$')
+def open_video(_step, player_id):
+ _navigate_to_an_item_in_a_sequence(sequence[player_id])
+
+
+@step('video "([^"]*)" should start playing at speed "([^"]*)"$')
+def check_video_speed(_step, player_id, speed):
+ speed_css = '.speeds p.active'
+ assert world.css_has_text(speed_css, '{0}x'.format(speed))
+
+
@step('youtube server is up and response time is (.*) seconds$')
def set_youtube_response_timeout(_step, time):
world.youtube.config['time_to_response'] = float(time)
@@ -157,7 +272,7 @@ def video_is_rendered(_step, mode):
@step('all sources are correct$')
def all_sources_are_correct(_step):
- elements = world.css_find('.video video source')
+ elements = world.css_find('.video-player video source')
sources = [source['src'].split('?')[0] for source in elements]
assert set(sources) == set(HTML5_SOURCES)
@@ -227,56 +342,8 @@ def select_language(_step, code):
world.wait_for_ajax_complete()
-@step('I click on video button "([^"]*)"$')
-def click_button(_step, button):
- world.css_find(VIDEO_BUTTONS[button]).click()
-
-
-def _upload_file(videoId, lang, location):
- if lang == 'en':
- filename = 'subs_{0}.srt.sjson'.format(videoId)
- else:
- filename = '{0}_subs_{1}.srt.sjson'.format(lang, videoId)
-
- path = os.path.join(TEST_ROOT, 'uploads/', filename)
- f = open(os.path.abspath(path))
- mime_type = "application/json"
-
- content_location = StaticContent.compute_location(
- location.org, location.course, filename
- )
-
- sc_partial = partial(StaticContent, content_location, filename, mime_type)
- content = sc_partial(f.read())
-
- (thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(
- content,
- tempfile_path=None
- )
- del_cached_content(thumbnail_location)
-
- if thumbnail_content is not None:
- content.thumbnail_location = thumbnail_location
-
- contentstore().save(content)
- del_cached_content(content.location)
-
-
-def _navigate_to_an_item_in_a_sequence(number):
- sequence_css = 'a[data-element="{0}"]'.format(number)
- world.css_click(sequence_css)
-
-
-def _change_video_speed(speed):
- world.browser.execute_script("$('.speeds').addClass('open')")
- speed_css = 'li[data-speed="{0}"] a'.format(speed)
- world.css_click(speed_css)
-
-
@step('I click video button "([^"]*)"$')
-def click_button_video(_step, button_type):
- world.wait_for_ajax_complete()
- button = button_type.strip()
+def click_button(_step, button):
world.css_click(VIDEO_BUTTONS[button])
@@ -295,7 +362,81 @@ def seek_video_to_n_seconds(_step, seconds):
world.browser.execute_script(jsCode)
-def _open_menu(menu):
- world.browser.execute_script("$('{selector}').parent().addClass('open')".format(
- selector=VIDEO_MENUS[menu]
- ))
+@step('I have a "([^"]*)" transcript file in assets$')
+def upload_to_assets(_step, filename):
+ _upload_file(filename, world.scenario_dict['COURSE'].location)
+
+
+@step('button "([^"]*)" is hidden$')
+def is_hidden_button(_step, button):
+ assert not world.css_visible(VIDEO_BUTTONS[button])
+
+@step('menu "([^"]*)" doesn\'t exist$')
+def is_hidden_menu(_step, menu):
+ assert world.is_css_not_present(VIDEO_MENUS[menu])
+
+
+@step('I see video aligned correctly (with(?:out)?) enabled transcript$')
+def video_alignment(_step, transcript_visibility):
+ # Width of the video container in css equal 75% of window if transcript enabled
+ wrapper_width = 75 if transcript_visibility == "with" else 100
+ initial = _get_window_dimensions()
+
+ _set_window_dimensions(300, 600)
+ real, expected = _get_all_dimensions()
+
+ width = round(100 * real['width']/expected['width']) == wrapper_width
+
+ _set_window_dimensions(600, 300)
+ real, expected = _get_all_dimensions()
+
+ height = abs(expected['height'] - real['height']) <= 5
+
+ # Restore initial window size
+ _set_window_dimensions(
+ initial['width'], initial['height']
+ )
+
+ assert all([width, height])
+
+
+@step('I can download transcript in "([^"]*)" format$')
+def i_can_download_transcript(_step, format):
+ button = world.css_find('.video-tracks .a11y-menu-button').first
+ assert button.text.strip() == '.' + format
+
+ formats = {
+ 'srt': {
+ 'content': '0\n00:00:00,270',
+ 'mime_type': 'application/x-subrip'
+ },
+ 'txt': {
+ 'content': 'Hi, welcome to Edx.',
+ 'mime_type': 'text/plain'
+ },
+ }
+
+ url = world.css_find(VIDEO_BUTTONS['download_transcript'])[0]['href']
+ request = ReuqestHandlerWithSessionId()
+ assert request.get(url).is_success()
+ assert request.check_header('content-type', formats[format]['mime_type'])
+ assert request.content.startswith(formats[format]['content'])
+
+
+@step('I select the transcript format "([^"]*)"$')
+def select_transcript_format(_step, format):
+ button = world.css_find('.video-tracks .a11y-menu-button').first
+ button.mouse_over()
+ assert button.text.strip() == '...'
+
+ menu_selector = VIDEO_MENUS['download_transcript']
+ menu_items = world.css_find(menu_selector + ' a')
+
+ for item in menu_items:
+ if item['data-value'] == format:
+ item.click()
+ world.wait_for_ajax_complete()
+ break
+
+ assert world.css_find(menu_selector + ' .active a')[0]['data-value'] == format
+ assert button.text.strip() == '.' + format
diff --git a/lms/djangoapps/courseware/tests/test_split_module.py b/lms/djangoapps/courseware/tests/test_split_module.py
index 75d1b0be10..958712476e 100644
--- a/lms/djangoapps/courseware/tests/test_split_module.py
+++ b/lms/djangoapps/courseware/tests/test_split_module.py
@@ -1,25 +1,30 @@
"""
Test for split test XModule
"""
-import ddt
-from mock import MagicMock, patch, Mock
from django.core.urlresolvers import reverse
from django.test.utils import override_settings
-from student.tests.factories import UserFactory, CourseEnrollmentFactory, AdminFactory
+from student.tests.factories import UserFactory, CourseEnrollmentFactory
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.partitions.partitions import Group, UserPartition
-from xmodule.partitions.test_partitions import StaticPartitionService
from user_api.tests.factories import UserCourseTagFactory
-from xmodule.partitions.partitions import Group, UserPartition
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class SplitTestBase(ModuleStoreTestCase):
+ """
+ Sets up a basic course and user for split test testing.
+ Also provides tests of rendered HTML for two user_tag conditions, 0 and 1.
+ """
__test__ = False
+ COURSE_NUMBER = 'split-test-base'
+ ICON_CLASSES = None
+ TOOLTIPS = None
+ HIDDEN_CONTENT = None
+ VISIBLE_CONTENT = None
def setUp(self):
self.partition = UserPartition(
@@ -53,6 +58,10 @@ class SplitTestBase(ModuleStoreTestCase):
self.client.login(username=self.student.username, password='test')
def _video(self, parent, group):
+ """
+ Returns a video component with parent ``parent``
+ that is intended to be displayed to group ``group``.
+ """
return ItemFactory.create(
parent_location=parent.location,
category="video",
@@ -60,6 +69,10 @@ class SplitTestBase(ModuleStoreTestCase):
)
def _problem(self, parent, group):
+ """
+ Returns a problem component with parent ``parent``
+ that is intended to be displayed to group ``group``.
+ """
return ItemFactory.create(
parent_location=parent.location,
category="problem",
@@ -68,6 +81,10 @@ class SplitTestBase(ModuleStoreTestCase):
)
def _html(self, parent, group):
+ """
+ Returns an html component with parent ``parent``
+ that is intended to be displayed to group ``group``.
+ """
return ItemFactory.create(
parent_location=parent.location,
category="html",
@@ -82,21 +99,23 @@ class SplitTestBase(ModuleStoreTestCase):
self._check_split_test(1)
def _check_split_test(self, user_tag):
- tag_factory = UserCourseTagFactory(
+ """Checks that the right compentents are rendered for user with ``user_tag``"""
+ # This explicitly sets the user_tag for self.student to ``user_tag``
+ UserCourseTagFactory(
user=self.student,
course_id=self.course.id,
key='xblock.partition_service.partition_{0}'.format(self.partition.id),
value=str(user_tag)
)
- resp = self.client.get(reverse('courseware_section',
- kwargs={'course_id': self.course.id,
- 'chapter': self.chapter.url_name,
- 'section': self.sequential.url_name}
+ resp = self.client.get(reverse(
+ 'courseware_section',
+ kwargs={'course_id': self.course.id,
+ 'chapter': self.chapter.url_name,
+ 'section': self.sequential.url_name}
))
content = resp.content
- print content
# Assert we see the proper icon in the top display
self.assertIn(' 00:00:02,720
@@ -328,6 +352,33 @@ class TestVideoTranscriptsDownload(TestVideo):
self.assertEqual(text, expected_text)
+ def test_good_txt_transcript(self):
+ good_sjson = _create_file(content=textwrap.dedent("""\
+ {
+ "start": [
+ 270,
+ 2720
+ ],
+ "end": [
+ 2720,
+ 5430
+ ],
+ "text": [
+ "Hi, welcome to Edx.",
+ "Let's start with what is on your screen right now."
+ ]
+ }
+ """))
+
+ _upload_sjson_file(good_sjson, self.item.location)
+ self.item.sub = _get_subs_id(good_sjson.name)
+ text, format, mime_type = self.item.get_transcript(format="txt")
+ expected_text = textwrap.dedent("""\
+ Hi, welcome to Edx.
+ Let's start with what is on your screen right now.""")
+
+ self.assertEqual(text, expected_text)
+
def test_not_found_error(self):
with self.assertRaises(NotFoundError):
self.item.get_transcript()
diff --git a/lms/djangoapps/courseware/tests/test_video_mongo.py b/lms/djangoapps/courseware/tests/test_video_mongo.py
index 5b74762b4a..38e69afa35 100644
--- a/lms/djangoapps/courseware/tests/test_video_mongo.py
+++ b/lms/djangoapps/courseware/tests/test_video_mongo.py
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
"""Video xmodule tests in mongo."""
from mock import patch, PropertyMock
-import json
from . import BaseTestXmodule
from .test_video_xml import SOURCE_XML
@@ -41,6 +40,8 @@ class TestVideoYouTube(TestVideo):
'youtube_streams': create_youtube_string(self.item_descriptor),
'yt_test_timeout': 1500,
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/',
+ 'transcript_download_format': 'srt',
+ 'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
'transcript_language': 'en',
'transcript_languages': '{"en": "English", "uk": "Ukrainian"}',
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
@@ -103,6 +104,8 @@ class TestVideoNonYouTube(TestVideo):
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'yt_test_timeout': 1500,
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/',
+ 'transcript_download_format': 'srt',
+ 'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
'transcript_language': 'en',
'transcript_languages': '{"en": "English"}',
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
@@ -191,6 +194,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'yt_test_timeout': 1500,
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/',
+ 'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
}
for data in cases:
@@ -208,6 +212,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
context = self.item_descriptor.render('student_view').content
expected_context.update({
+ 'transcript_download_format': None if self.item_descriptor.track and self.item_descriptor.download_track else 'srt',
'transcript_languages': '{"en": "English"}',
'transcript_language': 'en',
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
@@ -305,6 +310,8 @@ class TestGetHtmlMethod(BaseTestXmodule):
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'yt_test_timeout': 1500,
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/',
+ 'transcript_download_format': 'srt',
+ 'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
'transcript_language': 'en',
'transcript_languages': '{"en": "English"}',
}
diff --git a/lms/djangoapps/dashboard/git_import.py b/lms/djangoapps/dashboard/git_import.py
index 812dc34796..a3391665fd 100644
--- a/lms/djangoapps/dashboard/git_import.py
+++ b/lms/djangoapps/dashboard/git_import.py
@@ -39,7 +39,15 @@ class GitImportError(Exception):
CANNOT_PULL = _('git clone or pull failed!')
XML_IMPORT_FAILED = _('Unable to run import command.')
UNSUPPORTED_STORE = _('The underlying module store does not support import.')
-
+ # Translators: This is an error message when they ask for a
+ # particular version of a git repository and that version isn't
+ # available from the remote source they specified
+ REMOTE_BRANCH_MISSING = _('The specified remote branch is not available.')
+ # Translators: Error message shown when they have asked for a git
+ # repository branch, a specific version within a repository, that
+ # doesn't exist, or there is a problem changing to it.
+ CANNOT_BRANCH = _('Unable to switch to specified branch. Please check '
+ 'your branch name.')
def cmd_log(cmd, cwd):
"""
@@ -54,8 +62,65 @@ def cmd_log(cmd, cwd):
return output
-def add_repo(repo, rdir_in):
- """This will add a git repo into the mongo modulestore"""
+def switch_branch(branch, rdir):
+ """
+ This will determine how to change the branch of the repo, and then
+ use the appropriate git commands to do so.
+
+ Raises an appropriate GitImportError exception if there is any issues with changing
+ branches.
+ """
+ # Get the latest remote
+ try:
+ cmd_log(['git', 'fetch', ], rdir)
+ except subprocess.CalledProcessError as ex:
+ log.exception('Unable to fetch remote: %r', ex.output)
+ raise GitImportError(GitImportError.CANNOT_BRANCH)
+
+ # Check if the branch is available from the remote.
+ cmd = ['git', 'ls-remote', 'origin', '-h', 'refs/heads/{0}'.format(branch), ]
+ try:
+ output = cmd_log(cmd, rdir)
+ except subprocess.CalledProcessError as ex:
+ log.exception('Getting a list of remote branches failed: %r', ex.output)
+ raise GitImportError(GitImportError.CANNOT_BRANCH)
+ if not branch in output:
+ raise GitImportError(GitImportError.REMOTE_BRANCH_MISSING)
+ # Check it the remote branch has already been made locally
+ cmd = ['git', 'branch', '-a', ]
+ try:
+ output = cmd_log(cmd, rdir)
+ except subprocess.CalledProcessError as ex:
+ log.exception('Getting a list of local branches failed: %r', ex.output)
+ raise GitImportError(GitImportError.CANNOT_BRANCH)
+ branches = []
+ for line in output.split('\n'):
+ branches.append(line.replace('*', '').strip())
+
+ if branch not in branches:
+ # Checkout with -b since it is remote only
+ cmd = ['git', 'checkout', '--force', '--track',
+ '-b', branch, 'origin/{0}'.format(branch), ]
+ try:
+ cmd_log(cmd, rdir)
+ except subprocess.CalledProcessError as ex:
+ log.exception('Unable to checkout remote branch: %r', ex.output)
+ raise GitImportError(GitImportError.CANNOT_BRANCH)
+ # Go ahead and reset hard to the newest version of the branch now that we know
+ # it is local.
+ try:
+ cmd_log(['git', 'reset', '--hard', 'origin/{0}'.format(branch), ], rdir)
+ except subprocess.CalledProcessError as ex:
+ log.exception('Unable to reset to branch: %r', ex.output)
+ raise GitImportError(GitImportError.CANNOT_BRANCH)
+
+
+def add_repo(repo, rdir_in, branch=None):
+ """
+ This will add a git repo into the mongo modulestore.
+ If branch is left as None, it will fetch the most recent
+ version of the current branch.
+ """
# pylint: disable=R0915
# Set defaults even if it isn't defined in settings
@@ -102,6 +167,9 @@ def add_repo(repo, rdir_in):
log.exception('Error running git pull: %r', ex.output)
raise GitImportError(GitImportError.CANNOT_PULL)
+ if branch:
+ switch_branch(branch, rdirp)
+
# get commit id
cmd = ['git', 'log', '-1', '--format=%H', ]
try:
diff --git a/lms/djangoapps/dashboard/management/commands/git_add_course.py b/lms/djangoapps/dashboard/management/commands/git_add_course.py
index 4a184e4cd2..58092fe5c6 100644
--- a/lms/djangoapps/dashboard/management/commands/git_add_course.py
+++ b/lms/djangoapps/dashboard/management/commands/git_add_course.py
@@ -25,8 +25,14 @@ class Command(BaseCommand):
Pull a git repo and import into the mongo based content database.
"""
- help = _('Import the specified git repository into the '
- 'modulestore and directory')
+ # Translators: A git repository is a place to store a grouping of
+ # versioned files. A branch is a sub grouping of a repository that
+ # has a specific version of the repository. A modulestore is the database used
+ # to store the courses for use on the Web site.
+ help = ('Usage: '
+ 'git_add_course repository_url [directory to check out into] [repository_branch] '
+ '\n{0}'.format(_('Import the specified git repository and optional branch into the '
+ 'modulestore and optionally specified directory.')))
def handle(self, *args, **options):
"""Check inputs and run the command"""
@@ -38,16 +44,19 @@ class Command(BaseCommand):
raise CommandError('This script requires at least one argument, '
'the git URL')
- if len(args) > 2:
- raise CommandError('This script requires no more than two '
- 'arguments')
+ if len(args) > 3:
+ raise CommandError('Expected no more than three '
+ 'arguments; recieved {0}'.format(len(args)))
rdir_arg = None
+ branch = None
if len(args) > 1:
rdir_arg = args[1]
+ if len(args) > 2:
+ branch = args[2]
try:
- dashboard.git_import.add_repo(args[0], rdir_arg)
+ dashboard.git_import.add_repo(args[0], rdir_arg, branch)
except GitImportError as ex:
raise CommandError(str(ex))
diff --git a/lms/djangoapps/dashboard/management/commands/tests/test_git_add_course.py b/lms/djangoapps/dashboard/management/commands/tests/test_git_add_course.py
index fd7c81dd56..f88b8dd431 100644
--- a/lms/djangoapps/dashboard/management/commands/tests/test_git_add_course.py
+++ b/lms/djangoapps/dashboard/management/commands/tests/test_git_add_course.py
@@ -2,11 +2,12 @@
Provide tests for git_add_course management command.
"""
-import unittest
+import logging
import os
import shutil
import StringIO
import subprocess
+import unittest
from django.conf import settings
from django.core.management import call_command
@@ -14,6 +15,9 @@ from django.core.management.base import CommandError
from django.test.utils import override_settings
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
+from xmodule.contentstore.django import contentstore
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.store_utilities import delete_course
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
import dashboard.git_import as git_import
from dashboard.git_import import GitImportError
@@ -39,6 +43,10 @@ class TestGitAddCourse(ModuleStoreTestCase):
"""
TEST_REPO = 'https://github.com/mitocw/edx4edx_lite.git'
+ TEST_COURSE = 'MITx/edx4edx/edx4edx'
+ TEST_BRANCH = 'testing_do_not_delete'
+ TEST_BRANCH_COURSE = 'MITx/edx4edx_branch/edx4edx'
+ GIT_REPO_DIR = getattr(settings, 'GIT_REPO_DIR')
def assertCommandFailureRegexp(self, regex, *args):
"""
@@ -56,42 +64,45 @@ class TestGitAddCourse(ModuleStoreTestCase):
self.assertCommandFailureRegexp(
'This script requires at least one argument, the git URL')
self.assertCommandFailureRegexp(
- 'This script requires no more than two arguments',
- 'blah', 'blah', 'blah')
+ 'Expected no more than three arguments; recieved 4',
+ 'blah', 'blah', 'blah', 'blah')
self.assertCommandFailureRegexp(
'Repo was not added, check log output for details',
'blah')
# Test successful import from command
- try:
- os.mkdir(getattr(settings, 'GIT_REPO_DIR'))
- except OSError:
- pass
+ if not os.path.isdir(self.GIT_REPO_DIR):
+ os.mkdir(self.GIT_REPO_DIR)
+ self.addCleanup(shutil.rmtree, self.GIT_REPO_DIR)
# Make a course dir that will be replaced with a symlink
# while we are at it.
- if not os.path.isdir(getattr(settings, 'GIT_REPO_DIR') / 'edx4edx'):
- os.mkdir(getattr(settings, 'GIT_REPO_DIR') / 'edx4edx')
+ if not os.path.isdir(self.GIT_REPO_DIR / 'edx4edx'):
+ os.mkdir(self.GIT_REPO_DIR / 'edx4edx')
call_command('git_add_course', self.TEST_REPO,
- getattr(settings, 'GIT_REPO_DIR') / 'edx4edx_lite')
- if os.path.isdir(getattr(settings, 'GIT_REPO_DIR')):
- shutil.rmtree(getattr(settings, 'GIT_REPO_DIR'))
+ self.GIT_REPO_DIR / 'edx4edx_lite')
+
+ # Test with all three args (branch)
+ call_command('git_add_course', self.TEST_REPO,
+ self.GIT_REPO_DIR / 'edx4edx_lite',
+ self.TEST_BRANCH)
+
def test_add_repo(self):
"""
Various exit path tests for test_add_repo
"""
with self.assertRaisesRegexp(GitImportError, GitImportError.NO_DIR):
- git_import.add_repo(self.TEST_REPO, None)
+ git_import.add_repo(self.TEST_REPO, None, None)
- os.mkdir(getattr(settings, 'GIT_REPO_DIR'))
- self.addCleanup(shutil.rmtree, getattr(settings, 'GIT_REPO_DIR'))
+ os.mkdir(self.GIT_REPO_DIR)
+ self.addCleanup(shutil.rmtree, self.GIT_REPO_DIR)
with self.assertRaisesRegexp(GitImportError, GitImportError.URL_BAD):
- git_import.add_repo('foo', None)
+ git_import.add_repo('foo', None, None)
with self.assertRaisesRegexp(GitImportError, GitImportError.CANNOT_PULL):
- git_import.add_repo('file:///foobar.git', None)
+ git_import.add_repo('file:///foobar.git', None, None)
# Test git repo that exists, but is "broken"
bare_repo = os.path.abspath('{0}/{1}'.format(settings.TEST_ROOT, 'bare.git'))
@@ -101,22 +112,107 @@ class TestGitAddCourse(ModuleStoreTestCase):
cwd=bare_repo)
with self.assertRaisesRegexp(GitImportError, GitImportError.BAD_REPO):
- git_import.add_repo('file://{0}'.format(bare_repo), None)
+ git_import.add_repo('file://{0}'.format(bare_repo), None, None)
def test_detached_repo(self):
"""
Test repo that is in detached head state.
"""
- repo_dir = getattr(settings, 'GIT_REPO_DIR')
+ repo_dir = self.GIT_REPO_DIR
# Test successful import from command
try:
os.mkdir(repo_dir)
except OSError:
pass
self.addCleanup(shutil.rmtree, repo_dir)
- git_import.add_repo(self.TEST_REPO, repo_dir / 'edx4edx_lite')
+ git_import.add_repo(self.TEST_REPO, repo_dir / 'edx4edx_lite', None)
subprocess.check_output(['git', 'checkout', 'HEAD~2', ],
stderr=subprocess.STDOUT,
cwd=repo_dir / 'edx4edx_lite')
with self.assertRaisesRegexp(GitImportError, GitImportError.CANNOT_PULL):
- git_import.add_repo(self.TEST_REPO, repo_dir / 'edx4edx_lite')
+ git_import.add_repo(self.TEST_REPO, repo_dir / 'edx4edx_lite', None)
+
+ def test_branching(self):
+ """
+ Exercise branching code of import
+ """
+ repo_dir = self.GIT_REPO_DIR
+ # Test successful import from command
+ if not os.path.isdir(repo_dir):
+ os.mkdir(repo_dir)
+ self.addCleanup(shutil.rmtree, repo_dir)
+
+ # Checkout non existent branch
+ with self.assertRaisesRegexp(GitImportError, GitImportError.REMOTE_BRANCH_MISSING):
+ git_import.add_repo(self.TEST_REPO, repo_dir / 'edx4edx_lite', 'asdfasdfasdf')
+
+ # Checkout new branch
+ git_import.add_repo(self.TEST_REPO,
+ repo_dir / 'edx4edx_lite',
+ self.TEST_BRANCH)
+ def_ms = modulestore()
+ # Validate that it is different than master
+ self.assertIsNotNone(def_ms.get_course(self.TEST_BRANCH_COURSE))
+
+ # Attempt to check out the same branch again to validate branch choosing
+ # works
+ git_import.add_repo(self.TEST_REPO,
+ repo_dir / 'edx4edx_lite',
+ self.TEST_BRANCH)
+
+ # Delete to test branching back to master
+ delete_course(def_ms, contentstore(),
+ def_ms.get_course(self.TEST_BRANCH_COURSE).location,
+ True)
+ self.assertIsNone(def_ms.get_course(self.TEST_BRANCH_COURSE))
+ git_import.add_repo(self.TEST_REPO,
+ repo_dir / 'edx4edx_lite',
+ 'master')
+ self.assertIsNone(def_ms.get_course(self.TEST_BRANCH_COURSE))
+ self.assertIsNotNone(def_ms.get_course(self.TEST_COURSE))
+
+ def test_branch_exceptions(self):
+ """
+ This wil create conditions to exercise bad paths in the switch_branch function.
+ """
+ # create bare repo that we can mess with and attempt an import
+ bare_repo = os.path.abspath('{0}/{1}'.format(settings.TEST_ROOT, 'bare.git'))
+ os.mkdir(bare_repo)
+ self.addCleanup(shutil.rmtree, bare_repo)
+ subprocess.check_output(['git', '--bare', 'init', ], stderr=subprocess.STDOUT,
+ cwd=bare_repo)
+
+ # Build repo dir
+ repo_dir = self.GIT_REPO_DIR
+ if not os.path.isdir(repo_dir):
+ os.mkdir(repo_dir)
+ self.addCleanup(shutil.rmtree, repo_dir)
+
+ rdir = '{0}/bare'.format(repo_dir)
+ with self.assertRaisesRegexp(GitImportError, GitImportError.BAD_REPO):
+ git_import.add_repo('file://{0}'.format(bare_repo), None, None)
+
+ # Get logger for checking strings in logs
+ output = StringIO.StringIO()
+ test_log_handler = logging.StreamHandler(output)
+ test_log_handler.setLevel(logging.DEBUG)
+ glog = git_import.log
+ glog.addHandler(test_log_handler)
+
+ # Move remote so fetch fails
+ shutil.move(bare_repo, '{0}/not_bare.git'.format(settings.TEST_ROOT))
+ try:
+ git_import.switch_branch('master', rdir)
+ except GitImportError:
+ self.assertIn('Unable to fetch remote', output.getvalue())
+ shutil.move('{0}/not_bare.git'.format(settings.TEST_ROOT), bare_repo)
+ output.truncate(0)
+
+ # Replace origin with a different remote
+ subprocess.check_output(
+ ['git', 'remote', 'rename', 'origin', 'blah', ],
+ stderr=subprocess.STDOUT, cwd=rdir
+ )
+ with self.assertRaises(GitImportError):
+ git_import.switch_branch('master', rdir)
+ self.assertIn('Getting a list of remote branches failed', output.getvalue())
diff --git a/lms/djangoapps/dashboard/sysadmin.py b/lms/djangoapps/dashboard/sysadmin.py
index 81c0e2824e..c036dc2c0e 100644
--- a/lms/djangoapps/dashboard/sysadmin.py
+++ b/lms/djangoapps/dashboard/sysadmin.py
@@ -272,7 +272,7 @@ class Users(SysadminDashboardView):
'msg': self.msg,
'djangopid': os.getpid(),
'modeflag': {'users': 'active-section'},
- 'mitx_version': getattr(settings, 'VERSION_STRING', ''),
+ 'edx_platform_version': getattr(settings, 'EDX_PLATFORM_VERSION_STRING', ''),
}
return render_to_response(self.template_name, context)
@@ -316,7 +316,7 @@ class Users(SysadminDashboardView):
'msg': self.msg,
'djangopid': os.getpid(),
'modeflag': {'users': 'active-section'},
- 'mitx_version': getattr(settings, 'VERSION_STRING', ''),
+ 'edx_platform_version': getattr(settings, 'EDX_PLATFORM_VERSION_STRING', ''),
}
return render_to_response(self.template_name, context)
@@ -348,7 +348,7 @@ class Courses(SysadminDashboardView):
return info
- def get_course_from_git(self, gitloc, datatable):
+ def get_course_from_git(self, gitloc, branch, datatable):
"""This downloads and runs the checks for importing a course in git"""
if not (gitloc.endswith('.git') or gitloc.startswith('http:') or
@@ -357,11 +357,11 @@ class Courses(SysadminDashboardView):
"and be a valid url")
if self.is_using_mongo:
- return self.import_mongo_course(gitloc)
+ return self.import_mongo_course(gitloc, branch)
- return self.import_xml_course(gitloc, datatable)
+ return self.import_xml_course(gitloc, branch, datatable)
- def import_mongo_course(self, gitloc):
+ def import_mongo_course(self, gitloc, branch):
"""
Imports course using management command and captures logging output
at debug level for display in template
@@ -390,7 +390,7 @@ class Courses(SysadminDashboardView):
error_msg = ''
try:
- git_import.add_repo(gitloc, None)
+ git_import.add_repo(gitloc, None, branch)
except GitImportError as ex:
error_msg = str(ex)
ret = output.getvalue()
@@ -411,7 +411,7 @@ class Courses(SysadminDashboardView):
msg += "