diff options
Diffstat (limited to 'js/videojs/test/unit/tracks')
20 files changed, 4844 insertions, 0 deletions
diff --git a/js/videojs/test/unit/tracks/audio-track-list.test.js b/js/videojs/test/unit/tracks/audio-track-list.test.js new file mode 100644 index 0000000..d559cb2 --- /dev/null +++ b/js/videojs/test/unit/tracks/audio-track-list.test.js @@ -0,0 +1,120 @@ +/* eslint-env qunit */ +import AudioTrackList from '../../../src/js/tracks/audio-track-list.js'; +import AudioTrack from '../../../src/js/tracks/audio-track.js'; +import EventTarget from '../../../src/js/event-target.js'; + +QUnit.module('Audio Track List'); + +QUnit.test('trigger "change" when "enabledchange" is fired on a track', function(assert) { + const track = new EventTarget(); + + track.loaded_ = true; + const audioTrackList = new AudioTrackList([track]); + let changes = 0; + const changeHandler = function() { + changes++; + }; + + audioTrackList.on('change', changeHandler); + track.trigger('enabledchange'); + assert.equal(changes, 1, 'one change events for trigger'); + + audioTrackList.off('change', changeHandler); + audioTrackList.onchange = changeHandler; + + track.trigger('enabledchange'); + assert.equal(changes, 2, 'one change events for another trigger'); + + audioTrackList.removeTrack(track); + audioTrackList.off('change'); +}); + +QUnit.test('only one track is ever enabled', function(assert) { + const track = new AudioTrack({enabled: true}); + const track2 = new AudioTrack({enabled: true}); + const track3 = new AudioTrack({enabled: true}); + const track4 = new AudioTrack(); + const list = new AudioTrackList([track, track2]); + + assert.equal(track.enabled, false, 'track is disabled'); + assert.equal(track2.enabled, true, 'track2 is enabled'); + + track.enabled = true; + assert.equal(track.enabled, true, 'track is enabled'); + assert.equal(track2.enabled, false, 'track2 is disabled'); + + list.addTrack(track3); + assert.equal(track.enabled, false, 'track is disabled'); + assert.equal(track2.enabled, false, 'track2 is disabled'); + assert.equal(track3.enabled, true, 'track3 is enabled'); + + track2.enabled = true; + assert.equal(track.enabled, false, 'track is disabled'); + assert.equal(track2.enabled, true, 'track2 is enabled'); + assert.equal(track3.enabled, false, 'track3 is disabled'); + + list.addTrack(track4); + assert.equal(track.enabled, false, 'track is disabled'); + assert.equal(track2.enabled, true, 'track2 is enabled'); + assert.equal(track3.enabled, false, 'track3 is disabled'); + assert.equal(track4.enabled, false, 'track4 is disabled'); + + list.removeTrack(track); + list.removeTrack(track2); + list.removeTrack(track3); + list.removeTrack(track4); +}); + +QUnit.test('all tracks can be disabled', function(assert) { + const track = new AudioTrack(); + const track2 = new AudioTrack(); + + // we need audiotracklist here to verify that it does not + // re-enable a track + const list = new AudioTrackList([track, track2]); + + assert.equal(track.enabled, false, 'track is disabled'); + assert.equal(track2.enabled, false, 'track2 is disabled'); + + track.enabled = true; + assert.equal(track.enabled, true, 'track is enabled'); + assert.equal(track2.enabled, false, 'track2 is disabled'); + + track.enabled = false; + assert.equal(track.enabled, false, 'track is disabled'); + assert.equal(track2.enabled, false, 'track2 is disabled'); + + list.removeTrack(track); + list.removeTrack(track2); +}); + +QUnit.test('trigger a change event per enabled change', function(assert) { + const track = new AudioTrack({enabled: true}); + const track2 = new AudioTrack({enabled: true}); + const track3 = new AudioTrack({enabled: true}); + const track4 = new AudioTrack(); + const list = new AudioTrackList([track, track2]); + let change = 0; + + list.on('change', () => change++); + track.enabled = true; + assert.equal(change, 1, 'one change triggered'); + + list.addTrack(track3); + assert.equal(change, 2, 'another change triggered by adding an enabled track'); + + track.enabled = true; + assert.equal(change, 3, 'another change trigger by changing enabled'); + + track.enabled = false; + assert.equal(change, 4, 'another change trigger by changing enabled'); + + list.addTrack(track4); + assert.equal(change, 4, 'no change triggered by adding a disabled track'); + + list.removeTrack(track); + list.removeTrack(track2); + list.removeTrack(track3); + list.removeTrack(track4); + list.off(); +}); diff --git a/js/videojs/test/unit/tracks/audio-track.test.js b/js/videojs/test/unit/tracks/audio-track.test.js new file mode 100644 index 0000000..e8f3f2f --- /dev/null +++ b/js/videojs/test/unit/tracks/audio-track.test.js @@ -0,0 +1,124 @@ +/* eslint-env qunit */ +import AudioTrack from '../../../src/js/tracks/audio-track.js'; +import {AudioTrackKind} from '../../../src/js/tracks/track-enums.js'; +import TrackBaseline from './track-baseline'; + +QUnit.module('Audio Track'); + +// do baseline track testing +TrackBaseline(AudioTrack, { + id: '1', + language: 'en', + label: 'English', + kind: 'main' +}); + +QUnit.test('can create an enabled property on an AudioTrack', function(assert) { + const enabled = true; + const track = new AudioTrack({ + enabled + }); + + assert.equal(track.enabled, enabled, 'enabled value matches what we passed in'); +}); + +QUnit.test('defaults when items not provided', function(assert) { + const track = new AudioTrack(); + + assert.equal(track.kind, '', 'kind defaulted to empty string'); + assert.equal(track.enabled, false, 'enabled defaulted to true since there is one track'); + assert.equal(track.label, '', 'label defaults to empty string'); + assert.equal(track.language, '', 'language defaults to empty string'); + assert.ok(track.id.match(/vjs_track_\d+/), 'id defaults to vjs_track_GUID'); +}); + +QUnit.test('kind can only be one of several options, defaults to empty string', function(assert) { + const track1 = new AudioTrack({ + kind: 'foo' + }); + + assert.equal(track1.kind, '', 'the kind is set to empty string, not foo'); + assert.notEqual(track1.kind, 'foo', 'the kind is set to empty string, not foo'); + + // loop through all possible kinds to verify + for (const key in AudioTrackKind) { + const currentKind = AudioTrackKind[key]; + const track = new AudioTrack({ + kind: currentKind + }); + + assert.equal(track.kind, currentKind, 'the kind is set to ' + currentKind); + } +}); + +QUnit.test('enabled can only be instantiated to true or false, defaults to false', function(assert) { + let track = new AudioTrack({ + enabled: 'foo' + }); + + assert.equal(track.enabled, false, 'the enabled value is set to false, not foo'); + assert.notEqual(track.enabled, 'foo', 'the enabled value is not set to foo'); + + track = new AudioTrack({ + enabled: true + }); + + assert.equal(track.enabled, true, 'the enabled value is set to true'); + + track = new AudioTrack({ + enabled: false + }); + + assert.equal(track.enabled, false, 'the enabled value is set to false'); +}); + +QUnit.test('enabled can only be changed to true or false', function(assert) { + const track = new AudioTrack(); + + track.enabled = 'foo'; + assert.notEqual(track.enabled, 'foo', 'enabled not set to invalid value, foo'); + assert.equal(track.enabled, false, 'enabled remains on the old value, false'); + + track.enabled = true; + assert.equal(track.enabled, true, 'enabled was set to true'); + + track.enabled = 'baz'; + assert.notEqual(track.enabled, 'baz', 'enabled not set to invalid value, baz'); + assert.equal(track.enabled, true, 'enabled remains on the old value, true'); + + track.enabled = false; + assert.equal(track.enabled, false, 'enabled was set to false'); +}); + +QUnit.test('when enabled is changed enabledchange event is fired', function(assert) { + const track = new AudioTrack({ + tech: this.tech, + enabled: false + }); + let eventsTriggered = 0; + + track.addEventListener('enabledchange', () => { + eventsTriggered++; + }); + + // two events + track.enabled = true; + track.enabled = false; + assert.equal(eventsTriggered, 2, 'two enabled changes'); + + // no event here + track.enabled = false; + track.enabled = false; + assert.equal(eventsTriggered, 2, 'still two enabled changes'); + + // one event + track.enabled = true; + assert.equal(eventsTriggered, 3, 'three enabled changes'); + + // no events + track.enabled = true; + track.enabled = true; + assert.equal(eventsTriggered, 3, 'still three enabled changes'); + + track.off(); +}); diff --git a/js/videojs/test/unit/tracks/audio-tracks.test.js b/js/videojs/test/unit/tracks/audio-tracks.test.js new file mode 100644 index 0000000..70327e0 --- /dev/null +++ b/js/videojs/test/unit/tracks/audio-tracks.test.js @@ -0,0 +1,110 @@ +/* eslint-env qunit */ +import Html5 from '../../../src/js/tech/html5.js'; +import TestHelpers from '../test-helpers.js'; +import sinon from 'sinon'; + +QUnit.module('Audio Tracks', { + beforeEach(assert) { + this.clock = sinon.useFakeTimers(); + }, + afterEach(assert) { + this.clock.restore(); + } +}); + +QUnit.test('Player track methods call the tech', function(assert) { + const player = TestHelpers.makePlayer(); + let calls = 0; + + player.tech_.audioTracks = function() { + calls++; + }; + + player.audioTracks(); + + assert.equal(calls, 1, 'audioTrack defers to the tech'); + player.dispose(); +}); + +QUnit.test('listen to remove and add track events in native audio tracks', function(assert) { + const oldTestVid = Html5.TEST_VID; + const oldAudioTracks = Html5.prototype.audioTracks; + const events = {}; + + Html5.prototype.audioTracks = function() { + return { + removeEventListener() {}, + addEventListener(type, handler) { + events[type] = true; + } + }; + }; + + Html5.TEST_VID = { + audioTracks: [] + }; + + const player = { + // Function.prototype is a built-in no-op function. + controls() {}, + ready() {}, + options() { + return {}; + }, + addChild() {}, + id() {}, + el() { + return { + insertBefore() {}, + appendChild() {} + }; + } + }; + + player.player_ = player; + player.options_ = {}; + + const html = new Html5({}); + + assert.ok(events.removetrack, 'removetrack listener was added'); + assert.ok(events.addtrack, 'addtrack listener was added'); + + Html5.TEST_VID = oldTestVid; + Html5.prototype.audioTracks = oldAudioTracks; + html.dispose(); +}); + +QUnit.test('html5 tech supports native audio tracks if the video supports it', function(assert) { + const oldTestVid = Html5.TEST_VID; + + Html5.TEST_VID = { + audioTracks: [] + }; + + assert.ok(Html5.supportsNativeAudioTracks(), 'native audio tracks are supported'); + + Html5.TEST_VID = oldTestVid; +}); + +QUnit.test('html5 tech does not support native audio tracks if the video does not supports it', function(assert) { + const oldTestVid = Html5.TEST_VID; + + Html5.TEST_VID = {}; + + assert.ok(!Html5.supportsNativeAudioTracks(), 'native audio tracks are not supported'); + + Html5.TEST_VID = oldTestVid; +}); + +QUnit.test('when switching techs, we should not get a new audio track', function(assert) { + const player = TestHelpers.makePlayer(); + + player.loadTech_('TechFaker'); + const firstTracks = player.audioTracks(); + + player.loadTech_('TechFaker'); + const secondTracks = player.audioTracks(); + + assert.ok(firstTracks === secondTracks, 'the tracks are equal'); + player.dispose(); +}); diff --git a/js/videojs/test/unit/tracks/html-track-element-list.test.js b/js/videojs/test/unit/tracks/html-track-element-list.test.js new file mode 100644 index 0000000..da25d36 --- /dev/null +++ b/js/videojs/test/unit/tracks/html-track-element-list.test.js @@ -0,0 +1,87 @@ +/* eslint-env qunit */ +import HTMLTrackElementList from '../../../src/js/tracks/html-track-element-list.js'; +import TextTrack from '../../../src/js/tracks/text-track.js'; + +const defaultTech = { + textTracks() {}, + on() {}, + off() {}, + one() {}, + currentTime() {} +}; + +const track1 = new TextTrack({ + id: 1, + tech: defaultTech +}); +const track2 = new TextTrack({ + id: 2, + tech: defaultTech +}); + +const genericHtmlTrackElements = [{ + tech() {}, + kind: 'captions', + track: track1 +}, { + tech() {}, + kind: 'chapters', + track: track2 +}]; + +QUnit.module('HTML Track Element List'); + +QUnit.test('HTMLTrackElementList\'s length is set correctly', function(assert) { + const htmlTrackElementList = new HTMLTrackElementList(genericHtmlTrackElements); + + assert.equal( + htmlTrackElementList.length, + genericHtmlTrackElements.length, + `the length is ${genericHtmlTrackElements.length}` + ); +}); + +QUnit.test('can get html track element by track', function(assert) { + const htmlTrackElementList = new HTMLTrackElementList(genericHtmlTrackElements); + + assert.equal( + htmlTrackElementList.getTrackElementByTrack_(track1).kind, + 'captions', + 'track1 has kind of captions' + ); + assert.equal( + htmlTrackElementList.getTrackElementByTrack_(track2).kind, + 'chapters', + 'track2 has kind of captions' + ); +}); + +QUnit.test('length is updated when new tracks are added or removed', function(assert) { + const htmlTrackElementList = new HTMLTrackElementList(genericHtmlTrackElements); + + htmlTrackElementList.addTrackElement_({tech() {}}); + assert.equal( + htmlTrackElementList.length, + genericHtmlTrackElements.length + 1, + `the length is ${genericHtmlTrackElements.length + 1}` + ); + htmlTrackElementList.addTrackElement_({tech() {}}); + assert.equal( + htmlTrackElementList.length, + genericHtmlTrackElements.length + 2, + `the length is ${genericHtmlTrackElements.length + 2}` + ); + + htmlTrackElementList.removeTrackElement_(htmlTrackElementList.getTrackElementByTrack_(track1)); + assert.equal( + htmlTrackElementList.length, + genericHtmlTrackElements.length + 1, + `the length is ${genericHtmlTrackElements.length + 1}` + ); + htmlTrackElementList.removeTrackElement_(htmlTrackElementList.getTrackElementByTrack_(track2)); + assert.equal( + htmlTrackElementList.length, + genericHtmlTrackElements.length, + `the length is ${genericHtmlTrackElements.length}` + ); +}); diff --git a/js/videojs/test/unit/tracks/html-track-element.test.js b/js/videojs/test/unit/tracks/html-track-element.test.js new file mode 100644 index 0000000..b46b072 --- /dev/null +++ b/js/videojs/test/unit/tracks/html-track-element.test.js @@ -0,0 +1,81 @@ +/* eslint-env qunit */ +import HTMLTrackElement from '../../../src/js/tracks/html-track-element.js'; +import TechFaker from '../tech/tech-faker'; + +QUnit.module('HTML Track Element', { + beforeEach() { + this.tech = new TechFaker(); + }, + afterEach() { + this.tech.dispose(); + this.tech = null; + } +}); + +QUnit.test('html track element requires a tech', function(assert) { + assert.throws( + function() { + return new HTMLTrackElement(); + }, + new Error('A tech was not provided.'), + 'a tech is required for html track element' + ); +}); + +QUnit.test('can create a html track element with various properties', function(assert) { + const kind = 'chapters'; + const label = 'English'; + const language = 'en'; + + const htmlTrackElement = new HTMLTrackElement({ + kind, + label, + language, + tech: this.tech + }); + + assert.equal(typeof htmlTrackElement.default, 'undefined', 'we have a default'); + assert.equal(htmlTrackElement.kind, kind, 'we have a kind'); + assert.equal(htmlTrackElement.label, label, 'we have a label'); + assert.equal(htmlTrackElement.readyState, 0, 'we have a readyState'); + assert.equal(htmlTrackElement.srclang, language, 'we have a srclang'); + + htmlTrackElement.track.off(); +}); + +QUnit.test('defaults when items not provided', function(assert) { + const htmlTrackElement = new HTMLTrackElement({ + tech: this.tech + }); + + assert.equal(typeof htmlTrackElement.default, 'undefined', 'we have a default'); + assert.equal(htmlTrackElement.kind, 'subtitles', 'we have a kind'); + assert.equal(htmlTrackElement.label, '', 'we have a label'); + assert.equal(htmlTrackElement.readyState, 0, 'we have a readyState'); + assert.equal(typeof htmlTrackElement.src, 'undefined', 'we have a src'); + assert.equal(htmlTrackElement.srclang, '', 'we have a srclang'); + assert.equal(htmlTrackElement.track.cues.length, 0, 'we have a track'); + + htmlTrackElement.track.off(); +}); + +QUnit.test('fires loadeddata when track cues become populated', function(assert) { + let changes = 0; + const loadHandler = function() { + changes++; + }; + const htmlTrackElement = new HTMLTrackElement({ + tech: this.tech + }); + + htmlTrackElement.addEventListener('load', loadHandler); + + // trigger loaded cues event + htmlTrackElement.track.trigger('loadeddata'); + + assert.equal(changes, 1, 'a loadeddata event trigger addEventListener'); + assert.equal(htmlTrackElement.readyState, 2, 'readyState is loaded'); + + htmlTrackElement.track.off(); + htmlTrackElement.off('load'); +}); diff --git a/js/videojs/test/unit/tracks/text-track-controls.test.js b/js/videojs/test/unit/tracks/text-track-controls.test.js new file mode 100644 index 0000000..e96ff94 --- /dev/null +++ b/js/videojs/test/unit/tracks/text-track-controls.test.js @@ -0,0 +1,572 @@ +/* eslint-env qunit */ +import TextTrackMenuItem from '../../../src/js/control-bar/text-track-controls/text-track-menu-item.js'; +import TestHelpers from '../test-helpers.js'; +import sinon from 'sinon'; + +QUnit.module('Text Track Controls', { + beforeEach(assert) { + this.clock = sinon.useFakeTimers(); + }, + afterEach(assert) { + this.clock.restore(); + } +}); + +const track = { + kind: 'captions', + label: 'test' +}; + +QUnit.test('should be displayed when text tracks list is not empty', function(assert) { + const player = TestHelpers.makePlayer({ + tracks: [track] + }); + + this.clock.tick(1000); + + assert.ok( + !player.controlBar.subsCapsButton.hasClass('vjs-hidden'), + 'control is displayed' + ); + assert.equal(player.textTracks().length, 1, 'textTracks contains one item'); + + player.dispose(); +}); + +QUnit.test('should be displayed when a text track is added to an empty track list', function(assert) { + const player = TestHelpers.makePlayer(); + + player.addRemoteTextTrack(track, true); + + assert.ok( + !player.controlBar.subsCapsButton.hasClass('vjs-hidden'), + 'control is displayed' + ); + assert.equal(player.textTracks().length, 1, 'textTracks contains one item'); + + player.dispose(); +}); + +QUnit.test('should not be displayed when text tracks list is empty', function(assert) { + const player = TestHelpers.makePlayer(); + + assert.ok( + player.controlBar.subsCapsButton.hasClass('vjs-hidden'), + 'control is not displayed' + ); + assert.equal(player.textTracks().length, 0, 'textTracks is empty'); + + player.dispose(); +}); + +QUnit.test('should not be displayed when last text track is removed', function(assert) { + const player = TestHelpers.makePlayer({ + tracks: [track] + }); + + player.removeRemoteTextTrack(player.textTracks()[0]); + + assert.ok( + player.controlBar.subsCapsButton.hasClass('vjs-hidden'), + 'control is not displayed' + ); + assert.equal(player.textTracks().length, 0, 'textTracks is empty'); + + player.dispose(); +}); + +QUnit.test('menu should contain "Settings", "Off" and one track', function(assert) { + const player = TestHelpers.makePlayer({ + language: 'en', + tracks: [track] + }); + + this.clock.tick(1000); + + const menuItems = player.controlBar.subsCapsButton.items; + + assert.equal(menuItems.length, 3, 'menu contains three items'); + assert.equal( + menuItems[0].track.label, + 'captions settings', + 'menu contains "captions settings"' + ); + assert.equal(menuItems[1].track.label, 'captions off', 'menu contains "captions off"'); + assert.equal(menuItems[2].track.label, 'test', 'menu contains "test" track'); + + player.dispose(); +}); + +QUnit.test('menu should contain "Settings", "Off", one captions and one subtitles track', function(assert) { + const player = TestHelpers.makePlayer({ + language: 'en', + tracks: [track, { + kind: 'subtitles', + label: 'test subs' + }] + }); + + this.clock.tick(1000); + + const menuItems = player.controlBar.subsCapsButton.items; + + assert.equal(menuItems.length, 4, 'menu contains three items'); + assert.equal( + menuItems[0].track.label, + 'captions settings', + 'menu contains "captions settings"' + ); + assert.equal(menuItems[1].track.label, 'captions off', 'menu contains "captions off"'); + assert.equal(menuItems[2].track.label, 'test', 'menu contains "test" track'); + + player.dispose(); +}); + +QUnit.test('menu should update with addRemoteTextTrack', function(assert) { + const player = TestHelpers.makePlayer({ + tracks: [track] + }); + + this.clock.tick(1000); + + player.addRemoteTextTrack(track, true); + + assert.equal( + player.controlBar.subsCapsButton.items.length, + 4, + 'menu does contain added track' + ); + assert.equal(player.textTracks().length, 2, 'textTracks contains two items'); + + player.dispose(); +}); + +QUnit.test('menu should update with removeRemoteTextTrack', function(assert) { + const player = TestHelpers.makePlayer({ + tracks: [track, track] + }); + + this.clock.tick(1000); + + player.removeRemoteTextTrack(player.textTracks()[0]); + + assert.equal( + player.controlBar.subsCapsButton.items.length, + 3, + 'menu does not contain removed track' + ); + assert.equal(player.textTracks().length, 1, 'textTracks contains one item'); + + player.dispose(); +}); + +const descriptionstrack = { + kind: 'descriptions', + label: 'desc' +}; + +QUnit.test('descriptions should be displayed when text tracks list is not empty', function(assert) { + const player = TestHelpers.makePlayer({ + tracks: [descriptionstrack] + }); + + this.clock.tick(1000); + + assert.ok( + !player.controlBar.descriptionsButton.hasClass('vjs-hidden'), + 'descriptions control is displayed' + ); + assert.equal(player.textTracks().length, 1, 'textTracks contains one item'); + + player.dispose(); +}); + +QUnit.test('descriptions should be displayed when a text track is added to an empty track list', function(assert) { + const player = TestHelpers.makePlayer(); + + player.addRemoteTextTrack(descriptionstrack, true); + + assert.ok( + !player.controlBar.descriptionsButton.hasClass('vjs-hidden'), + 'control is displayed' + ); + assert.equal(player.textTracks().length, 1, 'textTracks contains one item'); + + player.dispose(); +}); + +QUnit.test('descriptions should not be displayed when text tracks list is empty', function(assert) { + const player = TestHelpers.makePlayer(); + + assert.ok( + player.controlBar.descriptionsButton.hasClass('vjs-hidden'), + 'control is not displayed' + ); + assert.equal(player.textTracks().length, 0, 'textTracks is empty'); + + player.dispose(); +}); + +QUnit.test('descriptions should not be displayed when last text track is removed', function(assert) { + const player = TestHelpers.makePlayer({ + tracks: [descriptionstrack] + }); + + player.removeRemoteTextTrack(player.textTracks()[0]); + + assert.ok( + player.controlBar.descriptionsButton.hasClass('vjs-hidden'), + 'control is not displayed' + ); + assert.equal(player.textTracks().length, 0, 'textTracks is empty'); + + player.dispose(); +}); + +QUnit.test('descriptions menu should contain "Off" and one track', function(assert) { + const player = TestHelpers.makePlayer({ + tracks: [descriptionstrack] + }); + + this.clock.tick(1000); + + const menuItems = player.controlBar.descriptionsButton.items; + + assert.equal(menuItems.length, 2, 'descriptions menu contains two items'); + assert.equal( + menuItems[0].track.label, + 'descriptions off', + 'menu contains "descriptions off"' + ); + assert.equal(menuItems[1].track.label, 'desc', 'menu contains "desc" track'); + + player.dispose(); +}); + +QUnit.test('enabling a captions track should disable the descriptions menu button', function(assert) { + assert.expect(14); + + const player = TestHelpers.makePlayer({ + tracks: [track, descriptionstrack] + }); + + this.clock.tick(1000); + + assert.ok( + !player.controlBar.subsCapsButton.hasClass('vjs-hidden'), + 'captions control is displayed' + ); + assert.ok( + !player.controlBar.descriptionsButton.hasClass('vjs-hidden'), + 'descriptions control is displayed' + ); + assert.equal(player.textTracks().length, 2, 'textTracks contains two items'); + + assert.ok( + !player.controlBar.subsCapsButton.hasClass('vjs-disabled'), + 'captions control is NOT disabled' + ); + assert.ok( + !player.controlBar.descriptionsButton.hasClass('vjs-disabled'), + 'descriptions control is NOT disabled' + ); + + for (let i = 0; i < player.textTracks().length; i++) { + if (player.textTracks()[i].kind === 'descriptions') { + player.textTracks()[i].mode = 'showing'; + assert.ok( + player.textTracks()[i].kind === 'descriptions' && + player.textTracks()[i].mode === 'showing', + 'descriptions mode set to showing' + ); + } + } + + this.clock.tick(1000); + + assert.ok( + !player.controlBar.subsCapsButton.hasClass('vjs-disabled'), + 'captions control is NOT disabled' + ); + assert.ok( + !player.controlBar.descriptionsButton.hasClass('vjs-disabled'), + 'descriptions control is NOT disabled' + ); + + for (let i = 0; i < player.textTracks().length; i++) { + if (player.textTracks()[i].kind === 'captions') { + player.textTracks()[i].mode = 'showing'; + assert.ok( + player.textTracks()[i].kind === 'captions' && + player.textTracks()[i].mode === 'showing', + 'captions mode set to showing' + ); + } + } + + this.clock.tick(1000); + + assert.ok( + !player.controlBar.subsCapsButton.hasClass('vjs-disabled'), + 'captions control is NOT disabled' + ); + assert.ok( + player.controlBar.descriptionsButton.hasClass('vjs-disabled'), + 'descriptions control IS disabled' + ); + + for (let i = 0; i < player.textTracks().length; i++) { + if (player.textTracks()[i].kind === 'captions') { + player.textTracks()[i].mode = 'disabled'; + assert.ok( + player.textTracks()[i].kind === 'captions' && + player.textTracks()[i].mode === 'disabled', + 'captions mode set to disabled' + ); + } + } + + this.clock.tick(1000); + + assert.ok( + !player.controlBar.subsCapsButton.hasClass('vjs-disabled'), + 'captions control is NOT disabled' + ); + assert.ok( + !player.controlBar.descriptionsButton.hasClass('vjs-disabled'), + 'descriptions control is NOT disabled' + ); + + player.dispose(); +}); + +// This test tests a specific with iOS7 where +// the TextTrackList doesn't report track mode changes. +QUnit.test('menu items should polyfill mode change events', function(assert) { + const player = TestHelpers.makePlayer({}); + let changes; + + // emulate a TextTrackList that doesn't report track mode changes, + // like iOS7 + player.textTracks().onchange = undefined; + const trackMenuItem = new TextTrackMenuItem(player, { + track + }); + + player.textTracks().on('change', function() { + changes++; + }); + changes = 0; + trackMenuItem.trigger('tap'); + assert.equal(changes, 1, 'taps trigger change events'); + + trackMenuItem.trigger('click'); + assert.equal(changes, 2, 'clicks trigger change events'); + + player.dispose(); + trackMenuItem.dispose(); + player.textTracks().off('change'); +}); + +const chaptersTrack = { + kind: 'chapters', + label: 'Test Chapters' +}; + +QUnit.test('chapters should not be displayed when text tracks list is empty', function(assert) { + const player = TestHelpers.makePlayer(); + + assert.ok(player.controlBar.chaptersButton.hasClass('vjs-hidden'), 'control is not displayed'); + assert.equal(player.textTracks().length, 0, 'textTracks is empty'); + + player.dispose(); +}); + +QUnit.test('chapters should not be displayed when there is chapters track but no cues', function(assert) { + const player = TestHelpers.makePlayer({ + tracks: [chaptersTrack] + }); + + this.clock.tick(1000); + + assert.ok(player.controlBar.chaptersButton.hasClass('vjs-hidden'), 'chapters menu is not displayed'); + assert.equal(player.textTracks().length, 1, 'textTracks contains one item'); + + player.dispose(); +}); + +QUnit.test('chapters should be displayed when cues added to initial track and button updated', function(assert) { + const player = TestHelpers.makePlayer({ + tracks: [chaptersTrack] + }); + + this.clock.tick(1000); + + const chapters = player.textTracks()[0]; + + chapters.addCue({ + startTime: 0, + endTime: 2, + text: 'Chapter 1' + }); + chapters.addCue({ + startTime: 2, + endTime: 4, + text: 'Chapter 2' + }); + assert.equal(chapters.cues.length, 2); + + player.controlBar.chaptersButton.update(); + + assert.ok(!player.controlBar.chaptersButton.hasClass('vjs-hidden'), 'chapters menu is displayed'); + + const menuItems = player.controlBar.chaptersButton.items; + + assert.equal(menuItems.length, 2, 'menu contains two item'); + + player.dispose(); +}); + +QUnit.test('chapters should be displayed when a track and its cures added and button updated', function(assert) { + const player = TestHelpers.makePlayer(); + + this.clock.tick(1000); + + const chapters = player.addTextTrack('chapters', 'Test Chapters', 'en'); + + chapters.addCue({ + startTime: 0, + endTime: 2, + text: 'Chapter 1' + }); + chapters.addCue({ + startTime: 2, + endTime: 4, + text: 'Chapter 2' + }); + assert.equal(chapters.cues.length, 2); + + player.controlBar.chaptersButton.update(); + + assert.ok(!player.controlBar.chaptersButton.hasClass('vjs-hidden'), 'chapters menu is displayed'); + + const menuItems = player.controlBar.chaptersButton.items; + + assert.equal(menuItems.length, 2, 'menu contains two item'); + + player.dispose(); +}); + +QUnit.test('chapters menu should use track label as menu title', function(assert) { + const player = TestHelpers.makePlayer({ + tracks: [chaptersTrack] + }); + + this.clock.tick(1000); + + const chapters = player.textTracks()[0]; + + chapters.addCue({ + startTime: 0, + endTime: 2, + text: 'Chapter 1' + }); + chapters.addCue({ + startTime: 2, + endTime: 4, + text: 'Chapter 2' + }); + assert.equal(chapters.cues.length, 2); + + player.controlBar.chaptersButton.update(); + + const menu = player.controlBar.chaptersButton.menu; + const titleEl = menu.contentEl().firstChild; + const menuTitle = titleEl.textContent || titleEl.innerText; + + assert.equal(menuTitle, 'Test Chapters', 'menu gets track label as title'); + + player.dispose(); +}); + +QUnit.test('chapters should be displayed when remote track added and load event fired', function(assert) { + const player = TestHelpers.makePlayer(); + + this.clock.tick(1000); + + const chaptersEl = player.addRemoteTextTrack(chaptersTrack, true); + + chaptersEl.track.addCue({ + startTime: 0, + endTime: 2, + text: 'Chapter 1' + }); + chaptersEl.track.addCue({ + startTime: 2, + endTime: 4, + text: 'Chapter 2' + }); + + assert.equal(chaptersEl.track.cues.length, 2); + + // Anywhere where we support using native text tracks, we can trigger a custom DOM event. + // On IE8 and other places where we have emulated tracks, either we cannot trigger custom + // DOM events (like IE8 with the custom DOM element) or we aren't using a DOM element at all. + // In those cases just trigger `load` directly on the chaptersEl object. + if (player.tech_.featuresNativeTextTracks) { + TestHelpers.triggerDomEvent(chaptersEl, 'load'); + } else { + chaptersEl.trigger('load'); + } + + assert.ok(!player.controlBar.chaptersButton.hasClass('vjs-hidden'), 'chapters menu is displayed'); + + const menuItems = player.controlBar.chaptersButton.items; + + assert.equal(menuItems.length, 2, 'menu contains two item'); + + player.dispose(); +}); + +QUnit.test('chapters button should update selected menu item', function(assert) { + const player = TestHelpers.makePlayer(); + + this.clock.tick(1000); + + const chaptersEl = player.addRemoteTextTrack(chaptersTrack, true); + + chaptersEl.track.addCue({ + startTime: 0, + endTime: 2, + text: 'Chapter 1' + }); + chaptersEl.track.addCue({ + startTime: 2, + endTime: 4, + text: 'Chapter 2' + }); + + assert.equal(chaptersEl.track.cues.length, 2); + + if (player.tech_.featuresNativeTextTracks) { + TestHelpers.triggerDomEvent(chaptersEl, 'load'); + } else { + chaptersEl.trigger('load'); + } + + const menuItems = player.controlBar.chaptersButton.items; + + assert.ok(menuItems.find(i => i.isSelected_) === menuItems[0], 'item with startTime 0 selected on init'); + + player.currentTime(4); + chaptersEl.track.timeupdateHandler(); + + assert.ok(menuItems.find(i => i.isSelected_) === menuItems[1], 'second item selected on cuechange'); + + player.currentTime(1); + chaptersEl.track.timeupdateHandler(); + + assert.ok(menuItems.find(i => i.isSelected_) === menuItems[0], 'first item selected on cuechange'); + + player.dispose(); +}); diff --git a/js/videojs/test/unit/tracks/text-track-cue-list.test.js b/js/videojs/test/unit/tracks/text-track-cue-list.test.js new file mode 100644 index 0000000..e62142d --- /dev/null +++ b/js/videojs/test/unit/tracks/text-track-cue-list.test.js @@ -0,0 +1,99 @@ +/* eslint-env qunit */ +import TextTrackCueList from '../../../src/js/tracks/text-track-cue-list.js'; + +const genericTracks = [ + { + id: '1' + }, { + id: '2' + }, { + id: '3' + } +]; + +QUnit.module('Text Track Cue List'); + +QUnit.test('TextTrackCueList\'s length is set correctly', function(assert) { + const ttcl = new TextTrackCueList(genericTracks); + + assert.equal(ttcl.length, genericTracks.length, 'the length is ' + genericTracks.length); +}); + +QUnit.test('can get cues by id', function(assert) { + const ttcl = new TextTrackCueList(genericTracks); + + assert.equal(ttcl.getCueById('1').id, 1, 'id "1" has id of "1"'); + assert.equal(ttcl.getCueById('2').id, 2, 'id "2" has id of "2"'); + assert.equal(ttcl.getCueById('3').id, 3, 'id "3" has id of "3"'); + assert.ok(!ttcl.getCueById(1), 'there isn\'t an item with "numeric" id of `1`'); +}); + +QUnit.test('length is updated when new tracks are added or removed', function(assert) { + const ttcl = new TextTrackCueList(genericTracks); + + ttcl.setCues_(genericTracks.concat([{id: '100'}])); + assert.equal( + ttcl.length, genericTracks.length + 1, + 'the length is ' + (genericTracks.length + 1) + ); + ttcl.setCues_(genericTracks.concat([{id: '100'}, {id: '101'}])); + assert.equal( + ttcl.length, genericTracks.length + 2, + 'the length is ' + (genericTracks.length + 2) + ); + + ttcl.setCues_(genericTracks.concat([{id: '100'}])); + assert.equal( + ttcl.length, genericTracks.length + 1, + 'the length is ' + (genericTracks.length + 1) + ); + ttcl.setCues_(genericTracks); + assert.equal(ttcl.length, genericTracks.length, 'the length is ' + genericTracks.length); +}); + +QUnit.test('can access items by index', function(assert) { + const ttcl = new TextTrackCueList(genericTracks); + const length = ttcl.length; + + assert.expect(length); + + for (let i = 0; i < length; i++) { + assert.equal(ttcl[i].id, String(i + 1), 'the id of a track matches the index + 1'); + } +}); + +QUnit.test('can access new items by index', function(assert) { + const ttcl = new TextTrackCueList(genericTracks); + + ttcl.setCues_(genericTracks.concat([{id: '100'}])); + + assert.equal(ttcl[3].id, '100', 'id of item at index 3 is 100'); + ttcl.setCues_(genericTracks.concat([{id: '100'}, {id: '101'}])); + assert.equal(ttcl[4].id, '101', 'id of item at index 4 is 101'); +}); + +QUnit.test('cannot access removed items by index', function(assert) { + const ttcl = new TextTrackCueList(genericTracks); + + ttcl.setCues_(genericTracks.concat([{id: '100'}, {id: '101'}])); + assert.equal(ttcl[3].id, '100', 'id of item at index 3 is 100'); + assert.equal(ttcl[4].id, '101', 'id of item at index 4 is 101'); + + ttcl.setCues_(genericTracks); + + assert.ok(!ttcl[3], 'nothing at index 3'); + assert.ok(!ttcl[4], 'nothing at index 4'); +}); + +QUnit.test('new item available at old index', function(assert) { + const ttcl = new TextTrackCueList(genericTracks); + + ttcl.setCues_(genericTracks.concat([{id: '100'}])); + assert.equal(ttcl[3].id, '100', 'id of item at index 3 is 100'); + + ttcl.setCues_(genericTracks); + assert.ok(!ttcl[3], 'nothing at index 3'); + + ttcl.setCues_(genericTracks.concat([{id: '101'}])); + assert.equal(ttcl[3].id, '101', 'id of new item at index 3 is now 101'); +}); diff --git a/js/videojs/test/unit/tracks/text-track-display.test.js b/js/videojs/test/unit/tracks/text-track-display.test.js new file mode 100644 index 0000000..6dcfb01 --- /dev/null +++ b/js/videojs/test/unit/tracks/text-track-display.test.js @@ -0,0 +1,622 @@ +/* eslint-env qunit */ +import window from 'global/window'; +import Html5 from '../../../src/js/tech/html5.js'; +import { constructColor } from '../../../src/js/tracks/text-track-display.js'; +import Component from '../../../src/js/component.js'; + +import * as browser from '../../../src/js/utils/browser.js'; +import TestHelpers from '../test-helpers.js'; +import document from 'global/document'; +import sinon from 'sinon'; + +QUnit.module('Text Track Display', { + beforeEach(assert) { + this.clock = sinon.useFakeTimers(); + }, + afterEach(assert) { + this.clock.restore(); + } +}); + +const getMenuItemByLanguage = function(items, language) { + for (let i = items.length - 1; i > 0; i--) { + const captionMenuItem = items[i]; + const trackLanguage = captionMenuItem.track.language; + + if (trackLanguage && trackLanguage === language) { + return captionMenuItem; + } + } +}; + +QUnit.test('if native text tracks are not supported, create a texttrackdisplay', function(assert) { + const oldTestVid = Html5.TEST_VID; + const oldIsFirefox = browser.IS_FIREFOX; + const oldTextTrackDisplay = Component.getComponent('TextTrackDisplay'); + const tag = document.createElement('video'); + const track1 = document.createElement('track'); + const track2 = document.createElement('track'); + + track1.kind = 'captions'; + track1.label = 'en'; + track1.language = 'English'; + track1.src = 'en.vtt'; + tag.appendChild(track1); + + track2.kind = 'captions'; + track2.label = 'es'; + track2.language = 'Spanish'; + track2.src = 'es.vtt'; + tag.appendChild(track2); + + Html5.TEST_VID = { + textTracks: [] + }; + + browser.stub_IS_FIREFOX(true); + + const fakeTTDSpy = sinon.spy(); + + class FakeTTD extends Component { + constructor(player, options) { + super(player, options); + fakeTTDSpy(); + } + } + + Component.registerComponent('TextTrackDisplay', FakeTTD); + + const player = TestHelpers.makePlayer({}, tag); + + assert.strictEqual(fakeTTDSpy.callCount, 1, 'text track display was created'); + + Html5.TEST_VID = oldTestVid; + browser.stub_IS_FIREFOX(oldIsFirefox); + Component.registerComponent('TextTrackDisplay', oldTextTrackDisplay); + + player.dispose(); +}); + +QUnit.test('shows the default caption track first', function(assert) { + const player = TestHelpers.makePlayer(); + const track1 = { + kind: 'captions', + label: 'English', + language: 'en', + src: 'en.vtt', + default: true + }; + const track2 = { + kind: 'captions', + label: 'Spanish', + language: 'es', + src: 'es.vtt' + }; + + // Add the text tracks + const englishTrack = player.addRemoteTextTrack(track1, true).track; + const spanishTrack = player.addRemoteTextTrack(track2, true).track; + + // Make sure the ready handler runs + this.clock.tick(1); + + assert.ok(englishTrack.mode === 'showing', 'English track should be showing'); + assert.ok(spanishTrack.mode === 'disabled', 'Spanish track should not be showing'); + player.dispose(); +}); + +if (!Html5.supportsNativeTextTracks()) { + QUnit.test('text track display should attach screen orientation change event handler', function(assert) { + const oldScreen = window.screen; + const removeHandlerSpy = sinon.spy(); + let changeHandlerSpy; + let changeHandlerAttached; + + window.screen = { + orientation: { + addEventListener: (type, func) => { + changeHandlerAttached = true; + changeHandlerSpy = sinon.spy(); + }, + dispatchEvent: (type) => changeHandlerSpy(), + removeEventListener: removeHandlerSpy + } + }; + + const player = TestHelpers.makePlayer(); + + this.clock.tick(1); + + assert.true(changeHandlerAttached, 'screen orientation change event handler was not attached'); + assert.strictEqual(changeHandlerSpy.callCount, 0, 'screen orientation change event handler should not be called'); + + window.screen.orientation.dispatchEvent('change'); + + assert.strictEqual(changeHandlerSpy.callCount, 1, 'screen orientation change event handler was not called'); + + player.dispose(); + + assert.strictEqual( + removeHandlerSpy.callCount, + 1, + 'screen orientation change event handler was not removed during player dispose' + ); + window.screen = oldScreen; + }); + + QUnit.test('selectedlanguagechange is triggered by a track mode change', function(assert) { + const player = TestHelpers.makePlayer(); + const track1 = { + kind: 'captions', + label: 'English', + language: 'en', + src: 'en.vtt' + }; + const spy = sinon.spy(); + const selectedLanguageHandler = function(event) { + spy(); + }; + const englishTrack = player.addRemoteTextTrack(track1, true).track; + + player.textTracks().addEventListener('selectedlanguagechange', selectedLanguageHandler); + englishTrack.mode = 'showing'; + + assert.strictEqual(spy.callCount, 1, 'selectedlanguagechange event was fired'); + player.dispose(); + player.textTracks().removeEventListener('selectedlanguagechange', selectedLanguageHandler); + }); + + QUnit.test("if user-selected language is unavailable, don't pick a track to show", function(assert) { + // The video has no default language but has ‘English’ captions only + const player = TestHelpers.makePlayer(); + const track1 = { + kind: 'captions', + label: 'English', + language: 'en', + src: 'en.vtt' + }; + const captionsButton = player.controlBar.getChild('SubsCapsButton'); + + player.src({type: 'video/mp4', src: 'http://google.com'}); + // manualCleanUp = true by default + const englishTrack = player.addRemoteTextTrack(track1, true).track; + + // Force 'es' as user-selected track + player.cache_.selectedLanguage = { language: 'es', kind: 'captions' }; + + this.clock.tick(1); + player.play(); + + assert.ok(!captionsButton.hasClass('vjs-hidden'), 'The captions button is shown'); + assert.ok(englishTrack.mode === 'disabled', 'English track should be disabled'); + player.dispose(); + }); + + QUnit.test('the user-selected language takes priority over default language', function(assert) { + // The video has ‘English’ captions as default, but has ‘Spanish’ captions also + const player = TestHelpers.makePlayer(); + const track1 = { + kind: 'captions', + label: 'English', + language: 'en', + src: 'en.vtt', + default: true + }; + const track2 = { + kind: 'captions', + label: 'Spanish', + language: 'es', + src: 'es.vtt' + }; + + player.src({type: 'video/mp4', src: 'http://google.com'}); + // manualCleanUp = true by default + const englishTrack = player.addRemoteTextTrack(track1, true).track; + const spanishTrack = player.addRemoteTextTrack(track2, true).track; + + // Force 'es' as user-selected track + player.cache_.selectedLanguage = { enabled: true, language: 'es', kind: 'captions' }; + this.clock.tick(1); + + assert.ok(spanishTrack.mode === 'showing', 'Spanish captions should be shown'); + assert.ok(englishTrack.mode === 'disabled', 'English captions should be hidden'); + player.dispose(); + }); + + QUnit.test("don't select user language if it is an empty string", function(assert) { + const player = TestHelpers.makePlayer(); + const track1 = { + kind: 'captions', + label: 'English', + language: 'en', + src: 'en.vtt' + }; + const track2 = { + kind: 'captions', + label: 'Spanish', + language: 'es', + src: 'es.vtt' + }; + const track3 = { + kind: 'metadata', + label: 'segment-metadata' + }; + + player.src({type: 'video/mp4', src: 'http://google.com'}); + // manualCleanUp = true by default + const englishTrack = player.addRemoteTextTrack(track1, true).track; + const spanishTrack = player.addRemoteTextTrack(track2, true).track; + const metadataTrack = player.addRemoteTextTrack(track3, true).track; + + // Force empty string ('') as "user-selected" track + player.cache_.selectedLanguage = { enabled: true, language: '', kind: 'captions' }; + this.clock.tick(1); + + assert.equal(spanishTrack.mode, 'disabled', 'Spanish captions should be disabled'); + assert.equal(englishTrack.mode, 'disabled', 'English captions should be disabled'); + assert.notEqual(metadataTrack.mode, 'showing', 'Metadata track should not be showing'); + + // Force es as "user-selected" track + player.cache_.selectedLanguage = { enabled: true, language: 'es', kind: 'captions' }; + player.trigger('loadedmetadata'); + + assert.equal(spanishTrack.mode, 'showing', 'Spanish captions should be showing'); + assert.equal(englishTrack.mode, 'disabled', 'English captions should be disabled'); + assert.notEqual(metadataTrack.mode, 'showing', 'Metadata track should not be showing'); + + player.dispose(); + }); + + QUnit.test("matching both the selectedLanguage's language and kind takes priority over just matching the language", function(assert) { + const player = TestHelpers.makePlayer(); + const track1 = { + kind: 'captions', + label: 'English', + language: 'en', + src: 'en.vtt' + }; + const track2 = { + kind: 'subtitles', + label: 'English', + language: 'en', + src: 'en.vtt' + }; + + player.src({type: 'video/mp4', src: 'http://google.com'}); + // manualCleanUp = true by default + const captionTrack = player.addRemoteTextTrack(track1, true).track; + const subsTrack = player.addRemoteTextTrack(track2, true).track; + + // Force English captions as user-selected track + player.cache_.selectedLanguage = { enabled: true, language: 'en', kind: 'captions' }; + this.clock.tick(1); + + assert.ok(captionTrack.mode === 'showing', 'Captions track should be preselected'); + assert.ok(subsTrack.mode === 'disabled', 'Subtitles track should remain disabled'); + player.dispose(); + }); + + QUnit.test('the user-selected language is used for subsequent source changes', function(assert) { + // Start with two captions tracks: English and Spanish + const player = TestHelpers.makePlayer(); + const track1 = { + kind: 'captions', + label: 'English', + language: 'en', + src: 'en.vtt' + }; + const track2 = { + kind: 'captions', + label: 'Spanish', + language: 'es', + src: 'es.vtt' + }; + const tracks = player.tech_.remoteTextTracks(); + const captionsButton = player.controlBar.getChild('SubsCapsButton'); + let esCaptionMenuItem; + let enCaptionMenuItem; + + player.src({type: 'video/mp4', src: 'http://google.com'}); + // manualCleanUp = true by default + player.addRemoteTextTrack(track1, true); + player.addRemoteTextTrack(track2, true); + + // Keep track of menu items + esCaptionMenuItem = getMenuItemByLanguage(captionsButton.items, 'es'); + enCaptionMenuItem = getMenuItemByLanguage(captionsButton.items, 'en'); + + // The user chooses Spanish + player.play(); + esCaptionMenuItem.trigger('click'); + + // Track mode changes on user-selection + assert.ok( + esCaptionMenuItem.track.mode === 'showing', + 'Spanish should be showing after selection' + ); + assert.ok( + enCaptionMenuItem.track.mode === 'disabled', + 'English should be disabled after selecting Spanish' + ); + assert.deepEqual( + player.cache_.selectedLanguage, + { enabled: true, language: 'es', kind: 'captions' } + ); + + // Switch source and remove old tracks + player.tech_.src({type: 'video/mp4', src: 'http://example.com'}); + while (tracks.length > 0) { + player.removeRemoteTextTrack(tracks[0]); + } + // Add tracks for the new source + // change the kind of track to subtitles + track1.kind = 'subtitles'; + track2.kind = 'subtitles'; + const englishTrack = player.addRemoteTextTrack(track1, true).track; + const spanishTrack = player.addRemoteTextTrack(track2, true).track; + + // Make sure player ready handler runs + this.clock.tick(1); + + // Keep track of menu items + esCaptionMenuItem = getMenuItemByLanguage(captionsButton.items, 'es'); + enCaptionMenuItem = getMenuItemByLanguage(captionsButton.items, 'en'); + + // The user-selection should have persisted + assert.ok( + esCaptionMenuItem.track.mode === 'showing', + 'Spanish should remain showing' + ); + assert.ok( + enCaptionMenuItem.track.mode === 'disabled', + 'English should remain disabled' + ); + assert.deepEqual( + player.cache_.selectedLanguage, + { enabled: true, language: 'es', kind: 'captions' } + ); + + assert.ok(spanishTrack.mode === 'showing', 'Spanish track remains showing'); + assert.ok(englishTrack.mode === 'disabled', 'English track remains disabled'); + player.dispose(); + }); + + QUnit.test('the user-selected language is cleared on turning off captions', function(assert) { + const player = TestHelpers.makePlayer(); + const track1 = { + kind: 'captions', + label: 'English', + language: 'en', + src: 'en.vtt' + }; + const captionsButton = player.controlBar.getChild('SubsCapsButton'); + + player.src({type: 'video/mp4', src: 'http://google.com'}); + // manualCleanUp = true by default + const englishTrack = player.addRemoteTextTrack(track1, true).track; + // Keep track of menu items + const enCaptionMenuItem = getMenuItemByLanguage(captionsButton.items, 'en'); + // we know the position of the OffTextTrackMenuItem + const offMenuItem = captionsButton.items[1]; + + // Select English initially + player.play(); + enCaptionMenuItem.trigger('click'); + + assert.deepEqual( + player.cache_.selectedLanguage, + { enabled: true, language: 'en', kind: 'captions' }, 'English track is selected' + ); + assert.ok(englishTrack.mode === 'showing', 'English track should be showing'); + + // Select the off button + offMenuItem.trigger('click'); + + assert.deepEqual( + player.cache_.selectedLanguage, + { enabled: false }, 'selectedLanguage is cleared' + ); + assert.ok(englishTrack.mode === 'disabled', 'English track is disabled'); + player.dispose(); + }); + + QUnit.test('a color can be constructed from a three digit hex code', function(assert) { + const hex = '#f0e'; + + // f gets mapped to ff -> 255 in decimal, + // 0 gets mapped to 00 -> 0 in decimal, + // e gets mapped to ee -> 238 in decimal. + assert.equal(constructColor(hex, 1), 'rgba(255,0,238,1)'); + }); + + QUnit.test('a color can be constructed from a six digit hex code', function(assert) { + const hex = '#f604e2'; + + // f6 -> 246 in decimal, + // 04 -> 4 in decimal, + // e2 -> 226 in decimal. + assert.equal(constructColor(hex, 1), 'rgba(246,4,226,1)'); + }); + + QUnit.test('an invalid hex code will throw an error', function(assert) { + const hex = '#f'; + + assert.throws( + function() { + constructColor(hex, 1); + }, + new Error('Invalid color code provided, #f; must be formatted as e.g. #f0e or #f604e2.'), + 'colors must be valid hex codes.' + ); + }); + + const skipOnOldChrome = window.CSS.supports('inset-inline: 10px') ? 'test' : 'skip'; + + QUnit[skipOnOldChrome]('text track display should overlay a video', function(assert) { + const tag = document.createElement('video'); + + tag.width = 320; + tag.height = 180; + const player = TestHelpers.makePlayer({}, tag); + const textTrackDisplay = player.getChild('TextTrackDisplay'); + const textTrackDisplayStyle = textTrackDisplay.el().style; + + assert.ok(textTrackDisplayStyle.insetInline === '', 'text track display style insetInline equal to empty string'); + assert.ok(textTrackDisplayStyle.insetBlock === '', 'text track display style insetBlock equal to empty string'); + + // video aspect ratio equal to NaN + player.tech_.videoWidth = () => 0; + player.tech_.videoHeight = () => 0; + + assert.ok(textTrackDisplayStyle.insetInline === '', 'text track display style insetInline equal to empty string'); + assert.ok(textTrackDisplayStyle.insetBlock === '', 'text track display style insetBlock equal to empty string'); + + // video aspect ratio 2:1 + player.tech_.videoWidth = () => 100; + player.tech_.videoHeight = () => 50; + + textTrackDisplay.updateDisplayOverlay(); + + assert.ok(textTrackDisplayStyle.insetInline === '', 'text track display style insetInline equal to empty string'); + assert.ok(textTrackDisplayStyle.insetBlock === '10px', 'text track display style insetBlock equal to 10px'); + + // video aspect ratio 4:3 + player.tech_.videoWidth = () => 100; + player.tech_.videoHeight = () => 75; + + textTrackDisplay.updateDisplayOverlay(); + + assert.ok(textTrackDisplayStyle.insetInline === '40px', 'text track display style insetInline equal to 40px'); + assert.ok(textTrackDisplayStyle.insetBlock === '', 'text track display style insetBlock equal to empty string'); + + // video aspect ratio 16:9 + player.tech_.videoWidth = () => 320; + player.tech_.videoHeight = () => 180; + + textTrackDisplay.updateDisplayOverlay(); + + assert.ok(textTrackDisplayStyle.insetInline === '', 'text track display style insetInline equal to empty string'); + assert.ok(textTrackDisplayStyle.insetBlock === '', 'text track display style insetBlock equal to empty string'); + + player.dispose(); + }); + + QUnit.test('should use relative position for vjs-text-track-display element if environment does not support window.CSS', function(assert) { + const cssDescriptor = Object.getOwnPropertyDescriptor(window, 'CSS'); + + try { + // Set conditions for the use of the style modifications. Temporarily + // remove window.CSS to match the environment created by jsdom. + // https://github.com/jsdom/jsdom/issues/3991 + delete window.CSS; + + const player = TestHelpers.makePlayer(); + const track1 = { + kind: 'captions', + label: 'English', + language: 'en', + src: 'en.vtt', + default: true + }; + + // Add the text track + player.addRemoteTextTrack(track1, true); + + player.src({type: 'video/mp4', src: 'http://google.com'}); + player.play(); + + // as if metadata was loaded + player.textTrackDisplay.updateDisplayOverlay(); + + // Make sure the ready handler runs + this.clock.tick(1); + + const textTrack = window.document.querySelector('.vjs-text-track-display'); + + assert.ok(textTrack.style.position === 'relative', 'Style of position for vjs-text-track-display element should be relative'); + assert.ok(textTrack.style.top === 'unset', 'Style of position for vjs-text-track-display element should be unset'); + assert.ok(textTrack.style.bottom === '0px', 'Style of bottom for vjs-text-track-display element should be 0px'); + player.dispose(); + } finally { + // Restore window.CSS + Object.defineProperty(window, 'CSS', cssDescriptor); + } + }); + + QUnit.test('should use relative position for vjs-text-track-display element if browser does not support inset property', function(assert) { + // Set conditions for the use of the style modifications + window.CSS.supports = () => false; + browser.IS_SMART_TV = () => true; + + const player = TestHelpers.makePlayer(); + const track1 = { + kind: 'captions', + label: 'English', + language: 'en', + src: 'en.vtt', + default: true + }; + + // Add the text track + player.addRemoteTextTrack(track1, true); + + player.src({type: 'video/mp4', src: 'http://google.com'}); + player.play(); + + // as if metadata was loaded + player.textTrackDisplay.updateDisplayOverlay(); + + // Make sure the ready handler runs + this.clock.tick(1); + + const textTrack = window.document.querySelector('.vjs-text-track-display'); + + assert.ok(textTrack.style.position === 'relative', 'Style of position for vjs-text-track-display element should be relative'); + assert.ok(textTrack.style.top === 'unset', 'Style of position for vjs-text-track-display element should be unset'); + assert.ok(textTrack.style.bottom === '0px', 'Style of bottom for vjs-text-track-display element should be 0px'); + player.dispose(); + }); + + QUnit.test('track cue should use values of top, right, botton, left if browser does not support inset property', function(assert) { + // Set conditions for the use of the style modifications + window.CSS.supports = () => false; + browser.IS_SMART_TV = () => true; + + const player = TestHelpers.makePlayer(); + const track1 = { + kind: 'captions', + label: 'English', + language: 'en', + src: 'en.vtt', + default: true + }; + + // Add the text track + player.addRemoteTextTrack(track1, true); + + player.src({type: 'video/mp4', src: 'http://google.com'}); + player.play(); + + // mock caption + const textTrackDisplay = window.document.querySelector('.vjs-text-track-display').firstChild; + const node = document.createElement('div'); + + node.classList.add('vjs-text-track-cue'); + node.style.inset = '1px 2px 3px'; + const textnode = document.createTextNode('Sample text'); + + node.appendChild(textnode); + textTrackDisplay.appendChild(node); + + // avoid captions clear + player.textTrackDisplay.clearDisplay = () => ''; + + // as if metadata was loaded + player.textTrackDisplay.updateDisplay(); + + assert.ok(player.textTrackDisplay.el_.querySelector('.vjs-text-track-cue').style.left === 'unset', 'Style of left for vjs-text-track-cue element should be unset'); + assert.ok(player.textTrackDisplay.el_.querySelector('.vjs-text-track-cue').style.top === '1px', 'Style of top for vjs-text-track-cue element should be 1px'); + assert.ok(player.textTrackDisplay.el_.querySelector('.vjs-text-track-cue').style.right === '2px', 'Style of right for vjs-text-track-cue element should be 2px'); + player.dispose(); + }); +} diff --git a/js/videojs/test/unit/tracks/text-track-list-converter.test.js b/js/videojs/test/unit/tracks/text-track-list-converter.test.js new file mode 100644 index 0000000..33501e0 --- /dev/null +++ b/js/videojs/test/unit/tracks/text-track-list-converter.test.js @@ -0,0 +1,336 @@ +/* eslint-env qunit */ +import c from '../../../src/js/tracks/text-track-list-converter.js'; +import TextTrack from '../../../src/js/tracks/text-track.js'; +import TextTrackList from '../../../src/js/tracks/text-track-list.js'; +import Html5 from '../../../src/js/tech/html5.js'; +import document from 'global/document'; + +QUnit.module('Text Track List Converter', {}); + +const clean = (item) => { + delete item.id; + delete item.inBandMetadataTrackDispatchType; + delete item.cues; +}; + +const cleanup = (item) => { + if (Array.isArray(item)) { + item.forEach(clean); + } else { + clean(item); + } + + return item; +}; + +if (Html5.supportsNativeTextTracks()) { + QUnit.test('trackToJson_ produces correct representation for native track object', function(assert) { + const track = document.createElement('track'); + + track.src = 'example.com/english.vtt'; + track.kind = 'captions'; + track.srclang = 'en'; + track.label = 'English'; + + assert.deepEqual(cleanup(c.trackToJson(track.track)), { + kind: 'captions', + label: 'English', + language: 'en', + mode: 'disabled' + }, 'the json output is same'); + }); + + QUnit.test('textTracksToJson produces good json output', function(assert) { + const emulatedTrack = new TextTrack({ + kind: 'captions', + label: 'English', + language: 'en', + tech: { + crossOrigin() { + return null; + }, + on() {}, + one() {} + } + }); + + const nativeTrack = document.createElement('track'); + + nativeTrack.kind = 'captions'; + nativeTrack.srclang = 'es'; + nativeTrack.label = 'Spanish'; + + const tt = new TextTrackList(); + + tt.addTrack(nativeTrack.track); + tt.addTrack(emulatedTrack); + + const tech = { + $$() { + return [nativeTrack]; + }, + + el() { + return { + querySelectorAll() { + return [nativeTrack]; + } + }; + }, + on() {}, + crossOrigin() { + return null; + }, + textTracks() { + return tt; + } + }; + + assert.deepEqual(cleanup(c.textTracksToJson(tech)), [{ + kind: 'captions', + label: 'Spanish', + language: 'es', + mode: 'disabled' + }, { + kind: 'captions', + label: 'English', + language: 'en', + mode: 'disabled' + }], 'the output is correct'); + + tt.removeTrack(nativeTrack.track); + tt.removeTrack(emulatedTrack); + }); + + QUnit.test('jsonToTextTracks calls addRemoteTextTrack on the tech with mixed tracks', function(assert) { + const emulatedTrack = new TextTrack({ + kind: 'captions', + label: 'English', + language: 'en', + src: 'example.com/english.vtt', + tech: { + crossOrigin() { + return null; + }, + on() {}, + one() {} + } + }); + + const nativeTrack = document.createElement('track'); + + nativeTrack.src = 'example.com/spanish.vtt'; + nativeTrack.kind = 'captions'; + nativeTrack.srclang = 'es'; + nativeTrack.label = 'Spanish'; + + const tt = new TextTrackList(); + + tt.addTrack(nativeTrack.track); + tt.addTrack(emulatedTrack); + + let addRemotes = 0; + const tech = { + $$() { + return [nativeTrack]; + }, + + el() { + return { + querySelectorAll() { + return [nativeTrack]; + } + }; + }, + crossOrigin() { + return null; + }, + on() {}, + textTracks() { + return tt; + }, + addRemoteTextTrack() { + addRemotes++; + return { + track: {} + }; + } + }; + + c.jsonToTextTracks(cleanup(c.textTracksToJson(tech)), tech); + + assert.equal(addRemotes, 2, 'we added two text tracks'); + + tt.removeTrack(nativeTrack.track); + tt.removeTrack(emulatedTrack); + }); +} + +QUnit.test('trackToJson_ produces correct representation for emulated track object', function(assert) { + const track = new TextTrack({ + kind: 'captions', + label: 'English', + language: 'en', + src: 'example.com/english.vtt', + tech: { + crossOrigin() { + return null; + }, + on() {}, + one() {} + } + }); + + assert.deepEqual(cleanup(c.trackToJson(track)), { + src: 'example.com/english.vtt', + kind: 'captions', + label: 'English', + language: 'en', + mode: 'disabled' + }, 'the json output is same'); +}); + +QUnit.test('textTracksToJson produces good json output for emulated only', function(assert) { + const emulatedTrack = new TextTrack({ + kind: 'captions', + label: 'English', + language: 'en', + src: 'example.com/english.vtt', + tech: { + crossOrigin() { + return null; + }, + on() {}, + one() {} + } + }); + + const anotherTrack = new TextTrack({ + src: 'example.com/spanish.vtt', + kind: 'captions', + srclang: 'es', + label: 'Spanish', + tech: { + crossOrigin() { + return null; + }, + on() {}, + one() {} + } + }); + + const tt = new TextTrackList(); + + tt.addTrack(anotherTrack); + tt.addTrack(emulatedTrack); + + const tech = { + $$() { + return []; + }, + + el() { + return { + querySelectorAll() { + return []; + } + }; + }, + crossOrigin() { + return null; + }, + on() {}, + one() {}, + textTracks() { + return tt; + } + }; + + assert.deepEqual(cleanup(c.textTracksToJson(tech)), [{ + src: 'example.com/spanish.vtt', + kind: 'captions', + label: 'Spanish', + language: 'es', + mode: 'disabled' + }, { + src: 'example.com/english.vtt', + kind: 'captions', + label: 'English', + language: 'en', + mode: 'disabled' + }], 'the output is correct'); + + tt.removeTrack(anotherTrack); + tt.removeTrack(emulatedTrack); +}); + +QUnit.test('jsonToTextTracks calls addRemoteTextTrack on the tech with emulated tracks only', function(assert) { + const emulatedTrack = new TextTrack({ + kind: 'captions', + label: 'English', + language: 'en', + src: 'example.com/english.vtt', + tech: { + crossOrigin() { + return null; + }, + on() {}, + one() {} + } + }); + + const anotherTrack = new TextTrack({ + src: 'example.com/spanish.vtt', + kind: 'captions', + srclang: 'es', + label: 'Spanish', + tech: { + crossOrigin() { + return null; + }, + on() {}, + one() {} + } + }); + + const tt = new TextTrackList(); + + tt.addTrack(anotherTrack); + tt.addTrack(emulatedTrack); + + let addRemotes = 0; + const tech = { + $$() { + return []; + }, + + el() { + return { + querySelectorAll() { + return []; + } + }; + }, + crossOrigin() { + return null; + }, + on() {}, + one() {}, + textTracks() { + return tt; + }, + addRemoteTextTrack() { + addRemotes++; + return { + track: {} + }; + } + }; + + c.jsonToTextTracks(cleanup(c.textTracksToJson(tech)), tech); + + assert.equal(addRemotes, 2, 'we added two text tracks'); + + tt.removeTrack(anotherTrack); + tt.removeTrack(emulatedTrack); +}); diff --git a/js/videojs/test/unit/tracks/text-track-list.test.js b/js/videojs/test/unit/tracks/text-track-list.test.js new file mode 100644 index 0000000..47c5f68 --- /dev/null +++ b/js/videojs/test/unit/tracks/text-track-list.test.js @@ -0,0 +1,105 @@ +/* eslint-env qunit */ +import TextTrackList from '../../../src/js/tracks/text-track-list.js'; +import TextTrack from '../../../src/js/tracks/text-track.js'; +import EventTarget from '../../../src/js/event-target.js'; +import TechFaker from '../tech/tech-faker'; +import sinon from 'sinon'; + +QUnit.module('Text Track List', { + beforeEach() { + this.clock = sinon.useFakeTimers(); + }, + afterEach() { + this.clock.restore(); + } +}); + +QUnit.test('trigger "change" event when "modechange" is fired on a track', function(assert) { + const tt = new EventTarget(); + const ttl = new TextTrackList([tt]); + let changes = 0; + const changeHandler = function() { + changes++; + }; + + ttl.on('change', changeHandler); + tt.trigger('modechange'); + this.clock.tick(1); + + ttl.off('change', changeHandler); + ttl.onchange = changeHandler; + + tt.trigger('modechange'); + this.clock.tick(1); + assert.equal(changes, 2, 'two change events should have fired'); + + ttl.removeTrack(tt); +}); + +QUnit.test('trigger "change" event when mode changes on a TextTrack', function(assert) { + const tech = new TechFaker(); + const tt = new TextTrack({tech}); + const ttl = new TextTrackList([tt]); + let changes = 0; + const changeHandler = function() { + changes++; + }; + + ttl.on('change', changeHandler); + tt.mode = 'showing'; + this.clock.tick(1); + + ttl.off('change', changeHandler); + ttl.onchange = changeHandler; + + tt.mode = 'hidden'; + tt.mode = 'disabled'; + this.clock.tick(1); + + assert.equal(changes, 2, 'two change events should have fired'); + ttl.removeTrack(tt); + tech.dispose(); +}); + +QUnit.test('toJSON', function(assert) { + const tech = new TechFaker(); + const ttl = new TextTrackList([]); + + let textTrackListJSON = ttl.toJSON(); + + assert.ok(textTrackListJSON.length === 0, 'an empty array is returned when the list is empty'); + + const tt1 = new TextTrack({tech}); + + ttl.addTrack(tt1); + textTrackListJSON = ttl.toJSON(); + + assert.equal(textTrackListJSON[0].id, tt1.id, 'text track in array of JSON should match the original track'); + assert.notOk(textTrackListJSON[0].tech_, 'tech_ should not exist on the text track value in the JSON list'); + + const tt2 = new TextTrack({tech}); + const tt3 = new TextTrack({tech}); + + ttl.addTrack(tt2); + ttl.addTrack(tt3); + textTrackListJSON = ttl.toJSON(); + + assert.equal(textTrackListJSON[1].id, tt2.id, 'text track in second spot of array should match the original track'); + assert.equal(textTrackListJSON[2].id, tt3.id, 'text track in third spot of array should match the original track'); +}); + +QUnit.test('serialize', function(assert) { + const tech = new TechFaker(); + const tt1 = new TextTrack({tech}); + const tt2 = new TextTrack({tech}); + const tt3 = new TextTrack({tech}); + const ttl = new TextTrackList([tt1, tt2, tt3]); + + const serializedTrackList = JSON.stringify(ttl); + + assert.notOk(serializedTrackList.includes('"tech_":'), 'tech_ does not exist in the serialized data'); + + assert.ok(serializedTrackList.includes(`"id":"${tt1.id}"`), 'serialzed track is found for text track 1'); + assert.ok(serializedTrackList.includes(`"id":"${tt2.id}"`), 'serialzed track is found for text track 2'); + assert.ok(serializedTrackList.includes(`"id":"${tt3.id}"`), 'serialzed track is found for text track 3'); +}); diff --git a/js/videojs/test/unit/tracks/text-track-select.test.js b/js/videojs/test/unit/tracks/text-track-select.test.js new file mode 100644 index 0000000..059dab6 --- /dev/null +++ b/js/videojs/test/unit/tracks/text-track-select.test.js @@ -0,0 +1,43 @@ +/* eslint-env qunit */ +import TestHelpers from '../test-helpers.js'; + +const tracks = [{ + kind: 'captions', + label: 'test' +}]; + +QUnit.module('Text Track Select'); + +QUnit.test('should associate with <select>s with <options>s', function(assert) { + const player = TestHelpers.makePlayer({ + tracks + }); + + const select = player.textTrackSettings.el_.querySelector('select'); + const option = select.querySelector('option'); + const selectAriaLabelledby = select.getAttribute('aria-labelledby'); + const optionAriaLabelledby = option.getAttribute('aria-labelledby'); + + assert.ok( + optionAriaLabelledby.includes(selectAriaLabelledby), + "select property 'aria-labelledby' is included in its option's property 'aria-labelledby'" + ); +}); + +QUnit.test('aria-labelledby values must be valid and unique', function(assert) { + const player = TestHelpers.makePlayer({ + tracks + }); + const albs = player.$$('.vjs-text-track-settings select[aria-labelledby]'); + + albs.forEach(el => { + const ids = el.getAttribute('aria-labelledby').split(' '); + const invalidIds = ids.find(id => { + return !(player.$(`#${id}`)); + }); + + assert.notOk(invalidIds, `${el.id} has valid aria-labelledby ids`); + + assert.ok((new Set(ids)).size === ids.length, `${el.id} does not contain duplicate ids`); + }); +}); diff --git a/js/videojs/test/unit/tracks/text-track-settings.test.js b/js/videojs/test/unit/tracks/text-track-settings.test.js new file mode 100644 index 0000000..1eec3e6 --- /dev/null +++ b/js/videojs/test/unit/tracks/text-track-settings.test.js @@ -0,0 +1,416 @@ +/* eslint-env qunit */ +import TextTrackSettings from '../../../src/js/tracks/text-track-settings.js'; +import TestHelpers from '../test-helpers.js'; +import * as Events from '../../../src/js/utils/events.js'; +import sinon from 'sinon'; +import window from 'global/window'; +import Component from '../../../src/js/component.js'; +import videojs from '../../../src/js/video.js'; + +const tracks = [{ + kind: 'captions', + label: 'test' +}]; + +const defaultSettings = { + backgroundColor: '#000', + backgroundOpacity: '1', + color: '#FFF', + fontFamily: 'proportionalSansSerif', + textOpacity: '1', + windowColor: '#000', + windowOpacity: '0' +}; + +QUnit.module('Text Track Settings', { + beforeEach() { + window.localStorage.clear(); + this.oldComponentFocus = Component.prototype.focus; + this.oldComponentBlur = Component.prototype.blur; + Component.prototype.focus = function() {}; + Component.prototype.blur = function() {}; + }, + afterEach() { + Component.prototype.focus = this.oldComponentFocus; + Component.prototype.blur = this.oldComponentBlur; + } +}); + +QUnit.test('should update settings', function(assert) { + const player = TestHelpers.makePlayer({ + tracks, + persistTextTrackSettings: true + }); + + const newSettings = { + backgroundOpacity: '0.5', + textOpacity: '0.5', + windowOpacity: '0.5', + edgeStyle: 'raised', + fontFamily: 'monospaceSerif', + color: '#F00', + backgroundColor: '#FFF', + windowColor: '#FFF', + fontPercent: 1.25 + }; + + player.textTrackSettings.setValues(newSettings); + + assert.deepEqual( + player.textTrackSettings.getValues(), + newSettings, + 'values are updated' + ); + + assert.equal( + player.$('.vjs-text-color > select').selectedIndex, + 2, + 'text-color is set to new value' + ); + + assert.equal( + player.$('.vjs-bg-color > select').selectedIndex, + 1, + 'bg-color is set to new value' + ); + + assert.equal( + player.$('.vjs-window-color > select').selectedIndex, + 1, + 'window-color is set to new value' + ); + + assert.equal( + player.$('.vjs-text-opacity > select').selectedIndex, + 1, + 'text-opacity is set to new value' + ); + + assert.equal( + player.$('.vjs-bg-opacity > select').selectedIndex, + 1, + 'bg-opacity is set to new value' + ); + + assert.equal( + player.$('.vjs-window-opacity > select').selectedIndex, + 1, + 'window-opacity is set to new value' + ); + + assert.equal( + player.$('.vjs-edge-style select').selectedIndex, + 1, + 'edge-style is set to new value' + ); + + assert.equal( + player.$('.vjs-font-family select').selectedIndex, + 3, + 'font-family is set to new value' + ); + + assert.equal( + player.$('.vjs-font-percent select').selectedIndex, + 3, + 'font-percent is set to new value' + ); + + Events.trigger(player.$('.vjs-done-button'), 'click'); + + assert.deepEqual( + JSON.parse(window.localStorage.getItem('vjs-text-track-settings')), + newSettings, + 'values are saved' + ); + + player.dispose(); +}); + +QUnit.test('should restore default settings', function(assert) { + const player = TestHelpers.makePlayer({ + tracks, + persistTextTrackSettings: true + }); + + player.$('.vjs-text-color > select').selectedIndex = 1; + player.$('.vjs-bg-color > select').selectedIndex = 1; + player.$('.vjs-window-color > select').selectedIndex = 1; + player.$('.vjs-text-opacity > select').selectedIndex = 1; + player.$('.vjs-bg-opacity > select').selectedIndex = 1; + player.$('.vjs-window-opacity > select').selectedIndex = 1; + player.$('.vjs-edge-style select').selectedIndex = 1; + player.$('.vjs-font-family select').selectedIndex = 1; + player.$('.vjs-font-percent select').selectedIndex = 3; + + Events.trigger(player.$('.vjs-done-button'), 'click'); + Events.trigger(player.$('.vjs-default-button'), 'click'); + Events.trigger(player.$('.vjs-done-button'), 'click'); + + assert.deepEqual( + player.textTrackSettings.getValues(), + defaultSettings, + 'values are defaulted' + ); + // TODO: + // MikeA: need to figure out how to modify saveSettings + // to factor in defaults are no longer null + // assert.deepEqual(window.localStorage.getItem('vjs-text-track-settings'), + // defaultSettings, + // 'values are saved'); + + assert.equal( + player.$('.vjs-text-color > select').selectedIndex, + 0, + 'text-color is set to default value' + ); + + assert.equal( + player.$('.vjs-bg-color > select').selectedIndex, + 0, + 'bg-color is set to default value' + ); + + assert.equal( + player.$('.vjs-window-color > select').selectedIndex, + 0, + 'window-color is set to default value' + ); + + assert.equal( + player.$('.vjs-text-opacity > select').selectedIndex, + 0, + 'text-opacity is set to default value' + ); + + assert.equal( + player.$('.vjs-bg-opacity > select').selectedIndex, + 0, + 'bg-opacity is set to default value' + ); + + assert.equal( + player.$('.vjs-window-opacity > select').selectedIndex, + 0, + 'window-opacity is set to default value' + ); + + assert.equal( + player.$('.vjs-edge-style select').selectedIndex, + 0, + 'edge-style is set to default value' + ); + + assert.equal( + player.$('.vjs-font-family select').selectedIndex, + 0, + 'font-family is set to default value' + ); + + assert.equal( + player.$('.vjs-font-percent select').selectedIndex, + 2, + 'font-percent is set to default value' + ); + + player.dispose(); +}); + +QUnit.test('should open on click', function(assert) { + const clock = sinon.useFakeTimers(); + const player = TestHelpers.makePlayer({ + tracks + }); + + clock.tick(1); + + Events.trigger(player.$('.vjs-texttrack-settings'), 'click'); + assert.ok(!player.textTrackSettings.hasClass('vjs-hidden'), 'settings open'); + + player.dispose(); + clock.restore(); +}); + +QUnit.test('should close on done click', function(assert) { + const clock = sinon.useFakeTimers(); + const player = TestHelpers.makePlayer({ + tracks + }); + + clock.tick(1); + + Events.trigger(player.$('.vjs-texttrack-settings'), 'click'); + Events.trigger(player.$('.vjs-done-button'), 'click'); + assert.ok(player.textTrackSettings.hasClass('vjs-hidden'), 'settings closed'); + + player.dispose(); + clock.restore(); +}); + +QUnit.test('if persist option is set, restore settings on init', function(assert) { + const oldRestoreSettings = TextTrackSettings.prototype.restoreSettings; + let restore = 0; + + TextTrackSettings.prototype.restoreSettings = function() { + restore++; + }; + + const player = TestHelpers.makePlayer({ + tracks, + persistTextTrackSettings: true + }); + + assert.equal(restore, 1, 'restore was called'); + + TextTrackSettings.prototype.restoreSettings = oldRestoreSettings; + + player.dispose(); +}); + +QUnit.test('if persist option is set, save settings when "done"', function(assert) { + const player = TestHelpers.makePlayer({ + tracks, + persistTextTrackSettings: true + }); + + const oldSaveSettings = TextTrackSettings.prototype.saveSettings; + let save = 0; + + TextTrackSettings.prototype.saveSettings = function() { + save++; + }; + + Events.trigger(player.$('.vjs-done-button'), 'click'); + + assert.equal(save, 1, 'save was called'); + + TextTrackSettings.prototype.saveSettings = oldSaveSettings; + + player.dispose(); +}); + +QUnit.test('do not try to restore or save settings if persist option is not set', function(assert) { + const oldRestoreSettings = TextTrackSettings.prototype.restoreSettings; + const oldSaveSettings = TextTrackSettings.prototype.saveSettings; + let save = 0; + let restore = 0; + + TextTrackSettings.prototype.restoreSettings = function() { + restore++; + }; + TextTrackSettings.prototype.saveSettings = function() { + save++; + }; + + const player = TestHelpers.makePlayer({ + tracks, + persistTextTrackSettings: false + }); + + assert.equal(restore, 0, 'restore was not called'); + + Events.trigger(player.$('.vjs-done-button'), 'click'); + + // saveSettings is called but does nothing + assert.equal(save, 1, 'save was not called'); + + TextTrackSettings.prototype.saveSettings = oldSaveSettings; + TextTrackSettings.prototype.restoreSettings = oldRestoreSettings; + + player.dispose(); +}); + +QUnit.test('should restore saved settings', function(assert) { + const newSettings = { + backgroundOpacity: '0.5', + textOpacity: '0.5', + windowOpacity: '0.5', + edgeStyle: 'raised', + fontFamily: 'monospaceSerif', + color: '#F00', + backgroundColor: '#FFF', + windowColor: '#FFF', + fontPercent: 1.25 + }; + + window.localStorage.setItem('vjs-text-track-settings', JSON.stringify(newSettings)); + + const player = TestHelpers.makePlayer({ + tracks, + persistTextTrackSettings: true + }); + + assert.deepEqual(player.textTrackSettings.getValues(), newSettings); + + player.dispose(); +}); + +QUnit.test('should not restore saved settings', function(assert) { + const newSettings = { + backgroundOpacity: '0.5', + textOpacity: '0.5', + windowOpacity: '0.5', + edgeStyle: 'raised', + fontFamily: 'monospaceSerif', + color: '#F00', + backgroundColor: '#FFF', + windowColor: '#FFF', + fontPercent: 1.25 + }; + + window.localStorage.setItem('vjs-text-track-settings', JSON.stringify(newSettings)); + + const player = TestHelpers.makePlayer({ + tracks, + persistTextTrackSettings: false + }); + + assert.deepEqual(player.textTrackSettings.getValues(), defaultSettings); + + player.dispose(); +}); + +QUnit.test('should update on languagechange', function(assert) { + const player = TestHelpers.makePlayer({ + tracks + }); + + videojs.addLanguage('test', { + 'Font Size': 'FONTSIZE', + 'Color': 'COLOR', + 'White': 'WHITE' + }); + player.language('test'); + + assert.equal(player.$('.vjs-font-percent legend').textContent, 'FONTSIZE', 'settings dialog updates on languagechange'); + assert.equal(player.$('.vjs-text-color label').textContent, 'COLOR', 'settings dialog label updates on languagechange'); + assert.equal(player.$('.vjs-text-color select option').textContent, 'WHITE', 'settings dialog select updates on languagechange'); + + player.dispose(); +}); + +QUnit.test('should associate <label>s with <select>s', function(assert) { + const player = TestHelpers.makePlayer({ + tracks + }); + + const firstLabelFor = player.textTrackSettings.el_.querySelector('label').getAttribute('for'); + + assert.ok( + videojs.dom.isEl(player.textTrackSettings.el_.querySelector(`#${firstLabelFor}`)), + 'label has a `for` attribute matching an `id`' + ); + +}); + +QUnit.test('should not duplicate ids', function(assert) { + const player = TestHelpers.makePlayer({ + tracks + }); + + const elements = [...player.el().querySelectorAll('[id]')]; + const ids = elements.map(el => el.id); + const duplicates = elements.filter(el => ids.filter(id => id === el.id).length > 1); + + assert.strictEqual(duplicates.length, 0, 'there should be no duplicate ids'); +}); diff --git a/js/videojs/test/unit/tracks/text-track.test.js b/js/videojs/test/unit/tracks/text-track.test.js new file mode 100644 index 0000000..b96122a --- /dev/null +++ b/js/videojs/test/unit/tracks/text-track.test.js @@ -0,0 +1,806 @@ +/* eslint-env qunit */ +import window from 'global/window'; +import EventTarget from '../../../src/js/event-target.js'; +import TrackBaseline from './track-baseline'; +import TechFaker from '../tech/tech-faker'; +import TextTrack from '../../../src/js/tracks/text-track.js'; +import TestHelpers from '../test-helpers.js'; +import sinon from 'sinon'; +import log from '../../../src/js/utils/log.js'; +import XHR from '@videojs/xhr'; + +QUnit.module('Text Track', { + beforeEach() { + this.tech = new TechFaker(); + this.oldXMLHttpRequest = XHR.XMLHttpRequest; + this.oldXDomainRequest = XHR.XDomainRequest; + this.xhr = sinon.useFakeXMLHttpRequest(); + XHR.XMLHttpRequest = this.xhr; + XHR.XDomainRequest = this.xhr; + }, + afterEach() { + this.tech.dispose(); + this.tech = null; + XHR.XMLHttpRequest = this.oldXMLHttpRequest; + XHR.XDomainRequest = this.oldXDomainRequest; + this.xhr.restore(); + } +}); + +// do baseline track testing +TrackBaseline(TextTrack, { + id: '1', + kind: 'subtitles', + mode: 'disabled', + label: 'English', + language: 'en' + // tech is added in baseline + // tech: new TechFaker() +}); + +QUnit.test('requires a tech', function(assert) { + const error = new Error('A tech was not provided.'); + + assert.throws(() => new TextTrack({}), error, 'a tech is required'); + assert.throws(() => new TextTrack({tech: null}), error, 'a tech is required'); +}); + +QUnit.test('can create a TextTrack with a mode property', function(assert) { + const mode = 'disabled'; + const tt = new TextTrack({ + mode, + tech: this.tech + }); + + assert.equal(tt.mode, mode, 'we have a mode'); +}); + +QUnit.test('defaults when items not provided', function(assert) { + const tt = new TextTrack({ + tech: this.tech + }); + + assert.equal(tt.kind, 'subtitles', 'kind defaulted to subtitles'); + assert.equal(tt.mode, 'disabled', 'mode defaulted to disabled'); + assert.equal(tt.label, '', 'label defaults to empty string'); + assert.equal(tt.language, '', 'language defaults to empty string'); +}); + +QUnit.test('kind can only be one of several options, defaults to subtitles', function(assert) { + let tt = new TextTrack({ + tech: this.tech, + kind: 'foo' + }); + + assert.equal(tt.kind, 'subtitles', 'the kind is set to subtitles, not foo'); + assert.notEqual(tt.kind, 'foo', 'the kind is set to subtitles, not foo'); + + tt = new TextTrack({ + tech: this.tech, + kind: 'subtitles' + }); + + assert.equal(tt.kind, 'subtitles', 'the kind is set to subtitles'); + + tt = new TextTrack({ + tech: this.tech, + kind: 'captions' + }); + + assert.equal(tt.kind, 'captions', 'the kind is set to captions'); + + tt = new TextTrack({ + tech: this.tech, + kind: 'descriptions' + }); + + assert.equal(tt.kind, 'descriptions', 'the kind is set to descriptions'); + + tt = new TextTrack({ + tech: this.tech, + kind: 'chapters' + }); + + assert.equal(tt.kind, 'chapters', 'the kind is set to chapters'); + + tt = new TextTrack({ + tech: this.tech, + kind: 'metadata' + }); + + assert.equal(tt.kind, 'metadata', 'the kind is set to metadata'); +}); + +QUnit.test('mode can only be one of several options, defaults to disabled', function(assert) { + let tt = new TextTrack({ + tech: this.tech, + mode: 'foo' + }); + + assert.equal(tt.mode, 'disabled', 'the mode is set to disabled, not foo'); + assert.notEqual(tt.mode, 'foo', 'the mode is set to disabld, not foo'); + + tt = new TextTrack({ + tech: this.tech, + mode: 'disabled' + }); + + assert.equal(tt.mode, 'disabled', 'the mode is set to disabled'); + + tt = new TextTrack({ + tech: this.tech, + mode: 'hidden' + }); + + assert.equal(tt.mode, 'hidden', 'the mode is set to hidden'); + + tt = new TextTrack({ + tech: this.tech, + mode: 'showing' + }); + + assert.equal(tt.mode, 'showing', 'the mode is set to showing'); +}); + +QUnit.test('cue and activeCues are read only', function(assert) { + const mode = 'disabled'; + const tt = new TextTrack({ + mode, + tech: this.tech + }); + + tt.cues = 'foo'; + tt.activeCues = 'bar'; + + assert.notEqual(tt.cues, 'foo', 'cues is still original value'); + assert.notEqual(tt.activeCues, 'bar', 'activeCues is still original value'); +}); + +QUnit.test('mode can only be set to a few options', function(assert) { + const tt = new TextTrack({ + tech: this.tech + }); + + tt.mode = 'foo'; + + assert.notEqual(tt.mode, 'foo', 'the mode is still the old value, disabled'); + assert.equal(tt.mode, 'disabled', 'still on the default mode, disabled'); + + tt.mode = 'hidden'; + assert.equal(tt.mode, 'hidden', 'mode set to hidden'); + + tt.mode = 'bar'; + assert.notEqual(tt.mode, 'bar', 'the mode is still the old value, hidden'); + assert.equal(tt.mode, 'hidden', 'still on the previous mode, hidden'); + + tt.mode = 'showing'; + assert.equal(tt.mode, 'showing', 'mode set to showing'); + + tt.mode = 'baz'; + assert.notEqual(tt.mode, 'baz', 'the mode is still the old value, showing'); + assert.equal(tt.mode, 'showing', 'still on the previous mode, showing'); +}); + +QUnit.test('cues and activeCues return a TextTrackCueList', function(assert) { + const tt = new TextTrack({ + tech: this.tech + }); + + assert.ok(tt.cues.getCueById, 'cues are a TextTrackCueList'); + assert.ok(tt.activeCues.getCueById, 'activeCues are a TextTrackCueList'); +}); + +QUnit.test('cues can be added and removed from a TextTrack', function(assert) { + const tt = new TextTrack({ + tech: this.tech + }); + const cues = tt.cues; + + assert.equal(cues.length, 0, 'start with zero cues'); + + tt.addCue({id: '1'}); + + assert.equal(cues.length, 1, 'we have one cue'); + + tt.removeCue(cues.getCueById('1')); + + assert.equal(cues.length, 0, 'we have removed our one cue'); + + tt.addCue({id: '1'}); + tt.addCue({id: '2'}); + tt.addCue({id: '3'}); + + assert.equal(cues.length, 3, 'we now have 3 cues'); +}); + +QUnit.test('original cue can be used to remove cue from cues list', function(assert) { + const tt = new TextTrack({ + tech: this.tech + }); + const Cue = window.VTTCue || + window.vttjs && window.vttjs.VTTCue || + window.TextTrackCue; + + const cue1 = new Cue(0, 1, 'some-cue'); + + assert.equal(tt.cues.length, 0, 'start with zero cues'); + tt.addCue(cue1); + assert.equal(tt.cues.length, 1, 'we have one cue'); + + tt.removeCue(cue1); + assert.equal(tt.cues.length, 0, 'we have removed cue1'); +}); + +QUnit.test('non-VTTCue can be used to remove cue from cues list', function(assert) { + const tt = new TextTrack({ + tech: this.tech + }); + + const cue1 = { id: 1, text: 'test' }; + + assert.equal(tt.cues.length, 0, 'start with zero cues'); + tt.addCue(cue1); + assert.equal(tt.cues.length, 1, 'we have one cue'); + + tt.removeCue(cue1); + assert.equal(tt.cues.length, 0, 'we have removed cue1'); +}); + +QUnit.test('can only remove one cue at a time', function(assert) { + const tt = new TextTrack({ + tech: this.tech + }); + const Cue = window.VTTCue || + window.vttjs && window.vttjs.VTTCue || + window.TextTrackCue; + + const cue1 = new Cue(0, 1, 'some-cue'); + + assert.equal(tt.cues.length, 0, 'start with zero cues'); + tt.addCue(cue1); + tt.addCue(cue1); + assert.equal(tt.cues.length, 2, 'we have two cues'); + + tt.removeCue(cue1); + assert.equal(tt.cues.length, 1, 'we have removed one instance of cue1'); + + tt.removeCue(cue1); + assert.equal(tt.cues.length, 0, 'we have removed the other instance of cue1'); +}); + +QUnit.test('does not include past cues in activeCues', function(assert) { + // Testing for the absence of a previous behaviour, which considered cues with equal + // start and end times as active 0.5s after ending + const player = TestHelpers.makePlayer(); + const tt = new TextTrack({ + tech: player.tech_, + mode: 'showing' + }); + const expectedCue = { + id: '2', + startTime: 2.555, + endTime: 3 + }; + + player.tech_.currentTime = function() { + return 2.556; + }; + + tt.addCue({ + id: '1', + startTime: 1, + endTime: 2.555 + }); + tt.addCue({ + id: '2', + startTime: 2.555, + endTime: 2.555 + }); + // start 2.55 + tt.addCue(expectedCue); + + player.tech_.trigger('playing'); + + assert.equal(tt.activeCues_.length, 1, 'only one cue is present'); + assert.equal(tt.activeCues_[0].originalCue_, expectedCue, 'correct active cue is present'); +}); + +QUnit.test('does not fire cuechange before Tech is ready', function(assert) { + const done = assert.async(); + const clock = sinon.useFakeTimers(); + const player = TestHelpers.makePlayer({techfaker: {autoReady: false}}); + let changes = 0; + const tt = new TextTrack({ + tech: player.tech_, + mode: 'showing' + }); + const cuechangeHandler = function() { + changes++; + }; + + tt.addCue({ + id: '1', + startTime: 0, + endTime: 5 + }); + + tt.oncuechange = cuechangeHandler; + tt.addEventListener('cuechange', cuechangeHandler); + + player.tech_.currentTime = function() { + return 0; + }; + + // `playing` would trigger rvfc or raf, `timeupdate` for fallback + player.tech_.trigger('playing'); + player.tech_.trigger('timeupdate'); + assert.equal(changes, 0, 'a cuechange event is not triggered'); + + player.tech_.on('ready', function() { + player.tech_.currentTime = function() { + return 0.2; + }; + + player.tech_.trigger('playing'); + clock.tick(1); + + assert.equal(changes, 2, 'a cuechange event trigger addEventListener and oncuechange'); + + player.tech_.trigger('timeupdate'); + clock.tick(1); + + assert.equal(changes, 2, 'a cuechange event trigger not duplicated by timeupdate'); + + tt.off(); + player.dispose(); + clock.restore(); + done(); + }); + player.tech_.triggerReady(); + clock.tick(1); +}); + +QUnit.test('fires cuechange when cues become active and inactive', function(assert) { + const player = TestHelpers.makePlayer(); + let changes = 0; + const tt = new TextTrack({ + tech: player.tech_, + mode: 'showing' + }); + const cuechangeHandler = function() { + changes++; + }; + let fakeCurrentTime = 0; + + player.tech_.currentTime = function() { + return fakeCurrentTime; + }; + + tt.addCue({ + id: '1', + startTime: 1, + endTime: 5 + }); + tt.addCue({ + id: '2', + startTime: 11, + endTime: 14 + }); + + tt.oncuechange = cuechangeHandler; + tt.addEventListener('cuechange', cuechangeHandler); + + fakeCurrentTime = 2; + player.tech_.trigger('playing'); + + assert.equal(changes, 2, 'a cuechange event trigger addEventListener and oncuechange (rvfc/raf)'); + + fakeCurrentTime = 7; + player.tech_.trigger('playing'); + + assert.equal(changes, 4, 'a cuechange event trigger addEventListener and oncuechange (rvfc/raf)'); + + fakeCurrentTime = 12; + player.tech_.trigger('timeupdate'); + + assert.equal(changes, 6, 'a cuechange event trigger addEventListener and oncuechange (timeupdate)'); + + fakeCurrentTime = 17; + player.tech_.trigger('timeupdate'); + + assert.equal(changes, 8, 'a cuechange event trigger addEventListener and oncuechange (timeupdate)'); + + tt.off(); + player.dispose(); +}); + +QUnit.test('enabled and disabled cuechange handler when changing mode to hidden', function(assert) { + const player = TestHelpers.makePlayer(); + let changes = 0; + const tt = new TextTrack({ + tech: player.tech_ + }); + const cuechangeHandler = function() { + changes++; + }; + + tt.mode = 'hidden'; + + tt.addCue({ + id: '1', + startTime: 1, + endTime: 5 + }); + + tt.addEventListener('cuechange', cuechangeHandler); + + player.tech_.currentTime = function() { + return 2; + }; + player.tech_.trigger('playing'); + player.tech_.trigger('timeupdate'); + + assert.equal(changes, 1, 'a cuechange event trigger'); + + changes = 0; + // debugger; + tt.mode = 'disabled'; + + player.tech_.currentTime = function() { + return 7; + }; + player.tech_.trigger('playing'); + player.tech_.trigger('timeupdate'); + + assert.equal(changes, 0, 'NO cuechange event trigger'); + + tt.off(); + player.dispose(); +}); + +QUnit.test('enabled and disabled cuechange handler when changing mode to showing', function(assert) { + const clock = sinon.useFakeTimers(); + const player = TestHelpers.makePlayer(); + let changes = 0; + const tt = new TextTrack({ + tech: player.tech_ + }); + const cuechangeHandler = function() { + changes++; + }; + + tt.mode = 'showing'; + + tt.addCue({ + id: '1', + startTime: 1, + endTime: 5 + }); + + tt.addEventListener('cuechange', cuechangeHandler); + + player.tech_.currentTime = function() { + return 2; + }; + player.tech_.trigger('playing'); + clock.tick(10); + + assert.equal(changes, 1, 'a cuechange event trigger'); + + changes = 0; + tt.mode = 'disabled'; + + player.tech_.currentTime = function() { + return 7; + }; + player.tech_.trigger('playing'); + + assert.equal(changes, 0, 'NO cuechange event trigger'); + + tt.off(); + player.dispose(); + clock.restore(); +}); + +QUnit.test('if preloadTextTracks is false, default tracks are not parsed until mode is showing', function(assert) { + this.tech.preloadTextTracks = false; + const clock = sinon.useFakeTimers(); + const oldVTT = window.WebVTT; + let parserCreated = false; + const reqs = []; + + this.xhr.onCreate = function(req) { + reqs.push(req); + }; + + window.WebVTT = () => {}; + window.WebVTT.StringDecoder = () => {}; + + // This needs to be function expression rather than arrow function so it is constructable + window.WebVTT.Parser = function() { + parserCreated = true; + return { + oncue() {}, + onparsingerror() {}, + onflush() {}, + parse() {}, + flush() {} + }; + }; + + const tt = new TextTrack({ + tech: this.tech, + src: 'http://example.com', + default: true + }); + + assert.notOk(reqs.length, 'Default track is not requested'); + assert.notOk(parserCreated, 'Parser is not created'); + + tt.mode = 'showing'; + + const req = reqs.pop(); + + req.respond(200, null, 'WEBVTT\n'); + + assert.ok(parserCreated, 'Parser is created after track is showing'); + + clock.restore(); + tt.off(); + window.WebVTT = oldVTT; +}); + +QUnit.test('tracks are parsed if vttjs is loaded', function(assert) { + const clock = sinon.useFakeTimers(); + const oldVTT = window.WebVTT; + let parserCreated = false; + const reqs = []; + + this.xhr.onCreate = function(req) { + reqs.push(req); + }; + + window.WebVTT = () => {}; + window.WebVTT.StringDecoder = () => {}; + + // This needs to be function expression rather than arrow function so it is constructable + window.WebVTT.Parser = function() { + parserCreated = true; + return { + oncue() {}, + onparsingerror() {}, + onflush() {}, + parse() {}, + flush() {} + }; + }; + + const tt = new TextTrack({ + tech: this.tech, + src: 'http://example.com' + }); + + const req = reqs.pop(); + + req.respond(200, null, 'WEBVTT\n'); + + assert.ok(parserCreated, 'WebVTT is loaded, so we can just parse'); + assert.notOk(req.withCredentials, 'the request defaults not to send credentials'); + + clock.restore(); + tt.off(); + window.WebVTT = oldVTT; +}); + +QUnit.test('tracks are loaded withCredentials is crossorigin is set to use-credentials', function(assert) { + const clock = sinon.useFakeTimers(); + const oldVTT = window.WebVTT; + const reqs = []; + + this.xhr.onCreate = function(req) { + reqs.push(req); + }; + + window.WebVTT = () => {}; + window.WebVTT.StringDecoder = () => {}; + + // This needs to be function expression rather than arrow function so it is constructable + window.WebVTT.Parser = function() { + return { + oncue() {}, + onparsingerror() {}, + onflush() {}, + parse() {}, + flush() {} + }; + }; + + this.tech.crossOrigin = () => 'use-credentials'; + + const tt = new TextTrack({ + tech: this.tech, + src: 'http://example.com' + }); + + const req = reqs.pop(); + + assert.ok(req.withCredentials, 'the request was made withCredentials'); + + this.tech.crossOrigin = () => 'anonymous'; + + const tt2 = new TextTrack({ + tech: this.tech, + src: 'http://example.com' + }); + + const req2 = reqs.pop(); + + assert.notOk(req2.withCredentials, 'the request was not made withCredentials'); + + req.abort(); + req2.abort(); + clock.restore(); + tt.off(); + tt2.off(); + window.WebVTT = oldVTT; +}); + +QUnit.test('tracks are parsed once vttjs is loaded', function(assert) { + const clock = sinon.useFakeTimers(); + const oldVTT = window.WebVTT; + let parserCreated = false; + const reqs = []; + + this.xhr.onCreate = function(req) { + reqs.push(req); + }; + + window.WebVTT = true; + + const testTech = new EventTarget(); + + testTech.textTracks = () => {}; + testTech.currentTime = () => {}; + testTech.crossOrigin = () => null; + + const tt = new TextTrack({ + tech: testTech, + src: 'http://example.com' + }); + + reqs.pop().respond(200, null, 'WEBVTT\n'); + + assert.ok(!parserCreated, 'WebVTT is not loaded, do not try to parse yet'); + + clock.tick(100); + assert.ok(!parserCreated, 'WebVTT still not loaded, do not try to parse yet'); + + window.WebVTT = () => {}; + window.WebVTT.StringDecoder = () => {}; + + // This needs to be function expression rather than arrow function so it is constructable + window.WebVTT.Parser = function() { + parserCreated = true; + return { + oncue() {}, + onparsingerror() {}, + onflush() {}, + parse() {}, + flush() {} + }; + }; + + testTech.trigger('vttjsloaded'); + assert.ok(parserCreated, 'WebVTT is loaded, so we can parse now'); + + clock.restore(); + tt.off(); + testTech.off(); + window.WebVTT = oldVTT; +}); + +QUnit.test('stops processing if vttjs loading errored out', function(assert) { + const clock = sinon.useFakeTimers(); + const errorSpy = sinon.spy(); + const oldVTT = window.WebVTT; + const oldLogError = log.error; + const reqs = []; + + this.xhr.onCreate = function(req) { + reqs.push(req); + }; + + log.error = errorSpy; + + window.WebVTT = true; + + const testTech = new EventTarget(); + + testTech.textTracks = () => {}; + testTech.currentTime = () => {}; + testTech.crossOrigin = () => null; + + sinon.stub(testTech, 'off'); + testTech.off.withArgs('vttjsloaded'); + + const tt = new TextTrack({ + tech: testTech, + src: 'http://example.com' + }); + + reqs.pop().respond(200, null, 'WEBVTT\n'); + + testTech.trigger('vttjserror'); + + assert.equal(errorSpy.callCount, 1, 'vttjs failed to load, so log.error was called'); + + testTech.trigger('vttjserror'); + + // vttjserror not called again + assert.equal(errorSpy.callCount, 1, 'vttjserror handler not called again'); + + clock.restore(); + window.WebVTT = oldVTT; + tt.off(); + testTech.off.restore(); + testTech.off(); + log.error = oldLogError; +}); + +QUnit.test('toJSON', function(assert) { + const tt = new TextTrack({ + tech: this.tech + }); + + tt.addCue({ + id: '1', + startTime: 1, + endTime: 2.555 + }); + tt.addCue({ + id: '2', + startTime: 2.555, + endTime: 2.555 + }); + + const jsonTrack = tt.toJSON(); + + // Properties we want copied are copied correctly + assert.equal(tt.id, jsonTrack.id, 'the id for the copied track stayed the same'); + assert.equal(tt.mode, jsonTrack.mode, 'the mode for the copied track stayed the same'); + assert.equal(tt.kind, jsonTrack.kind, 'the kind for the copied track stayed the same'); + + // The tech_ property stays on the original track, but is removed from the copy + assert.ok(tt.tech_, 'the tech exists on the original track'); + assert.notOk(jsonTrack.tech_, 'the tech does not exist on the copied track'); +}); + +QUnit.test('serialize', function(assert) { + const tt = new TextTrack({ + tech: this.tech + }); + + tt.addCue({ + id: '1', + startTime: 1, + endTime: 2.555 + }); + tt.addCue({ + id: '2', + startTime: 2.555, + endTime: 2.555 + }); + + const serializedTrack = JSON.stringify(tt); + + // Ensure tech was not removed from the actual track + assert.ok(tt.tech_, 'the tech exists on the original track'); + + // Values from the track should be found in the serialized string + assert.ok(serializedTrack.includes(`"id":"${tt.id}"`), 'serialized data should include id'); + assert.ok(serializedTrack.includes(`"mode":"${tt.mode}"`), 'serialized data should include mode'); + assert.ok(serializedTrack.includes(`"kind":"${tt.kind}"`), 'serialized data should include cues'); + + // tech_ should not be found in the serialized string + assert.notOk(serializedTrack.includes('"tech_":'), 'serialized data should not include tech_'); +}); diff --git a/js/videojs/test/unit/tracks/text-tracks.test.js b/js/videojs/test/unit/tracks/text-tracks.test.js new file mode 100644 index 0000000..e49e8f9 --- /dev/null +++ b/js/videojs/test/unit/tracks/text-tracks.test.js @@ -0,0 +1,690 @@ +/* eslint-env qunit */ +import ChaptersButton from '../../../src/js/control-bar/text-track-controls/chapters-button.js'; +import DescriptionsButton from '../../../src/js/control-bar/text-track-controls/descriptions-button.js'; +import SubtitlesButton from '../../../src/js/control-bar/text-track-controls/subtitles-button.js'; +import CaptionsButton from '../../../src/js/control-bar/text-track-controls/captions-button.js'; +import SubsCapsButton from '../../../src/js/control-bar/text-track-controls/subs-caps-button.js'; + +import TextTrack from '../../../src/js/tracks/text-track.js'; +import TextTrackDisplay from '../../../src/js/tracks/text-track-display.js'; +import Html5 from '../../../src/js/tech/html5.js'; +import Tech from '../../../src/js/tech/tech.js'; + +import * as browser from '../../../src/js/utils/browser.js'; +import TestHelpers from '../test-helpers.js'; +import document from 'global/document'; +import sinon from 'sinon'; + +QUnit.module('Text Tracks', { + beforeEach(assert) { + this.clock = sinon.useFakeTimers(); + }, + afterEach(assert) { + this.clock.restore(); + } +}); + +QUnit.test('should place title list item into ul', function(assert) { + const player = TestHelpers.makePlayer(); + const chaptersButton = new ChaptersButton(player); + + const menuContentElement = chaptersButton.el().getElementsByTagName('UL')[0]; + const titleElement = menuContentElement.children[0]; + + assert.ok(titleElement.innerHTML === 'Chapters', 'title element placed in ul'); + + player.dispose(); + chaptersButton.dispose(); +}); + +QUnit.test('Player track methods call the tech', function(assert) { + const player = TestHelpers.makePlayer(); + let calls = 0; + + player.tech_.textTracks = function() { + calls++; + }; + player.tech_.addTextTrack = function() { + calls++; + }; + + player.addTextTrack(); + player.textTracks(); + + assert.equal(calls, 2, 'both textTrack and addTextTrack defer to the tech'); + + player.dispose(); +}); + +QUnit.test('TextTrackDisplay initializes tracks on player ready', function(assert) { + let calls = 0; + const player = TestHelpers.makePlayer(); + + player.addTextTrack = () => calls--; + player.getChild = () => calls--; + player.ready = () => calls++; + + const ttd = new TextTrackDisplay(player, {}); + + assert.equal(calls, 1, 'only a player.ready call was made'); + + ttd.dispose(); + player.dispose(); +}); + +QUnit.test('listen to remove and add track events in native text tracks', function(assert) { + const oldTestVid = Html5.TEST_VID; + const oldTextTracks = Html5.prototype.textTracks; + const events = {}; + + Html5.prototype.textTracks = function() { + return { + removeEventListener() {}, + addEventListener(type, handler) { + events[type] = true; + } + }; + }; + + Html5.TEST_VID = { + textTracks: [] + }; + + const player = { + // Function.prototype is a built-in no-op function. + controls() {}, + ready() {}, + options() { + return {}; + }, + addChild() {}, + id() {}, + el() { + return { + insertBefore() {}, + appendChild() {} + }; + } + }; + + player.player_ = player; + player.options_ = {}; + + const html = new Html5({}); + + assert.ok(events.removetrack, 'removetrack listener was added'); + assert.ok(events.addtrack, 'addtrack listener was added'); + + Html5.TEST_VID = oldTestVid; + Html5.prototype.textTracks = oldTextTracks; + + html.dispose(); +}); + +QUnit.test('update texttrack buttons on removetrack or addtrack', function(assert) { + let update = 0; + const events = {}; + const oldCaptionsUpdate = CaptionsButton.prototype.update; + const oldSubsUpdate = SubtitlesButton.prototype.update; + const oldDescriptionsUpdate = DescriptionsButton.prototype.update; + const oldChaptersUpdate = ChaptersButton.prototype.update; + const oldSubsCapsUpdate = SubsCapsButton.prototype.update; + + CaptionsButton.prototype.update = function() { + update++; + oldCaptionsUpdate.call(this); + }; + SubtitlesButton.prototype.update = function() { + update++; + oldSubsUpdate.call(this); + }; + DescriptionsButton.prototype.update = function() { + update++; + oldDescriptionsUpdate.call(this); + }; + ChaptersButton.prototype.update = function() { + update++; + oldChaptersUpdate.call(this); + }; + SubsCapsButton.prototype.update = function() { + update++; + oldSubsCapsUpdate.call(this); + }; + + const oldFeaturesNativeTextTracks = Tech.prototype.featuresNativeTextTracks; + const oldTextTracks = Tech.prototype.textTracks; + + Tech.prototype.featuresNativeTextTracks = true; + Tech.prototype.textTracks = function() { + return { + length: 0, + addEventListener(type, handler) { + if (!events[type]) { + events[type] = []; + } + events[type].push(handler); + }, + // Required in player.dispose() + removeEventListener() {} + }; + }; + + const tag = document.createElement('video'); + const track1 = document.createElement('track'); + const track2 = document.createElement('track'); + + track1.kind = 'captions'; + track1.label = 'en'; + track1.language = 'English'; + track1.src = '#en.vtt'; + tag.appendChild(track1); + + track2.kind = 'captions'; + track2.label = 'es'; + track2.language = 'Spanish'; + track2.src = '#es.vtt'; + tag.appendChild(track2); + + const player = TestHelpers.makePlayer({ + controlBar: { + captionsButton: true, + subtitlesButton: true + } + }, tag); + + player.player_ = player; + + assert.equal(update, 5, 'update was called on the five buttons during init'); + + for (let i = 0; i < events.removetrack.length; i++) { + events.removetrack[i](); + } + + assert.equal(update, 10, 'update was called on the five buttons for remove track'); + + for (let i = 0; i < events.addtrack.length; i++) { + events.addtrack[i](); + } + + assert.equal(update, 15, 'update was called on the five buttons for remove track'); + + Tech.prototype.textTracks = oldTextTracks; + Tech.prototype.featuresNativeTextTracks = oldFeaturesNativeTextTracks; + CaptionsButton.prototype.update = oldCaptionsUpdate; + SubtitlesButton.prototype.update = oldSubsUpdate; + ChaptersButton.prototype.update = oldChaptersUpdate; + SubsCapsButton.prototype.update = oldSubsCapsUpdate; + DescriptionsButton.prototype.update = oldDescriptionsUpdate; + + player.dispose(); +}); + +QUnit.test('emulated tracks are always used, except in safari', function(assert) { + const oldTestVid = Html5.TEST_VID; + const oldIsAnySafari = browser.IS_ANY_SAFARI; + + Html5.TEST_VID = { + textTracks: [] + }; + + browser.stub_IS_ANY_SAFARI(false); + + assert.ok(!Html5.supportsNativeTextTracks(), 'Html5 does not support native text tracks, in non-safari'); + + browser.stub_IS_ANY_SAFARI(true); + + assert.ok(Html5.supportsNativeTextTracks(), 'Html5 does support native text tracks in safari'); + + Html5.TEST_VID = oldTestVid; + browser.stub_IS_ANY_SAFARI(oldIsAnySafari); +}); + +QUnit.test('when switching techs, we should not get a new text track', function(assert) { + const player = TestHelpers.makePlayer(); + + player.loadTech_('TechFaker'); + const firstTracks = player.textTracks(); + + player.loadTech_('TechFaker'); + const secondTracks = player.textTracks(); + + assert.ok(firstTracks === secondTracks, 'the tracks are equal'); + + player.dispose(); +}); + +if (Html5.supportsNativeTextTracks()) { + QUnit.test('listen to native remove and add track events in native text tracks', function(assert) { + const done = assert.async(); + + const el = document.createElement('video'); + const html = new Html5({el}); + const tt = el.textTracks; + const emulatedTt = html.textTracks(); + const track = document.createElement('track'); + + el.appendChild(track); + + const addtrack = function() { + assert.equal(emulatedTt.length, tt.length, 'we have matching tracks length'); + assert.equal(emulatedTt.length, 1, 'we have one text track'); + + el.removeChild(track); + }; + + emulatedTt.one('addtrack', addtrack); + emulatedTt.one('removetrack', function() { + assert.equal(emulatedTt.length, tt.length, 'we have matching tracks length'); + assert.equal(emulatedTt.length, 0, 'we have no more text tracks'); + + html.dispose(); + done(); + }); + }); + + QUnit.test('should have removed tracks on dispose', function(assert) { + const done = assert.async(); + + const el = document.createElement('video'); + const html = new Html5({el}); + const tt = el.textTracks; + const emulatedTt = html.textTracks(); + const track = document.createElement('track'); + + el.appendChild(track); + + const addtrack = function() { + assert.equal(emulatedTt.length, tt.length, 'we have matching tracks length'); + assert.equal(emulatedTt.length, 1, 'we have one text track'); + + emulatedTt.off('addtrack', addtrack); + html.dispose(); + + assert.equal(emulatedTt.length, tt.length, 'we have matching tracks length'); + assert.equal(emulatedTt.length, 0, 'we have no more text tracks'); + + done(); + }; + + emulatedTt.on('addtrack', addtrack); + }); +} + +QUnit.test('should check for text track changes when emulating text tracks', function(assert) { + const tech = new Tech(); + let numTextTrackChanges = 0; + + tech.on('texttrackchange', function() { + numTextTrackChanges++; + }); + tech.emulateTextTracks(); + assert.equal(numTextTrackChanges, 1, 'we got a texttrackchange event'); + + tech.dispose(); +}); + +QUnit.test('no lang attribute on cue elements if one is provided', function(assert) { + const player = TestHelpers.makePlayer(); + const tt = new TextTrack({ + tech: player.tech_, + mode: 'showing' + }); + + tt.addCue({ + id: '1', + startTime: 2, + endTime: 5 + }); + player.tech_.textTracks().addTrack(tt); + + player.currentTime(2); + player.tech_.trigger('playing'); + + assert.notOk(tt.activeCues[0].displayState.hasAttribute('lang'), 'no lang attribute should be set'); + + player.dispose(); +}); + +QUnit.test('set lang attribute on cue elements if one is provided', function(assert) { + const player = TestHelpers.makePlayer(); + const tt = new TextTrack({ + srclang: 'en', + tech: player.tech_, + mode: 'showing' + }); + + tt.addCue({ + id: '1', + startTime: 2, + endTime: 5 + }); + player.tech_.textTracks().addTrack(tt); + + player.currentTime(2); + player.tech_.trigger('playing'); + + assert.equal(tt.activeCues[0].displayState.getAttribute('lang'), 'en', 'the lang should be set to en'); + + player.dispose(); +}); + +QUnit.test('removes cuechange event when text track is hidden for emulated tracks', function(assert) { + const player = TestHelpers.makePlayer(); + const tt = new TextTrack({ + tech: player.tech_, + mode: 'showing' + }); + + tt.addCue({ + id: '1', + startTime: 2, + endTime: 5 + }); + player.tech_.textTracks().addTrack(tt); + + let numTextTrackChanges = 0; + + player.tech_.on('texttrackchange', function() { + numTextTrackChanges++; + }); + + tt.mode = 'disabled'; + this.clock.tick(1); + assert.equal( + numTextTrackChanges, 1, + 'texttrackchange should be called once for mode change' + ); + tt.mode = 'showing'; + this.clock.tick(1); + assert.equal( + numTextTrackChanges, 2, + 'texttrackchange should be called once for mode change' + ); + + player.tech_.currentTime = function() { + return 3; + }; + player.tech_.trigger('playing'); + assert.equal( + numTextTrackChanges, 3, + 'texttrackchange should be triggered once for the cuechange' + ); + + tt.mode = 'hidden'; + this.clock.tick(1); + assert.equal( + numTextTrackChanges, 4, + 'texttrackchange should be called once for the mode change' + ); + + player.tech_.currentTime = function() { + return 7; + }; + player.tech_.trigger('timeupdate'); + assert.equal( + numTextTrackChanges, 4, + 'texttrackchange should be not be called since mode is hidden' + ); + player.dispose(); +}); + +QUnit.test('should return correct remote text track values', function(assert) { + const fixture = document.getElementById('qunit-fixture'); + const html = ` + <video id="example_1" class="video-js" autoplay preload="none"> + <source src="http://google.com" type="video/mp4"> + <source src="http://google.com" type="video/webm"> + <track kind="captions" label="label"> + </video> + `; + + fixture.innerHTML += html; + const tag = document.getElementById('example_1'); + const player = TestHelpers.makePlayer({}, tag); + + this.clock.tick(10); + + assert.equal(player.remoteTextTracks().length, 1, 'add text track via html'); + assert.equal(player.remoteTextTrackEls().length, 1, 'add html track element via html'); + + const htmlTrackElement = player.addRemoteTextTrack({ + kind: 'captions', + label: 'label' + }, true); + + assert.equal(player.remoteTextTracks().length, 2, 'add text track via method'); + assert.equal(player.remoteTextTrackEls().length, 2, 'add html track element via method'); + + player.removeRemoteTextTrack(htmlTrackElement.track); + + assert.equal(player.remoteTextTracks().length, 1, 'remove text track via method'); + assert.equal( + player.remoteTextTrackEls().length, + 1, + 'remove html track element via method' + ); + + player.dispose(); +}); + +QUnit.test('should uniformly create html track element when adding text track', function(assert) { + const player = TestHelpers.makePlayer(); + const track = { + kind: 'kind', + src: 'src', + language: 'language', + label: 'label', + default: 'default' + }; + + assert.equal(player.remoteTextTrackEls().length, 0, 'no html text tracks'); + + const htmlTrackElement = player.addRemoteTextTrack(track, true); + + assert.equal( + htmlTrackElement.kind, + htmlTrackElement.track.kind, + 'verify html track element kind' + ); + assert.equal( + htmlTrackElement.src, + htmlTrackElement.track.src, + 'verify html track element src' + ); + assert.equal( + htmlTrackElement.srclang, + htmlTrackElement.track.language, + 'verify html track element language' + ); + assert.equal( + htmlTrackElement.label, + htmlTrackElement.track.label, + 'verify html track element label' + ); + assert.equal( + htmlTrackElement.default, + htmlTrackElement.track.default, + 'verify html track element default' + ); + + assert.equal(player.remoteTextTrackEls().length, 1, 'html track element exist'); + assert.equal( + player.remoteTextTrackEls().getTrackElementByTrack_(htmlTrackElement.track), + htmlTrackElement, + 'verify same html track element' + ); + + player.dispose(); +}); + +// disable in Firefox because while the code works in practice, during the +// tests, somehow the text track object isn't ready and thus it won't allow +// us to change the mode of the track rendering the test non-functional. +if (!browser.IS_FIREFOX) { + QUnit.test('remote text tracks change event should fire when using native text tracks', function(assert) { + const done = assert.async(); + + const player = TestHelpers.makePlayer({ + techOrder: ['html5'], + html5: { nativeTextTracks: true } + }); + + player.remoteTextTracks().on('change', function(e) { + assert.ok(true, 'change event triggered'); + player.dispose(); + done(); + }); + + const track = { + kind: 'kind', + src: 'src', + language: 'language', + label: 'label', + default: 'default' + }; + + player.addRemoteTextTrack(track, true); + }); +} + +QUnit.test('default text tracks should show by default', function(assert) { + const tag = TestHelpers.makeTag(); + const capt = document.createElement('track'); + + capt.setAttribute('kind', 'captions'); + capt.setAttribute('default', 'default'); + + tag.appendChild(capt); + + const player = TestHelpers.makePlayer({ + html5: { + nativeTextTracks: false + } + }, tag); + + // native tracks are initialized after the player is ready + this.clock.tick(1); + + const tracks = player.textTracks(); + + assert.equal(tracks[0].kind, 'captions', 'the captions track is present'); + assert.equal(tracks[0].mode, 'showing', 'the captions track is showing'); + + player.dispose(); +}); + +QUnit.test('default captions take precedence over default descriptions', function(assert) { + const tag = TestHelpers.makeTag(); + const desc = document.createElement('track'); + const capt = document.createElement('track'); + + desc.setAttribute('kind', 'descriptions'); + desc.setAttribute('default', 'default'); + capt.setAttribute('kind', 'captions'); + capt.setAttribute('default', 'default'); + + tag.appendChild(desc); + tag.appendChild(capt); + + const player = TestHelpers.makePlayer({ + html5: { + nativeTextTracks: false + } + }, tag); + + // native tracks are initialized after the player is ready + this.clock.tick(1); + + const tracks = player.textTracks(); + + assert.equal(tracks[0].kind, 'descriptions', 'the descriptions track is first'); + assert.equal(tracks[0].mode, 'disabled', 'the descriptions track is disabled'); + assert.equal(tracks[1].kind, 'captions', 'the captions track is second'); + assert.equal(tracks[1].mode, 'showing', 'the captions track is showing'); + player.dispose(); +}); + +QUnit.test('removeRemoteTextTrack should be able to take both a track and the response from addRemoteTextTrack', function(assert) { + const player = TestHelpers.makePlayer(); + const track = { + kind: 'kind', + src: 'src', + language: 'language', + label: 'label', + default: 'default' + }; + let htmlTrackElement = player.addRemoteTextTrack(track, true); + + assert.equal(player.remoteTextTrackEls().length, 1, 'html track element exist'); + + player.removeRemoteTextTrack(htmlTrackElement); + + assert.equal( + player.remoteTextTrackEls().length, + 0, + 'the track element was removed correctly' + ); + + htmlTrackElement = player.addRemoteTextTrack(track, true); + assert.equal(player.remoteTextTrackEls().length, 1, 'html track element exist'); + + player.removeRemoteTextTrack(htmlTrackElement.track); + assert.equal( + player.remoteTextTrackEls().length, + 0, + 'the track element was removed correctly' + ); + player.dispose(); +}); + +if (Html5.isSupported()) { + QUnit.test('auto remove tracks should not clean up tracks added while source is being added', function(assert) { + const player = TestHelpers.makePlayer({ + techOrder: ['html5'], + html5: { + nativeTextTracks: false + } + }); + + const track = { + kind: 'kind', + src: 'src', + language: 'language', + label: 'label', + default: 'default' + }; + + player.src({src: 'example.mp4', type: 'video/mp4'}); + player.addRemoteTextTrack(track, false); + + this.clock.tick(1); + assert.equal(player.textTracks().length, 1, 'we have one text track'); + + player.dispose(); + }); + + QUnit.test('auto remove tracks added right before a source change will be cleaned up', function(assert) { + const player = TestHelpers.makePlayer({ + techOrder: ['html5'], + html5: { + nativeTextTracks: false + } + }); + + const track = { + kind: 'kind', + src: 'src', + language: 'language', + label: 'label', + default: 'default' + }; + + player.addRemoteTextTrack(track, false); + player.src({src: 'example.mp4', type: 'video/mp4'}); + + this.clock.tick(1); + assert.equal(player.textTracks().length, 0, 'we do not have any tracks left'); + + player.dispose(); + }); +} diff --git a/js/videojs/test/unit/tracks/track-baseline.js b/js/videojs/test/unit/tracks/track-baseline.js new file mode 100644 index 0000000..4d24873 --- /dev/null +++ b/js/videojs/test/unit/tracks/track-baseline.js @@ -0,0 +1,49 @@ +/* eslint-env qunit */ +import TechFaker from '../tech/tech-faker'; + +/** + * Tests baseline functionality for all tracks + * + # @param {Track} TrackClass the track class object to use for testing + # @param {Object} options the options to setup a track with + */ +const TrackBaseline = function(TrackClass, options) { + + QUnit.test('is setup with id, kind, label, and language', function(assert) { + const tech = new TechFaker(); + const track = new TrackClass(Object.assign({tech}, options)); + + assert.equal(track.kind, options.kind, 'we have a kind'); + assert.equal(track.label, options.label, 'we have a label'); + assert.equal(track.language, options.language, 'we have a language'); + assert.equal(track.id, options.id, 'we have a id'); + + tech.dispose(); + }); + + QUnit.test('kind, language, id, are read only', function(assert) { + const tech = new TechFaker(); + const track = new TrackClass(Object.assign({tech}, options)); + + track.kind = 'subtitles'; + track.language = 'es'; + track.id = '2'; + + assert.equal(track.kind, options.kind, 'we have a kind'); + assert.equal(track.language, options.language, 'we have a language'); + assert.equal(track.id, options.id, 'we have an id'); + + tech.dispose(); + }); + + QUnit.test('returns an instance of itself on non ie8 browsers', function(assert) { + const tech = new TechFaker(); + const track = new TrackClass(Object.assign({tech}, options)); + + assert.ok(track instanceof TrackClass, 'returns an instance'); + + tech.dispose(); + }); +}; + +export default TrackBaseline; diff --git a/js/videojs/test/unit/tracks/track-list.test.js b/js/videojs/test/unit/tracks/track-list.test.js new file mode 100644 index 0000000..88e1caf --- /dev/null +++ b/js/videojs/test/unit/tracks/track-list.test.js @@ -0,0 +1,177 @@ +/* eslint-env qunit */ +import TrackList from '../../../src/js/tracks/track-list.js'; +import EventTarget from '../../../src/js/event-target.js'; + +const newTrack = function(id) { + return { + id, + addEventListener() {}, + off() {} + }; +}; + +QUnit.module('Track List', { + beforeEach() { + this.tracks = [newTrack('1'), newTrack('2'), newTrack('3')]; + } +}); + +QUnit.test('TrackList\'s length is set correctly', function(assert) { + const trackList = new TrackList(this.tracks); + + assert.equal(trackList.length, this.tracks.length, 'length is ' + this.tracks.length); +}); + +QUnit.test('can get tracks by int and string id', function(assert) { + const trackList = new TrackList(this.tracks); + + assert.equal(trackList.getTrackById('1').id, '1', 'id "1" has id of "1"'); + assert.equal(trackList.getTrackById('2').id, '2', 'id "2" has id of "2"'); + assert.equal(trackList.getTrackById('3').id, '3', 'id "3" has id of "3"'); + +}); + +QUnit.test('length is updated when new tracks are added or removed', function(assert) { + const trackList = new TrackList(this.tracks); + + trackList.addTrack(newTrack('100')); + assert.equal( + trackList.length, + this.tracks.length + 1, + 'the length is ' + (this.tracks.length + 1) + ); + trackList.addTrack(newTrack('101')); + assert.equal( + trackList.length, + this.tracks.length + 2, + 'the length is ' + (this.tracks.length + 2) + ); + + trackList.removeTrack(trackList.getTrackById('101')); + assert.equal( + trackList.length, + this.tracks.length + 1, + 'the length is ' + (this.tracks.length + 1) + ); + trackList.removeTrack(trackList.getTrackById('100')); + assert.equal( + trackList.length, + this.tracks.length, + 'the length is ' + this.tracks.length + ); +}); + +QUnit.test('can access items by index', function(assert) { + const trackList = new TrackList(this.tracks); + const length = trackList.length; + + assert.expect(length); + + for (let i = 0; i < length; i++) { + assert.equal( + trackList[i].id, + String(i + 1), + 'the id of a track matches the index + 1' + ); + } +}); + +QUnit.test('can access new items by index', function(assert) { + const trackList = new TrackList(this.tracks); + + trackList.addTrack(newTrack('100')); + assert.equal(trackList[3].id, '100', 'id of item at index 3 is 100'); + + trackList.addTrack(newTrack('101')); + assert.equal(trackList[4].id, '101', 'id of item at index 4 is 101'); +}); + +QUnit.test('cannot access removed items by index', function(assert) { + const trackList = new TrackList(this.tracks); + + trackList.addTrack(newTrack('100')); + trackList.addTrack(newTrack('101')); + assert.equal(trackList[3].id, '100', 'id of item at index 3 is 100'); + assert.equal(trackList[4].id, '101', 'id of item at index 4 is 101'); + + trackList.removeTrack(trackList.getTrackById('101')); + trackList.removeTrack(trackList.getTrackById('100')); + + assert.ok(!trackList[3], 'nothing at index 3'); + assert.ok(!trackList[4], 'nothing at index 4'); +}); + +QUnit.test('new item available at old index', function(assert) { + const trackList = new TrackList(this.tracks); + + trackList.addTrack(newTrack('100')); + assert.equal(trackList[3].id, '100', 'id of item at index 3 is 100'); + + trackList.removeTrack(trackList.getTrackById('100')); + assert.ok(!trackList[3], 'nothing at index 3'); + + trackList.addTrack(newTrack('101')); + assert.equal(trackList[3].id, '101', 'id of new item at index 3 is now 101'); +}); + +QUnit.test('a "addtrack" event is triggered when new tracks are added', function(assert) { + const trackList = new TrackList(this.tracks); + let tracks = 0; + let adds = 0; + const addHandler = (e) => { + if (e.track) { + tracks++; + } + adds++; + }; + + trackList.on('addtrack', addHandler); + + trackList.addTrack(newTrack('100')); + trackList.addTrack(newTrack('101')); + + trackList.off('addtrack', addHandler); + trackList.onaddtrack = addHandler; + + trackList.addTrack(newTrack('102')); + trackList.addTrack(newTrack('103')); + + assert.equal(adds, 4, 'we got ' + adds + ' "addtrack" events'); + assert.equal(tracks, 4, 'we got a track with every event'); +}); + +QUnit.test('a "removetrack" event is triggered when tracks are removed', function(assert) { + const trackList = new TrackList(this.tracks); + let tracks = 0; + let rms = 0; + const rmHandler = (e) => { + if (e.track) { + tracks++; + } + rms++; + }; + + trackList.on('removetrack', rmHandler); + trackList.removeTrack(trackList.getTrackById('1')); + trackList.removeTrack(trackList.getTrackById('2')); + + trackList.off('removetrack', rmHandler); + trackList.onremovetrack = rmHandler; + trackList.removeTrack(trackList.getTrackById('3')); + + assert.equal(rms, 3, 'we got ' + rms + ' "removetrack" events'); + assert.equal(tracks, 3, 'we got a track with every event'); +}); + +QUnit.test('labelchange event is fired for the list when a child track fires labelchange', function(assert) { + const trackList = new TrackList([new EventTarget()]); + let labelchanges = 0; + const labelchangeHandler = (e) => { + labelchanges++; + }; + + trackList.on('labelchange', labelchangeHandler); + trackList[0].trigger('labelchange'); + assert.equal(labelchanges, '1', 'labelchange event is fired on tracklist'); +}); + diff --git a/js/videojs/test/unit/tracks/track.test.js b/js/videojs/test/unit/tracks/track.test.js new file mode 100644 index 0000000..e5d9b3f --- /dev/null +++ b/js/videojs/test/unit/tracks/track.test.js @@ -0,0 +1,58 @@ +/* eslint-env qunit */ +import TrackBaseline from './track-baseline'; +import Track from '../../../src/js/tracks/track.js'; +import TextTrackList from '../../../src/js/tracks/text-track-list.js'; + +const defaultTech = { + textTracks() { + return new TextTrackList(); + }, + on() {}, + off() {}, + currentTime() {} +}; + +// do baseline track testing +QUnit.module('Track'); + +TrackBaseline(Track, { + id: '1', + kind: 'subtitles', + mode: 'disabled', + label: 'English', + language: 'en' + // tech is added in baseline + // tech: new TechFaker() +}); + +QUnit.test('defaults when items not provided', function(assert) { + const track = new Track({ + tech: defaultTech + }); + + assert.equal(track.kind, '', 'kind defaulted to empty string'); + assert.equal(track.label, '', 'label defaults to empty string'); + assert.equal(track.language, '', 'language defaults to empty string'); + assert.ok(track.id.match(/vjs_track_\d+/), 'id defaults to vjs_track_GUID'); +}); + +QUnit.test('label is updated and labelchange event is fired when label is changed', function(assert) { + const track = new Track({ + tech: defaultTech + }); + let eventsTriggered = 0; + + track.addEventListener('labelchange', () => { + eventsTriggered++; + }); + + track.label = 'English (auto)'; + assert.equal(eventsTriggered, 1, 'one label change'); + assert.equal(track.label, 'English (auto)'); + + track.label = 'English (auto)'; + assert.equal(eventsTriggered, 1, 'additional label change not fired when new label is the same as old'); + assert.equal(track.label, 'English (auto)'); + + track.off(); +}); diff --git a/js/videojs/test/unit/tracks/video-track-list.test.js b/js/videojs/test/unit/tracks/video-track-list.test.js new file mode 100644 index 0000000..611ec1e --- /dev/null +++ b/js/videojs/test/unit/tracks/video-track-list.test.js @@ -0,0 +1,117 @@ +/* eslint-env qunit */ +import VideoTrackList from '../../../src/js/tracks/video-track-list.js'; +import VideoTrack from '../../../src/js/tracks/video-track.js'; +import EventTarget from '../../../src/js/event-target.js'; + +QUnit.module('Video Track List'); + +QUnit.test('trigger "change" when "selectedchange" is fired on a track', function(assert) { + const track = new EventTarget(); + + track.loaded_ = true; + const videoTrackList = new VideoTrackList([track]); + let changes = 0; + const changeHandler = function() { + changes++; + }; + + videoTrackList.on('change', changeHandler); + track.trigger('selectedchange'); + assert.equal(changes, 1, 'one change events for trigger'); + + videoTrackList.off('change', changeHandler); + videoTrackList.onchange = changeHandler; + + track.trigger('selectedchange'); + assert.equal(changes, 2, 'one change events for another trigger'); + + videoTrackList.removeTrack(track); + videoTrackList.off('change'); +}); + +QUnit.test('only one track is ever selected', function(assert) { + const track = new VideoTrack({selected: true}); + const track2 = new VideoTrack({selected: true}); + const track3 = new VideoTrack({selected: true}); + const track4 = new VideoTrack(); + const list = new VideoTrackList([track, track2]); + + assert.equal(track.selected, false, 'track is unselected'); + assert.equal(track2.selected, true, 'track2 is selected'); + + track.selected = true; + assert.equal(track.selected, true, 'track is selected'); + assert.equal(track2.selected, false, 'track2 is unselected'); + + list.addTrack(track3); + assert.equal(track.selected, false, 'track is unselected'); + assert.equal(track2.selected, false, 'track2 is unselected'); + assert.equal(track3.selected, true, 'track3 is selected'); + + track2.selected = true; + assert.equal(track.selected, false, 'track is unselected'); + assert.equal(track2.selected, true, 'track2 is selected'); + assert.equal(track3.selected, false, 'track3 is unselected'); + + list.addTrack(track4); + assert.equal(track.selected, false, 'track is unselected'); + assert.equal(track2.selected, true, 'track2 is selected'); + assert.equal(track3.selected, false, 'track3 is unselected'); + assert.equal(track4.selected, false, 'track4 is unselected'); + + list.removeTrack(track); + list.removeTrack(track2); + list.removeTrack(track3); + list.removeTrack(track4); +}); + +QUnit.test('all tracks can be unselected', function(assert) { + const track = new VideoTrack(); + const track2 = new VideoTrack(); + const list = new VideoTrackList([track, track2]); + + assert.equal(track.selected, false, 'track is unselected'); + assert.equal(track2.selected, false, 'track2 is unselected'); + + track.selected = true; + assert.equal(track.selected, true, 'track is selected'); + assert.equal(track2.selected, false, 'track2 is unselected'); + + track.selected = false; + assert.equal(track.selected, false, 'track is unselected'); + assert.equal(track2.selected, false, 'track2 is unselected'); + + list.removeTrack(track); + list.removeTrack(track2); +}); + +QUnit.test('trigger a change event per selected change', function(assert) { + const track = new VideoTrack({selected: true}); + const track2 = new VideoTrack({selected: true}); + const track3 = new VideoTrack({selected: true}); + const track4 = new VideoTrack(); + const list = new VideoTrackList([track, track2]); + let change = 0; + + list.on('change', () => change++); + track.selected = true; + assert.equal(change, 1, 'one change triggered'); + + list.addTrack(track3); + assert.equal(change, 2, 'another change triggered by adding an selected track'); + + track.selected = true; + assert.equal(change, 3, 'another change trigger by changing selected'); + + track.selected = false; + assert.equal(change, 4, 'another change trigger by changing selected'); + + list.addTrack(track4); + assert.equal(change, 4, 'no change triggered by adding a unselected track'); + + list.removeTrack(track); + list.removeTrack(track2); + list.removeTrack(track3); + list.removeTrack(track4); + list.off('change'); +}); diff --git a/js/videojs/test/unit/tracks/video-track.test.js b/js/videojs/test/unit/tracks/video-track.test.js new file mode 100644 index 0000000..a48cbf7 --- /dev/null +++ b/js/videojs/test/unit/tracks/video-track.test.js @@ -0,0 +1,120 @@ +/* eslint-env qunit */ +import VideoTrack from '../../../src/js/tracks/video-track'; +import {VideoTrackKind} from '../../../src/js/tracks/track-enums'; +import TrackBaseline from './track-baseline'; + +QUnit.module('Video Track'); + +// do baseline track testing +TrackBaseline(VideoTrack, { + id: '1', + language: 'en', + label: 'English', + kind: 'main' +}); + +QUnit.test('can create an VideoTrack a selected property', function(assert) { + const selected = true; + const track = new VideoTrack({ + selected + }); + + assert.equal(track.selected, selected, 'selected value matches what we passed in'); +}); + +QUnit.test('defaults when items not provided', function(assert) { + const track = new VideoTrack(); + + assert.equal(track.kind, '', 'kind defaulted to empty string'); + assert.equal( + track.selected, + false, + 'selected defaulted to true since there is one track' + ); + assert.equal(track.label, '', 'label defaults to empty string'); + assert.equal(track.language, '', 'language defaults to empty string'); + assert.ok(track.id.match(/vjs_track_\d+/), 'id defaults to vjs_track_GUID'); +}); + +QUnit.test('kind can only be one of several options, defaults to empty string', function(assert) { + const track1 = new VideoTrack({ + kind: 'foo' + }); + + assert.equal(track1.kind, '', 'the kind is set to empty string, not foo'); + assert.notEqual(track1.kind, 'foo', 'the kind is set to empty string, not foo'); + + // loop through all possible kinds to verify + for (const key in VideoTrackKind) { + const currentKind = VideoTrackKind[key]; + const track = new VideoTrack({kind: currentKind}); + + assert.equal(track.kind, currentKind, 'the kind is set to ' + currentKind); + } +}); + +QUnit.test('selected can only be instantiated to true or false, defaults to false', function(assert) { + let track = new VideoTrack({ + selected: 'foo' + }); + + assert.equal(track.selected, false, 'the selected value is set to false, not foo'); + assert.notEqual(track.selected, 'foo', 'the selected value is not set to foo'); + + track = new VideoTrack({ + selected: true + }); + + assert.equal(track.selected, true, 'the selected value is set to true'); + + track = new VideoTrack({ + selected: false + }); + + assert.equal(track.selected, false, 'the selected value is set to false'); +}); + +QUnit.test('selected can only be changed to true or false', function(assert) { + const track = new VideoTrack(); + + track.selected = 'foo'; + assert.notEqual(track.selected, 'foo', 'selected not set to invalid value, foo'); + assert.equal(track.selected, false, 'selected remains on the old value, false'); + + track.selected = true; + assert.equal(track.selected, true, 'selected was set to true'); + + track.selected = 'baz'; + assert.notEqual(track.selected, 'baz', 'selected not set to invalid value, baz'); + assert.equal(track.selected, true, 'selected remains on the old value, true'); + + track.selected = false; + assert.equal(track.selected, false, 'selected was set to false'); +}); + +QUnit.test('when selected is changed selectedchange event is fired', function(assert) { + const track = new VideoTrack({ + selected: false + }); + let eventsTriggered = 0; + + track.addEventListener('selectedchange', () => { + eventsTriggered++; + }); + + // two events + track.selected = true; + track.selected = false; + assert.equal(eventsTriggered, 2, 'two selected changes'); + + // no event here + track.selected = false; + track.selected = false; + assert.equal(eventsTriggered, 2, 'still two selected changes'); + + // one event + track.selected = true; + assert.equal(eventsTriggered, 3, 'three selected changes'); + + track.off(); +}); diff --git a/js/videojs/test/unit/tracks/video-tracks.test.js b/js/videojs/test/unit/tracks/video-tracks.test.js new file mode 100644 index 0000000..40b236c --- /dev/null +++ b/js/videojs/test/unit/tracks/video-tracks.test.js @@ -0,0 +1,112 @@ +/* eslint-env qunit */ +import Html5 from '../../../src/js/tech/html5.js'; +import TestHelpers from '../test-helpers.js'; +import sinon from 'sinon'; + +QUnit.module('Video Tracks', { + beforeEach(assert) { + this.clock = sinon.useFakeTimers(); + }, + afterEach(assert) { + this.clock.restore(); + } +}); + +QUnit.test('Player track methods call the tech', function(assert) { + let calls = 0; + const player = TestHelpers.makePlayer(); + + player.tech_.videoTracks = function() { + calls++; + }; + + player.videoTracks(); + + assert.equal(calls, 1, 'videoTrack defers to the tech'); + player.dispose(); +}); + +QUnit.test('listen to remove and add track events in native video tracks', function(assert) { + const oldTestVid = Html5.TEST_VID; + const oldVideoTracks = Html5.prototype.videoTracks; + const events = {}; + + Html5.prototype.videoTracks = function() { + return { + removeEventListener() {}, + addEventListener(type, handler) { + events[type] = true; + } + }; + }; + + Html5.TEST_VID = { + videoTracks: [] + }; + + const player = { + // Function.prototype is a built-in no-op function. + controls() {}, + ready() {}, + options() { + return {}; + }, + addChild() {}, + id() {}, + el() { + return { + insertBefore() {}, + appendChild() {} + }; + } + }; + + player.player_ = player; + player.options_ = {}; + + const html = new Html5({}); + + assert.ok(events.removetrack, 'removetrack listener was added'); + assert.ok(events.addtrack, 'addtrack listener was added'); + + Html5.TEST_VID = oldTestVid; + Html5.prototype.videoTracks = oldVideoTracks; + + html.dispose(); +}); + +QUnit.test('html5 tech supports native video tracks if the video supports it', function(assert) { + const oldTestVid = Html5.TEST_VID; + + Html5.TEST_VID = { + videoTracks: [] + }; + + assert.ok(Html5.supportsNativeVideoTracks(), 'native video tracks are supported'); + + Html5.TEST_VID = oldTestVid; +}); + +QUnit.test('html5 tech does not support native video tracks if the video does not supports it', function(assert) { + const oldTestVid = Html5.TEST_VID; + + Html5.TEST_VID = {}; + + assert.ok(!Html5.supportsNativeVideoTracks(), 'native video tracks are not supported'); + + Html5.TEST_VID = oldTestVid; +}); + +QUnit.test('when switching techs, we should not get a new video track', function(assert) { + const player = TestHelpers.makePlayer(); + + player.loadTech_('TechFaker'); + const firstTracks = player.videoTracks(); + + player.loadTech_('TechFaker'); + const secondTracks = player.videoTracks(); + + assert.ok(firstTracks === secondTracks, 'the tracks are equal'); + + player.dispose(); +}); |
