Merge branch 'master' into jkarni/fix/descriptorsystemruntime

Conflicts:
	cms/djangoapps/contentstore/views/preview.py
This commit is contained in:
Julian Arni
2013-08-12 15:04:49 -04:00
126 changed files with 3513 additions and 4804 deletions

View File

@@ -210,27 +210,6 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time):
time.sleep(float(1))
@step('I have created a Video component$')
def i_created_a_video_component(step):
world.create_component_instance(
step, '.large-video-icon',
'video',
'.xmodule_VideoModule',
has_multiple_templates=False
)
@step('I have created a Video Alpha component$')
def i_created_video_alpha(step):
step.given('I have enabled the videoalpha advanced module')
world.css_click('a.course-link')
step.given('I have added a new subsection')
step.given('I expand the first section')
world.css_click('a.new-unit-item')
world.css_click('.large-advanced-icon')
world.click_component_from_menu('videoalpha', None, '.xmodule_VideoAlphaModule')
@step('I have enabled the (.*) advanced module$')
def i_enabled_the_advanced_module(step, module):
step.given('I have opened a new course section in Studio')
@@ -248,16 +227,6 @@ def open_new_unit(step):
world.css_click('a.new-unit-item')
@step('when I view the (video.*) it (.*) show the captions')
def shows_captions(_step, video_type, show_captions):
# Prevent cookies from overriding course settings
world.browser.cookies.delete('hide_captions')
if show_captions == 'does not':
assert world.css_has_class('.%s' % video_type, 'closed')
else:
assert world.is_css_not_present('.%s.closed' % video_type)
@step('the save button is disabled$')
def save_button_disabled(step):
button_css = '.action-save'

View File

@@ -1,16 +1,16 @@
Feature: Video Component Editor
As a course author, I want to be able to create video components.
Scenario: User can view metadata
Scenario: User can view Video metadata
Given I have created a Video component
And I edit and select Settings
Then I see the correct settings and default values
And I edit the component
Then I see the correct video settings and default values
Scenario: User can modify display name
Scenario: User can modify Video display name
Given I have created a Video component
And I edit and select Settings
And I edit the component
Then I can modify the display name
And my display name change is persisted on save
And my video display name change is persisted on save
Scenario: Captions are hidden when "show captions" is false
Given I have created a Video component

View File

@@ -2,18 +2,7 @@
# pylint: disable=C0111
from lettuce import world, step
@step('I see the correct settings and default values$')
def i_see_the_correct_settings_and_values(step):
world.verify_all_setting_entries([['Default Speed', 'OEoXaMPEzfM', False],
['Display Name', 'Video', False],
['Download Track', '', False],
['Download Video', '', False],
['Show Captions', 'True', False],
['Speed: .75x', '', False],
['Speed: 1.25x', '', False],
['Speed: 1.5x', '', False]])
from terrain.steps import reload_the_page
@step('I have set "show captions" to (.*)')
@@ -24,9 +13,19 @@ def set_show_captions(step, setting):
world.css_click('a.save-button')
@step('I see the correct videoalpha settings and default values$')
def correct_videoalpha_settings(_step):
world.verify_all_setting_entries([['Display Name', 'Video Alpha', False],
@step('when I view the (video.*) it (.*) show the captions')
def shows_captions(_step, video_type, show_captions):
# Prevent cookies from overriding course settings
world.browser.cookies.delete('hide_captions')
if show_captions == 'does not':
assert world.css_has_class('.%s' % video_type, 'closed')
else:
assert world.is_css_not_present('.%s.closed' % video_type)
@step('I see the correct video settings and default values$')
def correct_video_settings(_step):
world.verify_all_setting_entries([['Display Name', 'Video', False],
['Download Track', '', False],
['Download Video', '', False],
['End Time', '0', False],
@@ -38,3 +37,12 @@ def correct_videoalpha_settings(_step):
['Youtube ID for .75x speed', '', False],
['Youtube ID for 1.25x speed', '', False],
['Youtube ID for 1.5x speed', '', False]])
@step('my video display name change is persisted on save')
def video_name_persisted(step):
world.css_click('a.save-button')
reload_the_page(step)
world.edit_component()
world.verify_setting_entry(world.get_setting_entry('Display Name'), 'Display Name', '3.4', True)

View File

@@ -1,6 +1,7 @@
Feature: Video Component
As a course author, I want to be able to view my created videos in Studio.
# Video Alpha Features will work in Firefox only when Firefox is the active window
Scenario: Autoplay is disabled in Studio
Given I have created a Video component
Then when I view the video it does not have autoplay enabled
@@ -23,32 +24,6 @@ Feature: Video Component
And I have toggled captions
Then when I view the video it does show the captions
# Video Alpha Features will work in Firefox only when Firefox is the active window
Scenario: Autoplay is disabled in Studio for Video Alpha
Given I have created a Video Alpha component
Then when I view the videoalpha it does not have autoplay enabled
Scenario: User can view Video Alpha metadata
Given I have created a Video Alpha component
And I edit the component
Then I see the correct videoalpha settings and default values
Scenario: User can modify Video Alpha display name
Given I have created a Video Alpha component
And I edit the component
Then I can modify the display name
And my videoalpha display name change is persisted on save
Scenario: Video Alpha captions are hidden when "show captions" is false
Given I have created a Video Alpha component
And I have set "show captions" to False
Then when I view the videoalpha it does not show the captions
Scenario: Video Alpha captions are shown when "show captions" is true
Given I have created a Video Alpha component
And I have set "show captions" to True
Then when I view the videoalpha it does show the captions
Scenario: Video data is shown correctly
Given I have created a video with only XML data
Then the correct Youtube video is shown

View File

@@ -9,6 +9,16 @@ from contentstore.utils import get_modulestore
############### ACTIONS ####################
@step('I have created a Video component$')
def i_created_a_video_component(step):
world.create_component_instance(
step, '.large-video-icon',
'video',
'.xmodule_VideoModule',
has_multiple_templates=False
)
@step('when I view the (.*) it does not have autoplay enabled')
def does_not_autoplay(_step, video_type):
assert world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False'
@@ -22,6 +32,11 @@ def video_takes_a_single_click(_step):
assert(world.is_css_present('.xmodule_VideoModule'))
@step('I edit the component')
def i_edit_the_component(_step):
world.edit_component()
@step('I have (hidden|toggled) captions')
def hide_or_show_captions(step, shown):
button_css = 'a.hide-subtitles'
@@ -38,18 +53,6 @@ def hide_or_show_captions(step, shown):
button.mouse_out()
world.css_click(button_css)
@step('I edit the component')
def i_edit_the_component(_step):
world.edit_component()
@step('my videoalpha display name change is persisted on save')
def videoalpha_name_persisted(step):
world.css_click('a.save-button')
reload_the_page(step)
world.edit_component()
world.verify_setting_entry(world.get_setting_entry('Display Name'), 'Display Name', '3.4', True)
@step('I have created a video with only XML data')
def xml_only_video(step):
@@ -84,4 +87,5 @@ def xml_only_video(step):
@step('The correct Youtube video is shown')
def the_youtube_video_is_shown(_step):
ele = world.css_find('.video').first
assert ele['data-youtube-id-1-0'] == world.scenario_dict['YOUTUBE_ID']
assert ele['data-streams'].split(':')[1] == world.scenario_dict['YOUTUBE_ID']

View File

@@ -107,8 +107,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
expected_types is the list of elements that should appear on the page.
expected_types and component_types should be similar, but not
exactly the same -- for example, 'videoalpha' in
component_types should cause 'Video Alpha' to be present.
exactly the same -- for example, 'video' in
component_types should cause 'Video' to be present.
"""
store = modulestore('direct')
import_from_xml(store, 'common/test/data/', ['simple'])
@@ -136,14 +136,13 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
def test_advanced_components_in_edit_unit(self):
# This could be made better, but for now let's just assert that we see the advanced modules mentioned in the page
# response HTML
self.check_components_on_page(ADVANCED_COMPONENT_TYPES, ['Video Alpha',
'Word cloud',
self.check_components_on_page(ADVANCED_COMPONENT_TYPES, ['Word cloud',
'Annotation',
'Open Response Assessment',
'Peer Grading Interface'])
def test_advanced_components_require_two_clicks(self):
self.check_components_on_page(['videoalpha'], ['Video Alpha'])
self.check_components_on_page(['word_cloud'], ['Word cloud'])
def test_malformed_edit_unit_request(self):
store = modulestore('direct')
@@ -1354,7 +1353,7 @@ class ContentStoreTest(ModuleStoreTestCase):
'course': loc.course,
'name': loc.name}))
self.assertEqual(200, resp.status_code)
self.assert2XX(resp.status_code)
self.assertContains(resp, 'Chapter 2')
# go to various pages
@@ -1364,92 +1363,92 @@ class ContentStoreTest(ModuleStoreTestCase):
kwargs={'org': loc.org,
'course': loc.course,
'name': loc.name}))
self.assertEqual(200, resp.status_code)
self.assert2XX(resp.status_code)
# export page
resp = self.client.get(reverse('export_course',
kwargs={'org': loc.org,
'course': loc.course,
'name': loc.name}))
self.assertEqual(200, resp.status_code)
self.assert2XX(resp.status_code)
# manage users
resp = self.client.get(reverse('manage_users',
kwargs={'org': loc.org,
'course': loc.course,
'name': loc.name}))
self.assertEqual(200, resp.status_code)
self.assert2XX(resp.status_code)
# course info
resp = self.client.get(reverse('course_info',
kwargs={'org': loc.org,
'course': loc.course,
'name': loc.name}))
self.assertEqual(200, resp.status_code)
self.assert2XX(resp.status_code)
# settings_details
resp = self.client.get(reverse('settings_details',
kwargs={'org': loc.org,
'course': loc.course,
'name': loc.name}))
self.assertEqual(200, resp.status_code)
self.assert2XX(resp.status_code)
# settings_details
resp = self.client.get(reverse('settings_grading',
kwargs={'org': loc.org,
'course': loc.course,
'name': loc.name}))
self.assertEqual(200, resp.status_code)
self.assert2XX(resp.status_code)
# static_pages
resp = self.client.get(reverse('static_pages',
kwargs={'org': loc.org,
'course': loc.course,
'coursename': loc.name}))
self.assertEqual(200, resp.status_code)
self.assert2XX(resp.status_code)
# static_pages
resp = self.client.get(reverse('asset_index',
kwargs={'org': loc.org,
'course': loc.course,
'name': loc.name}))
self.assertEqual(200, resp.status_code)
self.assert2XX(resp.status_code)
# go look at a subsection page
subsection_location = loc.replace(category='sequential', name='test_sequence')
resp = self.client.get(reverse('edit_subsection',
kwargs={'location': subsection_location.url()}))
self.assertEqual(200, resp.status_code)
self.assert2XX(resp.status_code)
# go look at the Edit page
unit_location = loc.replace(category='vertical', name='test_vertical')
resp = self.client.get(reverse('edit_unit',
kwargs={'location': unit_location.url()}))
self.assertEqual(200, resp.status_code)
self.assert2XX(resp.status_code)
# delete a component
del_loc = loc.replace(category='html', name='test_html')
resp = self.client.post(reverse('delete_item'),
json.dumps({'id': del_loc.url()}), "application/json")
self.assertEqual(200, resp.status_code)
self.assert2XX(resp.status_code)
# delete a unit
del_loc = loc.replace(category='vertical', name='test_vertical')
resp = self.client.post(reverse('delete_item'),
json.dumps({'id': del_loc.url()}), "application/json")
self.assertEqual(200, resp.status_code)
self.assert2XX(resp.status_code)
# delete a unit
del_loc = loc.replace(category='sequential', name='test_sequence')
resp = self.client.post(reverse('delete_item'),
json.dumps({'id': del_loc.url()}), "application/json")
self.assertEqual(200, resp.status_code)
self.assert2XX(resp.status_code)
# delete a chapter
del_loc = loc.replace(category='chapter', name='chapter_2')
resp = self.client.post(reverse('delete_item'),
json.dumps({'id': del_loc.url()}), "application/json")
self.assertEqual(200, resp.status_code)
self.assert2XX(resp.status_code)
def test_import_into_new_course_id(self):
module_store = modulestore('direct')
@@ -1597,12 +1596,15 @@ class ContentStoreTest(ModuleStoreTestCase):
class MetadataSaveTestCase(ModuleStoreTestCase):
"""
Test that metadata is correctly decached.
"""
"""Test that metadata is correctly cached and decached."""
def setUp(self):
sample_xml = '''
CourseFactory.create(
org='edX', course='999', display_name='Robot Super Course')
course_location = Location(
['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
video_sample_xml = '''
<video display_name="Test Video"
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
show_captions="false"
@@ -1612,19 +1614,17 @@ class MetadataSaveTestCase(ModuleStoreTestCase):
<track src="http://www.example.com/track"/>
</video>
'''
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
self.video_descriptor = ItemFactory.create(
parent_location=course_location, category='video',
data={'data': video_sample_xml}
)
model_data = {'data': sample_xml}
self.descriptor = ItemFactory.create(parent_location=course_location, category='video', data=model_data)
def test_metadata_persistence(self):
def test_metadata_not_persistence(self):
"""
Test that descriptors which set metadata fields in their
constructor are correctly persisted.
constructor are correctly deleted.
"""
# We should start with a source field, from the XML's <source/> tag
self.assertIn('source', own_metadata(self.descriptor))
self.assertIn('html5_sources', own_metadata(self.video_descriptor))
attrs_to_strip = {
'show_captions',
'youtube_id_1_0',
@@ -1634,23 +1634,27 @@ class MetadataSaveTestCase(ModuleStoreTestCase):
'start_time',
'end_time',
'source',
'html5_sources',
'track'
}
# We strip out all metadata fields to reproduce a bug where
# constructors which set their fields (e.g. Video) didn't have
# those changes persisted. So in the end we have the XML data
# in `descriptor.data`, but not in the individual fields
fields = self.descriptor.fields
fields = self.video_descriptor.fields
location = self.video_descriptor.location
for field in fields:
if field.name in attrs_to_strip:
field.delete_from(self.descriptor)
field.delete_from(self.video_descriptor)
# Assert that we correctly stripped the field
self.assertNotIn('source', own_metadata(self.descriptor))
get_modulestore(self.descriptor.location).update_metadata(
self.descriptor.location,
own_metadata(self.descriptor)
self.assertNotIn('html5_sources', own_metadata(self.video_descriptor))
get_modulestore(location).update_metadata(
location,
own_metadata(self.video_descriptor)
)
module = get_modulestore(self.descriptor.location).get_item(self.descriptor.location)
# Assert that get_item correctly sets the metadata
self.assertIn('source', own_metadata(module))
module = get_modulestore(location).get_item(location)
self.assertNotIn('html5_sources', own_metadata(module))
def test_metadata_persistence(self):
# TODO: create the same test as `test_metadata_not_persistence`,
# but check persistence for some other module.
pass

View File

@@ -34,7 +34,7 @@ class DeleteItem(CourseTestCase):
resp.content,
"application/json"
)
self.assertEqual(resp.status_code, 200)
self.assert2XX(resp.status_code)
class TestCreateItem(CourseTestCase):

View File

@@ -49,7 +49,6 @@ NOTE_COMPONENT_TYPES = ['notes']
ADVANCED_COMPONENT_TYPES = [
'annotatable',
'word_cloud',
'videoalpha',
'graphical_slider_tool'
] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES
ADVANCED_COMPONENT_CATEGORY = 'advanced'

View File

@@ -1,15 +1,13 @@
import json
from uuid import uuid4
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.contrib.auth.decorators import login_required
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.inheritance import own_metadata
from util.json_request import expect_json
from util.json_request import expect_json, JsonResponse
from ..utils import get_modulestore
from .access import has_access
from .requests import _xmodule_recurse
@@ -20,6 +18,7 @@ __all__ = ['save_item', 'create_item', 'delete_item']
# cdodge: these are categories which should not be parented, they are detached from the hierarchy
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
@login_required
@expect_json
def save_item(request):
@@ -80,7 +79,7 @@ def save_item(request):
# commit to datastore
store.update_metadata(item_location, own_metadata(existing_item))
return HttpResponse()
return JsonResponse()
# [DHM] A hack until we implement a permanent soln. Proposed perm solution is to make namespace fields also top level
@@ -139,13 +138,17 @@ def create_item(request):
if display_name is not None:
metadata['display_name'] = display_name
get_modulestore(category).create_and_save_xmodule(dest_location, definition_data=data,
metadata=metadata, system=parent.system)
get_modulestore(category).create_and_save_xmodule(
dest_location,
definition_data=data,
metadata=metadata,
system=parent.system,
)
if category not in DETACHED_CATEGORIES:
get_modulestore(parent.location).update_children(parent_location, parent.children + [dest_location.url()])
return HttpResponse(json.dumps({'id': dest_location.url()}))
return JsonResponse({'id': dest_location.url()})
@login_required
@@ -184,4 +187,4 @@ def delete_item(request):
parent.children = children
modulestore('direct').update_children(parent.location, parent.children)
return HttpResponse()
return JsonResponse()

View File

@@ -82,7 +82,7 @@ def preview_component(request, location):
)
return render_to_response('component.html', {
'preview': get_module_previews(request, component)[0],
'preview': get_preview_html(request, component, 0),
'editor': component.runtime.render(component, None, 'studio_view').content,
})
@@ -169,15 +169,10 @@ def load_preview_module(request, preview_id, descriptor):
return module
def get_module_previews(request, descriptor):
def get_preview_html(request, descriptor, idx):
"""
Returns a list of preview XModule html contents. One preview is returned for each
pair of states returned by get_sample_state() for the supplied descriptor.
descriptor: An XModuleDescriptor
Returns the HTML returned by the XModule's student_view,
specified by the descriptor and idx.
"""
preview_html = []
for idx, (_instance_state, _shared_state) in enumerate(descriptor.get_sample_state()):
module = load_preview_module(request, str(idx), descriptor)
preview_html.append(module.get_html())
return preview_html
module = load_preview_module(request, str(idx), descriptor)
return module.runtime.render(module, None, "student_view").content

View File

@@ -107,6 +107,7 @@ DEFAULT_FEEDBACK_EMAIL = ENV_TOKENS.get('DEFAULT_FEEDBACK_EMAIL', DEFAULT_FEEDBA
ADMINS = ENV_TOKENS.get('ADMINS', ADMINS)
SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL)
MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS)
TECH_SUPPORT_EMAIL = ENV_TOKENS.get('TECH_SUPPORT_EMAIL', TECH_SUPPORT_EMAIL)
COURSES_WITH_UNSAFE_CODE = ENV_TOKENS.get("COURSES_WITH_UNSAFE_CODE", [])

View File

@@ -25,7 +25,7 @@ Longer TODO:
import sys
import lms.envs.common
from lms.envs.common import USE_TZ
from lms.envs.common import USE_TZ, TECH_SUPPORT_EMAIL
from path import path
############################ FEATURE CONFIGURATION #############################
@@ -39,9 +39,6 @@ MITX_FEATURES = {
'AUTH_USE_MIT_CERTIFICATES': False,
# do not display video when running automated acceptance tests
'STUB_VIDEO_FOR_TESTING': False,
# email address for studio staff (eg to request course creation)
'STUDIO_REQUEST_EMAIL': '',

View File

@@ -1,3 +1,11 @@
verifyInputType = (input, expectedType) ->
# Some browsers (e.g. FireFox) do not support the "number"
# input type. We can accept a "text" input instead
# and still get acceptable behavior in the UI.
if expectedType == 'number' and input.type != 'number'
expectedType = 'text'
expect(input.type).toBe(expectedType)
describe "Test Metadata Editor", ->
editorTemplate = readFixtures('metadata-editor.underscore')
numberEntryTemplate = readFixtures('metadata-number-entry.underscore')
@@ -113,7 +121,7 @@ describe "Test Metadata Editor", ->
verifyEntry = (index, display_name, type) ->
expect(childModels[index].get('display_name')).toBe(display_name)
expect(childViews[index].type).toBe(type)
verifyInputType(childViews[index], type)
verifyEntry(0, 'Display Name', 'text')
verifyEntry(1, 'Inputs', 'number')
@@ -164,7 +172,7 @@ describe "Test Metadata Editor", ->
assertInputType = (view, expectedType) ->
input = view.$el.find('.setting-input')
expect(input.length).toEqual(1)
expect(input[0].type).toEqual(expectedType)
verifyInputType(input[0], expectedType)
assertValueInView = (view, expectedValue) ->
expect(view.getValueFromEditor()).toEqual(expectedValue)

View File

@@ -2,7 +2,7 @@
// ====================
// Video Alpha
.xmodule_VideoAlphaModule {
.xmodule_VideoModule {
// display mode
&.xmodule_display {

View File

@@ -4,129 +4,105 @@ Parser and evaluator for FormulaResponse and NumericalResponse
Uses pyparsing to parse. Main function as of now is evaluator().
"""
import copy
import math
import operator
import re
import numbers
import numpy
import scipy.constants
import calcfunctions
# have numpy raise errors on functions outside its domain
# Have numpy ignore errors on functions outside its domain.
# See http://docs.scipy.org/doc/numpy/reference/generated/numpy.seterr.html
# TODO worry about thread safety/changing a global setting
numpy.seterr(all='ignore') # Also: 'ignore', 'warn' (default), 'raise'
from pyparsing import (Word, nums, Literal,
ZeroOrMore, MatchFirst,
Optional, Forward,
CaselessLiteral,
stringEnd, Suppress, Combine)
from pyparsing import (
Word, Literal, CaselessLiteral, ZeroOrMore, MatchFirst, Optional, Forward,
Group, ParseResults, stringEnd, Suppress, Combine, alphas, nums, alphanums
)
DEFAULT_FUNCTIONS = {'sin': numpy.sin,
'cos': numpy.cos,
'tan': numpy.tan,
'sec': calcfunctions.sec,
'csc': calcfunctions.csc,
'cot': calcfunctions.cot,
'sqrt': numpy.sqrt,
'log10': numpy.log10,
'log2': numpy.log2,
'ln': numpy.log,
'exp': numpy.exp,
'arccos': numpy.arccos,
'arcsin': numpy.arcsin,
'arctan': numpy.arctan,
'arcsec': calcfunctions.arcsec,
'arccsc': calcfunctions.arccsc,
'arccot': calcfunctions.arccot,
'abs': numpy.abs,
'fact': math.factorial,
'factorial': math.factorial,
'sinh': numpy.sinh,
'cosh': numpy.cosh,
'tanh': numpy.tanh,
'sech': calcfunctions.sech,
'csch': calcfunctions.csch,
'coth': calcfunctions.coth,
'arcsinh': numpy.arcsinh,
'arccosh': numpy.arccosh,
'arctanh': numpy.arctanh,
'arcsech': calcfunctions.arcsech,
'arccsch': calcfunctions.arccsch,
'arccoth': calcfunctions.arccoth
}
DEFAULT_VARIABLES = {'i': numpy.complex(0, 1),
'j': numpy.complex(0, 1),
'e': numpy.e,
'pi': numpy.pi,
'k': scipy.constants.k,
'c': scipy.constants.c,
'T': 298.15,
'q': scipy.constants.e
}
DEFAULT_FUNCTIONS = {
'sin': numpy.sin,
'cos': numpy.cos,
'tan': numpy.tan,
'sec': calcfunctions.sec,
'csc': calcfunctions.csc,
'cot': calcfunctions.cot,
'sqrt': numpy.sqrt,
'log10': numpy.log10,
'log2': numpy.log2,
'ln': numpy.log,
'exp': numpy.exp,
'arccos': numpy.arccos,
'arcsin': numpy.arcsin,
'arctan': numpy.arctan,
'arcsec': calcfunctions.arcsec,
'arccsc': calcfunctions.arccsc,
'arccot': calcfunctions.arccot,
'abs': numpy.abs,
'fact': math.factorial,
'factorial': math.factorial,
'sinh': numpy.sinh,
'cosh': numpy.cosh,
'tanh': numpy.tanh,
'sech': calcfunctions.sech,
'csch': calcfunctions.csch,
'coth': calcfunctions.coth,
'arcsinh': numpy.arcsinh,
'arccosh': numpy.arccosh,
'arctanh': numpy.arctanh,
'arcsech': calcfunctions.arcsech,
'arccsch': calcfunctions.arccsch,
'arccoth': calcfunctions.arccoth
}
DEFAULT_VARIABLES = {
'i': numpy.complex(0, 1),
'j': numpy.complex(0, 1),
'e': numpy.e,
'pi': numpy.pi,
'k': scipy.constants.k, # Boltzmann: 1.3806488e-23 (Joules/Kelvin)
'c': scipy.constants.c, # Light Speed: 2.998e8 (m/s)
'T': 298.15, # 0 deg C = T Kelvin
'q': scipy.constants.e # Fund. Charge: 1.602176565e-19 (Coulombs)
}
# We eliminated the following extreme suffixes:
# P (1e15), E (1e18), Z (1e21), Y (1e24),
# f (1e-15), a (1e-18), z (1e-21), y (1e-24)
# since they're rarely used, and potentially
# confusing. They may also conflict with variables if we ever allow e.g.
# 5R instead of 5*R
SUFFIXES = {'%': 0.01, 'k': 1e3, 'M': 1e6, 'G': 1e9, 'T': 1e12,
'c': 1e-2, 'm': 1e-3, 'u': 1e-6, 'n': 1e-9, 'p': 1e-12}
# P (1e15), E (1e18), Z (1e21), Y (1e24),
# f (1e-15), a (1e-18), z (1e-21), y (1e-24)
# since they're rarely used, and potentially confusing.
# They may also conflict with variables if we ever allow e.g.
# 5R instead of 5*R
SUFFIXES = {
'%': 0.01, 'k': 1e3, 'M': 1e6, 'G': 1e9, 'T': 1e12,
'c': 1e-2, 'm': 1e-3, 'u': 1e-6, 'n': 1e-9, 'p': 1e-12
}
class UndefinedVariable(Exception):
"""
Used to indicate the student input of a variable, which was unused by the
instructor.
Indicate when a student inputs a variable which was not expected.
"""
pass
def check_variables(string, variables):
"""
Confirm the only variables in string are defined.
Otherwise, raise an UndefinedVariable containing all bad variables.
Pyparsing uses a left-to-right parser, which makes a more
elegant approach pretty hopeless.
"""
general_whitespace = re.compile('[^\\w]+') # TODO consider non-ascii
# List of all alnums in string
possible_variables = re.split(general_whitespace, string)
bad_variables = []
for var in possible_variables:
if len(var) == 0:
continue
if var[0].isdigit(): # Skip things that begin with numbers
continue
if var not in variables:
bad_variables.append(var)
if len(bad_variables) > 0:
raise UndefinedVariable(' '.join(bad_variables))
def lower_dict(input_dict):
"""
takes each key in the dict and makes it lowercase, still mapping to the
same value.
Convert all keys in a dictionary to lowercase; keep their original values.
keep in mind that it is possible (but not useful?) to define different
Keep in mind that it is possible (but not useful?) to define different
variables that have the same lowercase representation. It would be hard to
tell which is used in the final dict and which isn't.
"""
return {k.lower(): v for k, v in input_dict.iteritems()}
# The following few functions define parse actions, which are run on lists of
# results from each parse component. They convert the strings and (previously
# The following few functions define evaluation actions, which are run on lists
# of results from each parse component. They convert the strings and (previously
# calculated) numbers into the number that component represents.
def super_float(text):
"""
Like float, but with si extensions. 1k goes to 1000
Like float, but with SI extensions. 1k goes to 1000.
"""
if text[-1] in SUFFIXES:
return float(text[:-1]) * SUFFIXES[text[-1]]
@@ -134,168 +110,314 @@ def super_float(text):
return float(text)
def number_parse_action(parse_result):
def eval_number(parse_result):
"""
Create a float out of its string parts
Create a float out of its string parts.
e.g. [ '7', '.', '13' ] -> [ 7.13 ]
Calls super_float above
e.g. [ '7.13', 'e', '3' ] -> 7130
Calls super_float above.
"""
return super_float("".join(parse_result))
def exp_parse_action(parse_result):
def eval_atom(parse_result):
"""
Take a list of numbers and exponentiate them, right to left
Return the value wrapped by the atom.
e.g. [ 3, 2, 3 ] (which is 3^2^3 = 3^(2^3)) -> 6561
In the case of parenthesis, ignore them.
"""
# pyparsing.ParseResults doesn't play well with reverse()
parse_result = reversed(parse_result)
# the result of an exponentiation is called a power
# Find first number in the list
result = next(k for k in parse_result if isinstance(k, numbers.Number))
return result
def eval_power(parse_result):
"""
Take a list of numbers and exponentiate them, right to left.
e.g. [ 2, 3, 2 ] -> 2^3^2 = 2^(3^2) -> 512
(not to be interpreted (2^3)^2 = 64)
"""
# `reduce` will go from left to right; reverse the list.
parse_result = reversed(
[k for k in parse_result
if isinstance(k, numbers.Number)] # Ignore the '^' marks.
)
# Having reversed it, raise `b` to the power of `a`.
power = reduce(lambda a, b: b ** a, parse_result)
return power
def parallel(parse_result):
def eval_parallel(parse_result):
"""
Compute numbers according to the parallel resistors operator
Compute numbers according to the parallel resistors operator.
BTW it is commutative. Its formula is given by
out = 1 / (1/in1 + 1/in2 + ...)
e.g. [ 1, 2 ] => 2/3
e.g. [ 1, 2 ] -> 2/3
Return NaN if there is a zero among the inputs
Return NaN if there is a zero among the inputs.
"""
# convert from pyparsing.ParseResults, which doesn't support '0 in parse_result'
parse_result = parse_result.asList()
if len(parse_result) == 1:
return parse_result[0]
if 0 in parse_result:
return float('nan')
reciprocals = [1. / e for e in parse_result]
reciprocals = [1. / e for e in parse_result
if isinstance(e, numbers.Number)]
return 1. / sum(reciprocals)
def sum_parse_action(parse_result):
def eval_sum(parse_result):
"""
Add the inputs
Add the inputs, keeping in mind their sign.
[ 1, '+', 2, '-', 3 ] -> 0
Allow a leading + or -
Allow a leading + or -.
"""
total = 0.0
current_op = operator.add
for token in parse_result:
if token is '+':
if token == '+':
current_op = operator.add
elif token is '-':
elif token == '-':
current_op = operator.sub
else:
total = current_op(total, token)
return total
def prod_parse_action(parse_result):
def eval_product(parse_result):
"""
Multiply the inputs
Multiply the inputs.
[ 1, '*', 2, '/', 3 ] => 0.66
[ 1, '*', 2, '/', 3 ] -> 0.66
"""
prod = 1.0
current_op = operator.mul
for token in parse_result:
if token is '*':
if token == '*':
current_op = operator.mul
elif token is '/':
elif token == '/':
current_op = operator.truediv
else:
prod = current_op(prod, token)
return prod
def evaluator(variables, functions, string, cs=False):
def add_defaults(variables, functions, case_sensitive):
"""
Evaluate an expression. Variables are passed as a dictionary
from string to value. Unary functions are passed as a dictionary
from string to function. Variables must be floats.
cs: Case sensitive
Create dictionaries with both the default and user-defined variables.
"""
all_variables = copy.copy(DEFAULT_VARIABLES)
all_functions = copy.copy(DEFAULT_FUNCTIONS)
all_variables = dict(DEFAULT_VARIABLES)
all_functions = dict(DEFAULT_FUNCTIONS)
all_variables.update(variables)
all_functions.update(functions)
if not cs:
string_cs = string.lower()
all_functions = lower_dict(all_functions)
if not case_sensitive:
all_variables = lower_dict(all_variables)
CasedLiteral = CaselessLiteral
else:
string_cs = string
CasedLiteral = Literal
all_functions = lower_dict(all_functions)
check_variables(string_cs, set(all_variables.keys() + all_functions.keys()))
return (all_variables, all_functions)
if string.strip() == "":
def evaluator(variables, functions, math_expr, case_sensitive=False):
"""
Evaluate an expression; that is, take a string of math and return a float.
-Variables are passed as a dictionary from string to value. They must be
python numbers.
-Unary functions are passed as a dictionary from string to function.
"""
# No need to go further.
if math_expr.strip() == "":
return float('nan')
# SI suffixes and percent
number_suffix = MatchFirst([Literal(k) for k in SUFFIXES.keys()])
plus_minus = Literal('+') | Literal('-')
times_div = Literal('*') | Literal('/')
# Parse the tree.
math_interpreter = ParseAugmenter(math_expr, case_sensitive)
math_interpreter.parse_algebra()
number_part = Word(nums)
# Get our variables together.
all_variables, all_functions = add_defaults(variables, functions, case_sensitive)
# 0.33 or 7 or .34 or 16.
inner_number = (number_part + Optional("." + Optional(number_part))) | ("." + number_part)
# by default pyparsing allows spaces between tokens--Combine prevents that
inner_number = Combine(inner_number)
# ...and check them
math_interpreter.check_variables(all_variables, all_functions)
# 0.33k or -17
number = (inner_number
+ Optional(CaselessLiteral("E") + Optional(plus_minus) + number_part)
+ Optional(number_suffix))
number.setParseAction(number_parse_action) # Convert to number
# Create a recursion to evaluate the tree.
if case_sensitive:
casify = lambda x: x
else:
casify = lambda x: x.lower() # Lowercase for case insens.
# Predefine recursive variables
expr = Forward()
evaluate_actions = {
'number': eval_number,
'variable': lambda x: all_variables[casify(x[0])],
'function': lambda x: all_functions[casify(x[0])](x[1]),
'atom': eval_atom,
'power': eval_power,
'parallel': eval_parallel,
'product': eval_product,
'sum': eval_sum
}
# Handle variables passed in.
# E.g. if we have {'R':0.5}, we make the substitution.
# We sort the list so that var names (like "e2") match before
# mathematical constants (like "e"). This is kind of a hack.
all_variables_keys = sorted(all_variables.keys(), key=len, reverse=True)
varnames = MatchFirst([CasedLiteral(k) for k in all_variables_keys])
varnames.setParseAction(
lambda x: [all_variables[k] for k in x]
)
return math_interpreter.reduce_tree(evaluate_actions)
# if all_variables were empty, then pyparsing wants
# varnames = NoMatch()
# this is not the case, as all_variables contains the defaults
# Same thing for functions.
all_functions_keys = sorted(all_functions.keys(), key=len, reverse=True)
funcnames = MatchFirst([CasedLiteral(k) for k in all_functions_keys])
function = funcnames + Suppress("(") + expr + Suppress(")")
function.setParseAction(
lambda x: [all_functions[x[0]](x[1])]
)
class ParseAugmenter(object):
"""
Holds the data for a particular parse.
atom = number | function | varnames | Suppress("(") + expr + Suppress(")")
Retains the `math_expr` and `case_sensitive` so they needn't be passed
around method to method.
Eventually holds the parse tree and sets of variables as well.
"""
def __init__(self, math_expr, case_sensitive=False):
"""
Create the ParseAugmenter for a given math expression string.
# Do the following in the correct order to preserve order of operation
pow_term = atom + ZeroOrMore(Suppress("^") + atom)
pow_term.setParseAction(exp_parse_action) # 7^6
par_term = pow_term + ZeroOrMore(Suppress('||') + pow_term) # 5k || 4k
par_term.setParseAction(parallel)
prod_term = par_term + ZeroOrMore(times_div + par_term) # 7 * 5 / 4 - 3
prod_term.setParseAction(prod_parse_action)
sum_term = Optional(plus_minus) + prod_term + ZeroOrMore(plus_minus + prod_term) # -5 + 4 - 3
sum_term.setParseAction(sum_parse_action)
expr << sum_term # finish the recursion
return (expr + stringEnd).parseString(string)[0]
Do the parsing later, when called like `OBJ.parse_algebra()`.
"""
self.case_sensitive = case_sensitive
self.math_expr = math_expr
self.tree = None
self.variables_used = set()
self.functions_used = set()
def vpa(tokens):
"""
When a variable is recognized, store it in `variables_used`.
"""
varname = tokens[0][0]
self.variables_used.add(varname)
def fpa(tokens):
"""
When a function is recognized, store it in `functions_used`.
"""
varname = tokens[0][0]
self.functions_used.add(varname)
self.variable_parse_action = vpa
self.function_parse_action = fpa
def parse_algebra(self):
"""
Parse an algebraic expression into a tree.
Store a `pyparsing.ParseResult` in `self.tree` with proper groupings to
reflect parenthesis and order of operations. Leave all operators in the
tree and do not parse any strings of numbers into their float versions.
Adding the groups and result names makes the `repr()` of the result
really gross. For debugging, use something like
print OBJ.tree.asXML()
"""
# 0.33 or 7 or .34 or 16.
number_part = Word(nums)
inner_number = (number_part + Optional("." + Optional(number_part))) | ("." + number_part)
# pyparsing allows spaces between tokens--`Combine` prevents that.
inner_number = Combine(inner_number)
# SI suffixes and percent.
number_suffix = MatchFirst(Literal(k) for k in SUFFIXES.keys())
# 0.33k or 17
plus_minus = Literal('+') | Literal('-')
number = Group(
Optional(plus_minus) +
inner_number +
Optional(CaselessLiteral("E") + Optional(plus_minus) + number_part) +
Optional(number_suffix)
)
number = number("number")
# Predefine recursive variables.
expr = Forward()
# Handle variables passed in. They must start with letters/underscores
# and may contain numbers afterward.
inner_varname = Word(alphas + "_", alphanums + "_")
varname = Group(inner_varname)("variable")
varname.setParseAction(self.variable_parse_action)
# Same thing for functions.
function = Group(inner_varname + Suppress("(") + expr + Suppress(")"))("function")
function.setParseAction(self.function_parse_action)
atom = number | function | varname | "(" + expr + ")"
atom = Group(atom)("atom")
# Do the following in the correct order to preserve order of operation.
pow_term = atom + ZeroOrMore("^" + atom)
pow_term = Group(pow_term)("power")
par_term = pow_term + ZeroOrMore('||' + pow_term) # 5k || 4k
par_term = Group(par_term)("parallel")
prod_term = par_term + ZeroOrMore((Literal('*') | Literal('/')) + par_term) # 7 * 5 / 4
prod_term = Group(prod_term)("product")
sum_term = Optional(plus_minus) + prod_term + ZeroOrMore(plus_minus + prod_term) # -5 + 4 - 3
sum_term = Group(sum_term)("sum")
# Finish the recursion.
expr << sum_term # pylint: disable=W0104
self.tree = (expr + stringEnd).parseString(self.math_expr)[0]
def reduce_tree(self, handle_actions, terminal_converter=None):
"""
Call `handle_actions` recursively on `self.tree` and return result.
`handle_actions` is a dictionary of node names (e.g. 'product', 'sum',
etc&) to functions. These functions are of the following form:
-input: a list of processed child nodes. If it includes any terminal
nodes in the list, they will be given as their processed forms also.
-output: whatever to be passed to the level higher, and what to
return for the final node.
`terminal_converter` is a function that takes in a token and returns a
processed form. The default of `None` just leaves them as strings.
"""
def handle_node(node):
"""
Return the result representing the node, using recursion.
Call the appropriate `handle_action` for this node. As its inputs,
feed it the output of `handle_node` for each child node.
"""
if not isinstance(node, ParseResults):
# Then treat it as a terminal node.
if terminal_converter is None:
return node
else:
return terminal_converter(node)
node_name = node.getName()
if node_name not in handle_actions: # pragma: no cover
raise Exception(u"Unknown branch name '{}'".format(node_name))
action = handle_actions[node_name]
handled_kids = [handle_node(k) for k in node]
return action(handled_kids)
# Find the value of the entire tree.
return handle_node(self.tree)
def check_variables(self, valid_variables, valid_functions):
"""
Confirm that all the variables used in the tree are valid/defined.
Otherwise, raise an UndefinedVariable containing all bad variables.
"""
if self.case_sensitive:
casify = lambda x: x
else:
casify = lambda x: x.lower() # Lowercase for case insens.
# Test if casify(X) is valid, but return the actual bad input (i.e. X)
bad_vars = set(var for var in self.variables_used
if casify(var) not in valid_variables)
bad_vars.update(func for func in self.functions_used
if casify(func) not in valid_functions)
if bad_vars:
raise UndefinedVariable(' '.join(sorted(bad_vars)))

390
common/lib/calc/preview.py Normal file
View File

@@ -0,0 +1,390 @@
"""
Provide a `latex_preview` method similar in syntax to `evaluator`.
That is, given a math string, parse it and render each branch of the result,
always returning valid latex.
Because intermediate values of the render contain more data than simply the
string of latex, store it in a custom class `LatexRendered`.
"""
from calc import ParseAugmenter, DEFAULT_VARIABLES, DEFAULT_FUNCTIONS, SUFFIXES
class LatexRendered(object):
"""
Data structure to hold a typeset representation of some math.
Fields:
-`latex` is a generated, valid latex string (as if it were standalone).
-`sans_parens` is usually the same as `latex` except without the outermost
parens (if applicable).
-`tall` is a boolean representing if the latex has any elements extending
above or below a normal height, specifically things of the form 'a^b' and
'\frac{a}{b}'. This affects the height of wrapping parenthesis.
"""
def __init__(self, latex, parens=None, tall=False):
"""
Instantiate with the latex representing the math.
Optionally include parenthesis to wrap around it and the height.
`parens` must be one of '(', '[' or '{'.
`tall` is a boolean (see note above).
"""
self.latex = latex
self.sans_parens = latex
self.tall = tall
# Generate parens and overwrite `self.latex`.
if parens is not None:
left_parens = parens
if left_parens == '{':
left_parens = r'\{'
pairs = {'(': ')',
'[': ']',
r'\{': r'\}'}
if left_parens not in pairs:
raise Exception(
u"Unknown parenthesis '{}': coder error".format(left_parens)
)
right_parens = pairs[left_parens]
if self.tall:
left_parens = r"\left" + left_parens
right_parens = r"\right" + right_parens
self.latex = u"{left}{expr}{right}".format(
left=left_parens,
expr=latex,
right=right_parens
)
def __repr__(self): # pragma: no cover
"""
Give a sensible representation of the object.
If `sans_parens` is different, include both.
If `tall` then have '<[]>' around the code, otherwise '<>'.
"""
if self.latex == self.sans_parens:
latex_repr = u'"{}"'.format(self.latex)
else:
latex_repr = u'"{}" or "{}"'.format(self.latex, self.sans_parens)
if self.tall:
wrap = u'<[{}]>'
else:
wrap = u'<{}>'
return wrap.format(latex_repr)
def render_number(children):
"""
Combine the elements forming the number, escaping the suffix if needed.
"""
children_latex = [k.latex for k in children]
suffix = ""
if children_latex[-1] in SUFFIXES:
suffix = children_latex.pop()
suffix = ur"\text{{{s}}}".format(s=suffix)
# Exponential notation-- the "E" splits the mantissa and exponent
if "E" in children_latex:
pos = children_latex.index("E")
mantissa = "".join(children_latex[:pos])
exponent = "".join(children_latex[pos + 1:])
latex = ur"{m}\!\times\!10^{{{e}}}{s}".format(
m=mantissa, e=exponent, s=suffix
)
return LatexRendered(latex, tall=True)
else:
easy_number = "".join(children_latex)
return LatexRendered(easy_number + suffix)
def enrich_varname(varname):
"""
Prepend a backslash if we're given a greek character.
"""
greek = ("alpha beta gamma delta epsilon varepsilon zeta eta theta "
"vartheta iota kappa lambda mu nu xi pi rho sigma tau upsilon "
"phi varphi chi psi omega").split()
if varname in greek:
return ur"\{letter}".format(letter=varname)
else:
return varname.replace("_", r"\_")
def variable_closure(variables, casify):
"""
Wrap `render_variable` so it knows the variables allowed.
"""
def render_variable(children):
"""
Replace greek letters, otherwise escape the variable names.
"""
varname = children[0].latex
if casify(varname) not in variables:
pass # TODO turn unknown variable red or give some kind of error
first, _, second = varname.partition("_")
if second:
# Then 'a_b' must become 'a_{b}'
varname = ur"{a}_{{{b}}}".format(
a=enrich_varname(first),
b=enrich_varname(second)
)
else:
varname = enrich_varname(varname)
return LatexRendered(varname) # .replace("_", r"\_"))
return render_variable
def function_closure(functions, casify):
"""
Wrap `render_function` so it knows the functions allowed.
"""
def render_function(children):
"""
Escape function names and give proper formatting to exceptions.
The exceptions being 'sqrt', 'log2', and 'log10' as of now.
"""
fname = children[0].latex
if casify(fname) not in functions:
pass # TODO turn unknown function red or give some kind of error
# Wrap the input of the function with parens or braces.
inner = children[1].latex
if fname == "sqrt":
inner = u"{{{expr}}}".format(expr=inner)
else:
if children[1].tall:
inner = ur"\left({expr}\right)".format(expr=inner)
else:
inner = u"({expr})".format(expr=inner)
# Correctly format the name of the function.
if fname == "sqrt":
fname = ur"\sqrt"
elif fname == "log10":
fname = ur"\log_{10}"
elif fname == "log2":
fname = ur"\log_2"
else:
fname = ur"\text{{{fname}}}".format(fname=fname)
# Put it together.
latex = fname + inner
return LatexRendered(latex, tall=children[1].tall)
# Return the function within the closure.
return render_function
def render_power(children):
"""
Combine powers so that the latex is wrapped in curly braces correctly.
Also, if you have 'a^(b+c)' don't include that last set of parens:
'a^{b+c}' is correct, whereas 'a^{(b+c)}' is extraneous.
"""
if len(children) == 1:
return children[0]
children_latex = [k.latex for k in children if k.latex != "^"]
children_latex[-1] = children[-1].sans_parens
raise_power = lambda x, y: u"{}^{{{}}}".format(y, x)
latex = reduce(raise_power, reversed(children_latex))
return LatexRendered(latex, tall=True)
def render_parallel(children):
"""
Simply join the child nodes with a double vertical line.
"""
if len(children) == 1:
return children[0]
children_latex = [k.latex for k in children if k.latex != "||"]
latex = r"\|".join(children_latex)
tall = any(k.tall for k in children)
return LatexRendered(latex, tall=tall)
def render_frac(numerator, denominator):
r"""
Given a list of elements in the numerator and denominator, return a '\frac'
Avoid parens if they are unnecessary (i.e. the only thing in that part).
"""
if len(numerator) == 1:
num_latex = numerator[0].sans_parens
else:
num_latex = r"\cdot ".join(k.latex for k in numerator)
if len(denominator) == 1:
den_latex = denominator[0].sans_parens
else:
den_latex = r"\cdot ".join(k.latex for k in denominator)
latex = ur"\frac{{{num}}}{{{den}}}".format(num=num_latex, den=den_latex)
return latex
def render_product(children):
r"""
Format products and division nicely.
Group bunches of adjacent, equal operators. Every time it switches from
denominator to the next numerator, call `render_frac`. Join these groupings
together with '\cdot's, ending on a numerator if needed.
Examples: (`children` is formed indirectly by the string on the left)
'a*b' -> 'a\cdot b'
'a/b' -> '\frac{a}{b}'
'a*b/c/d' -> '\frac{a\cdot b}{c\cdot d}'
'a/b*c/d*e' -> '\frac{a}{b}\cdot \frac{c}{d}\cdot e'
"""
if len(children) == 1:
return children[0]
position = "numerator" # or denominator
fraction_mode_ever = False
numerator = []
denominator = []
latex = ""
for kid in children:
if position == "numerator":
if kid.latex == "*":
pass # Don't explicitly add the '\cdot' yet.
elif kid.latex == "/":
# Switch to denominator mode.
fraction_mode_ever = True
position = "denominator"
else:
numerator.append(kid)
else:
if kid.latex == "*":
# Switch back to numerator mode.
# First, render the current fraction and add it to the latex.
latex += render_frac(numerator, denominator) + r"\cdot "
# Reset back to beginning state
position = "numerator"
numerator = []
denominator = []
elif kid.latex == "/":
pass # Don't explicitly add a '\frac' yet.
else:
denominator.append(kid)
# Add the fraction/numerator that we ended on.
if position == "denominator":
latex += render_frac(numerator, denominator)
else:
# We ended on a numerator--act like normal multiplication.
num_latex = r"\cdot ".join(k.latex for k in numerator)
latex += num_latex
tall = fraction_mode_ever or any(k.tall for k in children)
return LatexRendered(latex, tall=tall)
def render_sum(children):
"""
Concatenate elements, including the operators.
"""
if len(children) == 1:
return children[0]
children_latex = [k.latex for k in children]
latex = "".join(children_latex)
tall = any(k.tall for k in children)
return LatexRendered(latex, tall=tall)
def render_atom(children):
"""
Properly handle parens, otherwise this is trivial.
"""
if len(children) == 3:
return LatexRendered(
children[1].latex,
parens=children[0].latex,
tall=children[1].tall
)
else:
return children[0]
def add_defaults(var, fun, case_sensitive=False):
"""
Create sets with both the default and user-defined variables.
Compare to calc.add_defaults
"""
var_items = set(DEFAULT_VARIABLES)
fun_items = set(DEFAULT_FUNCTIONS)
var_items.update(var)
fun_items.update(fun)
if not case_sensitive:
var_items = set(k.lower() for k in var_items)
fun_items = set(k.lower() for k in fun_items)
return var_items, fun_items
def latex_preview(math_expr, variables=(), functions=(), case_sensitive=False):
"""
Convert `math_expr` into latex, guaranteeing its parse-ability.
Analagous to `evaluator`.
"""
# No need to go further
if math_expr.strip() == "":
return ""
# Parse tree
latex_interpreter = ParseAugmenter(math_expr, case_sensitive)
latex_interpreter.parse_algebra()
# Get our variables together.
variables, functions = add_defaults(variables, functions, case_sensitive)
# Create a recursion to evaluate the tree.
if case_sensitive:
casify = lambda x: x
else:
casify = lambda x: x.lower() # Lowercase for case insens.
render_actions = {
'number': render_number,
'variable': variable_closure(variables, casify),
'function': function_closure(functions, casify),
'atom': render_atom,
'power': render_power,
'parallel': render_parallel,
'product': render_product,
'sum': render_sum
}
backslash = "\\"
wrap_escaped_strings = lambda s: LatexRendered(
s.replace(backslash, backslash * 2)
)
output = latex_interpreter.reduce_tree(
render_actions,
terminal_converter=wrap_escaped_strings
)
return output.latex

View File

@@ -14,7 +14,7 @@ class EvaluatorTest(unittest.TestCase):
Go through all functionalities as specifically as possible--
work from number input to functions and complex expressions
Also test custom variable substitutions (i.e.
`evaluator({'x':3.0},{}, '3*x')`
`evaluator({'x':3.0}, {}, '3*x')`
gives 9.0) and more.
"""
@@ -41,37 +41,40 @@ class EvaluatorTest(unittest.TestCase):
"""
The string '.' should not evaluate to anything.
"""
self.assertRaises(ParseException, calc.evaluator, {}, {}, '.')
self.assertRaises(ParseException, calc.evaluator, {}, {}, '1+.')
with self.assertRaises(ParseException):
calc.evaluator({}, {}, '.')
with self.assertRaises(ParseException):
calc.evaluator({}, {}, '1+.')
def test_trailing_period(self):
"""
Test that things like '4.' will be 4 and not throw an error
"""
try:
self.assertEqual(4.0, calc.evaluator({}, {}, '4.'))
except ParseException:
self.fail("'4.' is a valid input, but threw an exception")
self.assertEqual(4.0, calc.evaluator({}, {}, '4.'))
def test_exponential_answer(self):
"""
Test for correct interpretation of scientific notation
"""
answer = 50
correct_responses = ["50", "50.0", "5e1", "5e+1",
"50e0", "50.0e0", "500e-1"]
correct_responses = [
"50", "50.0", "5e1", "5e+1",
"50e0", "50.0e0", "500e-1"
]
incorrect_responses = ["", "3.9", "4.1", "0", "5.01e1"]
for input_str in correct_responses:
result = calc.evaluator({}, {}, input_str)
fail_msg = "Expected '{0}' to equal {1}".format(
input_str, answer)
input_str, answer
)
self.assertEqual(answer, result, msg=fail_msg)
for input_str in incorrect_responses:
result = calc.evaluator({}, {}, input_str)
fail_msg = "Expected '{0}' to not equal {1}".format(
input_str, answer)
input_str, answer
)
self.assertNotEqual(answer, result, msg=fail_msg)
def test_si_suffix(self):
@@ -80,17 +83,21 @@ class EvaluatorTest(unittest.TestCase):
For instance 'k' stand for 'kilo-' so '1k' should be 1,000
"""
test_mapping = [('4.2%', 0.042), ('2.25k', 2250), ('8.3M', 8300000),
('9.9G', 9.9e9), ('1.2T', 1.2e12), ('7.4c', 0.074),
('5.4m', 0.0054), ('8.7u', 0.0000087),
('5.6n', 5.6e-9), ('4.2p', 4.2e-12)]
test_mapping = [
('4.2%', 0.042), ('2.25k', 2250), ('8.3M', 8300000),
('9.9G', 9.9e9), ('1.2T', 1.2e12), ('7.4c', 0.074),
('5.4m', 0.0054), ('8.7u', 0.0000087),
('5.6n', 5.6e-9), ('4.2p', 4.2e-12)
]
for (expr, answer) in test_mapping:
tolerance = answer * 1e-6 # Make rel. tolerance, because of floats
fail_msg = "Failure in testing suffix '{0}': '{1}' was not {2}"
fail_msg = fail_msg.format(expr[-1], expr, answer)
self.assertAlmostEqual(calc.evaluator({}, {}, expr), answer,
delta=tolerance, msg=fail_msg)
self.assertAlmostEqual(
calc.evaluator({}, {}, expr), answer,
delta=tolerance, msg=fail_msg
)
def test_operator_sanity(self):
"""
@@ -104,19 +111,20 @@ class EvaluatorTest(unittest.TestCase):
input_str = "{0} {1} {2}".format(var1, operator, var2)
result = calc.evaluator({}, {}, input_str)
fail_msg = "Failed on operator '{0}': '{1}' was not {2}".format(
operator, input_str, answer)
operator, input_str, answer
)
self.assertEqual(answer, result, msg=fail_msg)
def test_raises_zero_division_err(self):
"""
Ensure division by zero gives an error
"""
self.assertRaises(ZeroDivisionError, calc.evaluator,
{}, {}, '1/0')
self.assertRaises(ZeroDivisionError, calc.evaluator,
{}, {}, '1/0.0')
self.assertRaises(ZeroDivisionError, calc.evaluator,
{'x': 0.0}, {}, '1/x')
with self.assertRaises(ZeroDivisionError):
calc.evaluator({}, {}, '1/0')
with self.assertRaises(ZeroDivisionError):
calc.evaluator({}, {}, '1/0.0')
with self.assertRaises(ZeroDivisionError):
calc.evaluator({'x': 0.0}, {}, '1/x')
def test_parallel_resistors(self):
"""
@@ -153,7 +161,8 @@ class EvaluatorTest(unittest.TestCase):
input_str = "{0}({1})".format(fname, arg)
result = calc.evaluator({}, {}, input_str)
fail_msg = "Failed on function {0}: '{1}' was not {2}".format(
fname, input_str, val)
fname, input_str, val
)
self.assertAlmostEqual(val, result, delta=tolerance, msg=fail_msg)
def test_trig_functions(self):
@@ -303,21 +312,29 @@ class EvaluatorTest(unittest.TestCase):
"""
# Test sqrt
self.assert_function_values('sqrt',
[0, 1, 2, 1024], # -1
[0, 1, 1.414, 32]) # 1j
self.assert_function_values(
'sqrt',
[0, 1, 2, 1024], # -1
[0, 1, 1.414, 32] # 1j
)
# sqrt(-1) is NAN not j (!!).
# Test logs
self.assert_function_values('log10',
[0.1, 1, 3.162, 1000000, '1+j'],
[-1, 0, 0.5, 6, 0.151 + 0.341j])
self.assert_function_values('log2',
[0.5, 1, 1.414, 1024, '1+j'],
[-1, 0, 0.5, 10, 0.5 + 1.133j])
self.assert_function_values('ln',
[0.368, 1, 1.649, 2.718, 42, '1+j'],
[-1, 0, 0.5, 1, 3.738, 0.347 + 0.785j])
self.assert_function_values(
'log10',
[0.1, 1, 3.162, 1000000, '1+j'],
[-1, 0, 0.5, 6, 0.151 + 0.341j]
)
self.assert_function_values(
'log2',
[0.5, 1, 1.414, 1024, '1+j'],
[-1, 0, 0.5, 10, 0.5 + 1.133j]
)
self.assert_function_values(
'ln',
[0.368, 1, 1.649, 2.718, 42, '1+j'],
[-1, 0, 0.5, 1, 3.738, 0.347 + 0.785j]
)
# Test abs
self.assert_function_values('abs', [-1, 0, 1, 'j'], [1, 0, 1, 1])
@@ -341,26 +358,28 @@ class EvaluatorTest(unittest.TestCase):
"""
# Of the form ('expr', python value, tolerance (or None for exact))
default_variables = [('j', 1j, None),
('e', 2.7183, 1e-3),
('pi', 3.1416, 1e-3),
# c = speed of light
('c', 2.998e8, 1e5),
# 0 deg C = T Kelvin
('T', 298.15, 0.01),
# Note k = scipy.constants.k = 1.3806488e-23
('k', 1.3806488e-23, 1e-26),
# Note q = scipy.constants.e = 1.602176565e-19
('q', 1.602176565e-19, 1e-22)]
default_variables = [
('i', 1j, None),
('j', 1j, None),
('e', 2.7183, 1e-4),
('pi', 3.1416, 1e-4),
('k', 1.3806488e-23, 1e-26), # Boltzmann constant (Joules/Kelvin)
('c', 2.998e8, 1e5), # Light Speed in (m/s)
('T', 298.15, 0.01), # 0 deg C = T Kelvin
('q', 1.602176565e-19, 1e-22) # Fund. Charge (Coulombs)
]
for (variable, value, tolerance) in default_variables:
fail_msg = "Failed on constant '{0}', not within bounds".format(
variable)
variable
)
result = calc.evaluator({}, {}, variable)
if tolerance is None:
self.assertEqual(value, result, msg=fail_msg)
else:
self.assertAlmostEqual(value, result,
delta=tolerance, msg=fail_msg)
self.assertAlmostEqual(
value, result,
delta=tolerance, msg=fail_msg
)
def test_complex_expression(self):
"""
@@ -370,21 +389,51 @@ class EvaluatorTest(unittest.TestCase):
self.assertAlmostEqual(
calc.evaluator({}, {}, "(2^2+1.0)/sqrt(5e0)*5-1"),
10.180,
delta=1e-3)
delta=1e-3
)
self.assertAlmostEqual(
calc.evaluator({}, {}, "1+1/(1+1/(1+1/(1+1)))"),
1.6,
delta=1e-3)
delta=1e-3
)
self.assertAlmostEqual(
calc.evaluator({}, {}, "10||sin(7+5)"),
-0.567, delta=0.01)
self.assertAlmostEqual(calc.evaluator({}, {}, "sin(e)"),
0.41, delta=0.01)
self.assertAlmostEqual(calc.evaluator({}, {}, "k*T/q"),
0.025, delta=1e-3)
self.assertAlmostEqual(calc.evaluator({}, {}, "e^(j*pi)"),
-1, delta=1e-5)
-0.567, delta=0.01
)
self.assertAlmostEqual(
calc.evaluator({}, {}, "sin(e)"),
0.41, delta=0.01
)
self.assertAlmostEqual(
calc.evaluator({}, {}, "k*T/q"),
0.025, delta=1e-3
)
self.assertAlmostEqual(
calc.evaluator({}, {}, "e^(j*pi)"),
-1, delta=1e-5
)
def test_explicit_sci_notation(self):
"""
Expressions like 1.6*10^-3 (not 1.6e-3) it should evaluate.
"""
self.assertEqual(
calc.evaluator({}, {}, "-1.6*10^-3"),
-0.0016
)
self.assertEqual(
calc.evaluator({}, {}, "-1.6*10^(-3)"),
-0.0016
)
self.assertEqual(
calc.evaluator({}, {}, "-1.6*10^3"),
-1600
)
self.assertEqual(
calc.evaluator({}, {}, "-1.6*10^(3)"),
-1600
)
def test_simple_vars(self):
"""
@@ -404,19 +453,24 @@ class EvaluatorTest(unittest.TestCase):
self.assertEqual(calc.evaluator(variables, {}, 'loooooong'), 6.4)
# Test a simple equation
self.assertAlmostEqual(calc.evaluator(variables, {}, '3*x-y'),
21.25, delta=0.01) # = 3 * 9.72 - 7.91
self.assertAlmostEqual(calc.evaluator(variables, {}, 'x*y'),
76.89, delta=0.01)
self.assertAlmostEqual(
calc.evaluator(variables, {}, '3*x-y'),
21.25, delta=0.01 # = 3 * 9.72 - 7.91
)
self.assertAlmostEqual(
calc.evaluator(variables, {}, 'x*y'),
76.89, delta=0.01
)
self.assertEqual(calc.evaluator({'x': 9.72, 'y': 7.91}, {}, "13"), 13)
self.assertEqual(calc.evaluator(variables, {}, "13"), 13)
self.assertEqual(
calc.evaluator({
'a': 2.2997471478310274, 'k': 9, 'm': 8,
'x': 0.66009498411213041},
{}, "5"),
5)
calc.evaluator(
{'a': 2.2997471478310274, 'k': 9, 'm': 8, 'x': 0.6600949841121},
{}, "5"
),
5
)
def test_variable_case_sensitivity(self):
"""
@@ -424,15 +478,21 @@ class EvaluatorTest(unittest.TestCase):
"""
self.assertEqual(
calc.evaluator({'R1': 2.0, 'R3': 4.0}, {}, "r1*r3"),
8.0)
8.0
)
variables = {'t': 1.0}
self.assertEqual(calc.evaluator(variables, {}, "t"), 1.0)
self.assertEqual(calc.evaluator(variables, {}, "T"), 1.0)
self.assertEqual(calc.evaluator(variables, {}, "t", cs=True), 1.0)
self.assertEqual(
calc.evaluator(variables, {}, "t", case_sensitive=True),
1.0
)
# Recall 'T' is a default constant, with value 298.15
self.assertAlmostEqual(calc.evaluator(variables, {}, "T", cs=True),
298, delta=0.2)
self.assertAlmostEqual(
calc.evaluator(variables, {}, "T", case_sensitive=True),
298, delta=0.2
)
def test_simple_funcs(self):
"""
@@ -445,22 +505,41 @@ class EvaluatorTest(unittest.TestCase):
self.assertEqual(calc.evaluator(variables, functions, 'id(x)'), 4.712)
functions.update({'f': numpy.sin})
self.assertAlmostEqual(calc.evaluator(variables, functions, 'f(x)'),
-1, delta=1e-3)
self.assertAlmostEqual(
calc.evaluator(variables, functions, 'f(x)'),
-1, delta=1e-3
)
def test_function_case_sensitivity(self):
def test_function_case_insensitive(self):
"""
Test the case sensitivity of functions
Test case insensitive evaluation
Normal functions with some capitals should be fine
"""
functions = {'f': lambda x: x,
'F': lambda x: x + 1}
# Test case insensitive evaluation
# Both evaulations should call the same function
self.assertEqual(calc.evaluator({}, functions, 'f(6)'),
calc.evaluator({}, functions, 'F(6)'))
# Test case sensitive evaluation
self.assertNotEqual(calc.evaluator({}, functions, 'f(6)', cs=True),
calc.evaluator({}, functions, 'F(6)', cs=True))
self.assertAlmostEqual(
-0.28,
calc.evaluator({}, {}, 'SiN(6)', case_sensitive=False),
delta=1e-3
)
def test_function_case_sensitive(self):
"""
Test case sensitive evaluation
Incorrectly capitilized should fail
Also, it should pick the correct version of a function.
"""
with self.assertRaisesRegexp(calc.UndefinedVariable, 'SiN'):
calc.evaluator({}, {}, 'SiN(6)', case_sensitive=True)
# With case sensitive turned on, it should pick the right function
functions = {'f': lambda x: x, 'F': lambda x: x + 1}
self.assertEqual(
calc.evaluator({}, functions, 'f(6)', case_sensitive=True), 6
)
self.assertEqual(
calc.evaluator({}, functions, 'F(6)', case_sensitive=True), 7
)
def test_undefined_vars(self):
"""
@@ -468,9 +547,9 @@ class EvaluatorTest(unittest.TestCase):
"""
variables = {'R1': 2.0, 'R3': 4.0}
self.assertRaises(calc.UndefinedVariable, calc.evaluator,
{}, {}, "5+7 QWSEKO")
self.assertRaises(calc.UndefinedVariable, calc.evaluator,
{'r1': 5}, {}, "r1+r2")
self.assertRaises(calc.UndefinedVariable, calc.evaluator,
variables, {}, "r1*r3", cs=True)
with self.assertRaisesRegexp(calc.UndefinedVariable, 'QWSEKO'):
calc.evaluator({}, {}, "5+7*QWSEKO")
with self.assertRaisesRegexp(calc.UndefinedVariable, 'r2'):
calc.evaluator({'r1': 5}, {}, "r1+r2")
with self.assertRaisesRegexp(calc.UndefinedVariable, 'r1 r3'):
calc.evaluator(variables, {}, "r1*r3", case_sensitive=True)

View File

@@ -0,0 +1,251 @@
# -*- coding: utf-8 -*-
"""
Unit tests for preview.py
"""
import unittest
import preview
import pyparsing
class LatexRenderedTest(unittest.TestCase):
"""
Test the initializing code for LatexRendered.
Specifically that it stores the correct data and handles parens well.
"""
def test_simple(self):
"""
Test that the data values are stored without changing.
"""
math = 'x^2'
obj = preview.LatexRendered(math, tall=True)
self.assertEquals(obj.latex, math)
self.assertEquals(obj.sans_parens, math)
self.assertEquals(obj.tall, True)
def _each_parens(self, with_parens, math, parens, tall=False):
"""
Helper method to test the way parens are wrapped.
"""
obj = preview.LatexRendered(math, parens=parens, tall=tall)
self.assertEquals(obj.latex, with_parens)
self.assertEquals(obj.sans_parens, math)
self.assertEquals(obj.tall, tall)
def test_parens(self):
""" Test curvy parens. """
self._each_parens('(x+y)', 'x+y', '(')
def test_brackets(self):
""" Test brackets. """
self._each_parens('[x+y]', 'x+y', '[')
def test_squiggles(self):
""" Test curly braces. """
self._each_parens(r'\{x+y\}', 'x+y', '{')
def test_parens_tall(self):
""" Test curvy parens with the tall parameter. """
self._each_parens(r'\left(x^y\right)', 'x^y', '(', tall=True)
def test_brackets_tall(self):
""" Test brackets, also tall. """
self._each_parens(r'\left[x^y\right]', 'x^y', '[', tall=True)
def test_squiggles_tall(self):
""" Test tall curly braces. """
self._each_parens(r'\left\{x^y\right\}', 'x^y', '{', tall=True)
def test_bad_parens(self):
""" Check that we get an error with invalid parens. """
with self.assertRaisesRegexp(Exception, 'Unknown parenthesis'):
preview.LatexRendered('x^2', parens='not parens')
class LatexPreviewTest(unittest.TestCase):
"""
Run integrative tests for `latex_preview`.
All functionality was tested `RenderMethodsTest`, but see if it combines
all together correctly.
"""
def test_no_input(self):
"""
With no input (including just whitespace), see that no error is thrown.
"""
self.assertEquals('', preview.latex_preview(''))
self.assertEquals('', preview.latex_preview(' '))
self.assertEquals('', preview.latex_preview(' \t '))
def test_number_simple(self):
""" Simple numbers should pass through. """
self.assertEquals(preview.latex_preview('3.1415'), '3.1415')
def test_number_suffix(self):
""" Suffixes should be escaped. """
self.assertEquals(preview.latex_preview('1.618k'), r'1.618\text{k}')
def test_number_sci_notation(self):
""" Numbers with scientific notation should display nicely """
self.assertEquals(
preview.latex_preview('6.0221413E+23'),
r'6.0221413\!\times\!10^{+23}'
)
self.assertEquals(
preview.latex_preview('-6.0221413E+23'),
r'-6.0221413\!\times\!10^{+23}'
)
def test_number_sci_notation_suffix(self):
""" Test numbers with both of these. """
self.assertEquals(
preview.latex_preview('6.0221413E+23k'),
r'6.0221413\!\times\!10^{+23}\text{k}'
)
self.assertEquals(
preview.latex_preview('-6.0221413E+23k'),
r'-6.0221413\!\times\!10^{+23}\text{k}'
)
def test_variable_simple(self):
""" Simple valid variables should pass through. """
self.assertEquals(preview.latex_preview('x', variables=['x']), 'x')
def test_greek(self):
""" Variable names that are greek should be formatted accordingly. """
self.assertEquals(preview.latex_preview('pi'), r'\pi')
def test_variable_subscript(self):
""" Things like 'epsilon_max' should display nicely """
self.assertEquals(
preview.latex_preview('epsilon_max', variables=['epsilon_max']),
r'\epsilon_{max}'
)
def test_function_simple(self):
""" Valid function names should be escaped. """
self.assertEquals(
preview.latex_preview('f(3)', functions=['f']),
r'\text{f}(3)'
)
def test_function_tall(self):
r""" Functions surrounding a tall element should have \left, \right """
self.assertEquals(
preview.latex_preview('f(3^2)', functions=['f']),
r'\text{f}\left(3^{2}\right)'
)
def test_function_sqrt(self):
""" Sqrt function should be handled specially. """
self.assertEquals(preview.latex_preview('sqrt(3)'), r'\sqrt{3}')
def test_function_log10(self):
""" log10 function should be handled specially. """
self.assertEquals(preview.latex_preview('log10(3)'), r'\log_{10}(3)')
def test_function_log2(self):
""" log2 function should be handled specially. """
self.assertEquals(preview.latex_preview('log2(3)'), r'\log_2(3)')
def test_power_simple(self):
""" Powers should wrap the elements with braces correctly. """
self.assertEquals(preview.latex_preview('2^3^4'), '2^{3^{4}}')
def test_power_parens(self):
""" Powers should ignore the parenthesis of the last math. """
self.assertEquals(preview.latex_preview('2^3^(4+5)'), '2^{3^{4+5}}')
def test_parallel(self):
r""" Parallel items should combine with '\|'. """
self.assertEquals(preview.latex_preview('2||3'), r'2\|3')
def test_product_mult_only(self):
r""" Simple products should combine with a '\cdot'. """
self.assertEquals(preview.latex_preview('2*3'), r'2\cdot 3')
def test_product_big_frac(self):
""" Division should combine with '\frac'. """
self.assertEquals(
preview.latex_preview('2*3/4/5'),
r'\frac{2\cdot 3}{4\cdot 5}'
)
def test_product_single_frac(self):
""" Division should ignore parens if they are extraneous. """
self.assertEquals(
preview.latex_preview('(2+3)/(4+5)'),
r'\frac{2+3}{4+5}'
)
def test_product_keep_going(self):
"""
Complex products/quotients should split into many '\frac's when needed.
"""
self.assertEquals(
preview.latex_preview('2/3*4/5*6'),
r'\frac{2}{3}\cdot \frac{4}{5}\cdot 6'
)
def test_sum(self):
""" Sums should combine its elements. """
# Use 'x' as the first term (instead of, say, '1'), so it can't be
# interpreted as a negative number.
self.assertEquals(
preview.latex_preview('-x+2-3+4', variables=['x']),
'-x+2-3+4'
)
def test_sum_tall(self):
""" A complicated expression should not hide the tallness. """
self.assertEquals(
preview.latex_preview('(2+3^2)'),
r'\left(2+3^{2}\right)'
)
def test_complicated(self):
"""
Given complicated input, ensure that exactly the correct string is made.
"""
self.assertEquals(
preview.latex_preview('11*f(x)+x^2*(3||4)/sqrt(pi)'),
r'11\cdot \text{f}(x)+\frac{x^{2}\cdot (3\|4)}{\sqrt{\pi}}'
)
self.assertEquals(
preview.latex_preview('log10(1+3/4/Cos(x^2)*(x+1))',
case_sensitive=True),
(r'\log_{10}\left(1+\frac{3}{4\cdot \text{Cos}\left(x^{2}\right)}'
r'\cdot (x+1)\right)')
)
def test_syntax_errors(self):
"""
Test a lot of math strings that give syntax errors
Rather than have a lot of self.assertRaises, make a loop and keep track
of those that do not throw a `ParseException`, and assert at the end.
"""
bad_math_list = [
'11+',
'11*',
'f((x)',
'sqrt(x^)',
'3f(x)', # Not 3*f(x)
'3|4',
'3|||4'
]
bad_exceptions = {}
for math in bad_math_list:
try:
preview.latex_preview(math)
except pyparsing.ParseException:
pass # This is what we were expecting. (not excepting :P)
except Exception as error: # pragma: no cover
bad_exceptions[math] = error
else: # pragma: no cover
# If there is no exception thrown, this is a problem
bad_exceptions[math] = None
self.assertEquals({}, bad_exceptions)

View File

@@ -16,6 +16,8 @@ Module containing the problem elements which render into input objects
- crystallography
- vsepr_input
- drag_and_drop
- formulaequationinput
- chemicalequationinput
These are matched by *.html files templates/*.html which are mako templates with the
actual html.
@@ -47,6 +49,7 @@ import pyparsing
from .registry import TagRegistry
from chem import chemcalc
from preview import latex_preview
import xqueue_interface
from datetime import datetime
@@ -531,7 +534,7 @@ class TextLine(InputTypeBase):
is used e.g. for embedding simulations turned into questions.
Example:
<texline math="1" trailing_text="m/s" />
<textline math="1" trailing_text="m/s" />
This example will render out a text line with a math preview and the text 'm/s'
after the end of the text line.
@@ -1037,15 +1040,16 @@ class ChemicalEquationInput(InputTypeBase):
result = {'preview': '',
'error': ''}
formula = data['formula']
if formula is None:
try:
formula = data['formula']
except KeyError:
result['error'] = "No formula specified."
return result
try:
result['preview'] = chemcalc.render_to_html(formula)
except pyparsing.ParseException as p:
result['error'] = "Couldn't parse formula: {0}".format(p)
result['error'] = u"Couldn't parse formula: {0}".format(p.msg)
except Exception:
# this is unexpected, so log
log.warning(
@@ -1056,6 +1060,98 @@ class ChemicalEquationInput(InputTypeBase):
registry.register(ChemicalEquationInput)
#-------------------------------------------------------------------------
class FormulaEquationInput(InputTypeBase):
"""
An input type for entering formula equations. Supports live preview.
Example:
<formulaequationinput size="50"/>
options: size -- width of the textbox.
"""
template = "formulaequationinput.html"
tags = ['formulaequationinput']
@classmethod
def get_attributes(cls):
"""
Can set size of text field.
"""
return [Attribute('size', '20'), ]
def _extra_context(self):
"""
TODO (vshnayder): Get rid of 'previewer' once we have a standard way of requiring js to be loaded.
"""
# `reported_status` is basically `status`, except we say 'unanswered'
reported_status = ''
if self.status == 'unsubmitted':
reported_status = 'unanswered'
elif self.status in ('correct', 'incorrect', 'incomplete'):
reported_status = self.status
return {
'previewer': '/static/js/capa/src/formula_equation_preview.js',
'reported_status': reported_status
}
def handle_ajax(self, dispatch, get):
'''
Since we only have formcalc preview this input, check to see if it
matches the corresponding dispatch and send it through if it does
'''
if dispatch == 'preview_formcalc':
return self.preview_formcalc(get)
return {}
def preview_formcalc(self, get):
"""
Render an preview of a formula or equation. `get` should
contain a key 'formula' with a math expression.
Returns a json dictionary:
{
'preview' : '<some latex>' or ''
'error' : 'the-error' or ''
'request_start' : <time sent with request>
}
"""
result = {'preview': '',
'error': ''}
try:
formula = get['formula']
except KeyError:
result['error'] = "No formula specified."
return result
result['request_start'] = int(get.get('request_start', 0))
try:
# TODO add references to valid variables and functions
# At some point, we might want to mark invalid variables as red
# or something, and this is where we would need to pass those in.
result['preview'] = latex_preview(formula)
except pyparsing.ParseException as err:
result['error'] = "Sorry, couldn't parse formula"
result['formula'] = formula
except Exception:
# this is unexpected, so log
log.warning(
"Error while previewing formula", exc_info=True
)
result['error'] = "Error while rendering preview"
return result
registry.register(FormulaEquationInput)
#-----------------------------------------------------------------------------

View File

@@ -822,7 +822,7 @@ class NumericalResponse(LoncapaResponse):
response_tag = 'numericalresponse'
hint_tag = 'numericalhint'
allowed_inputfields = ['textline']
allowed_inputfields = ['textline', 'formulaequationinput']
required_attributes = ['answer']
max_inputfields = 1
@@ -837,11 +837,6 @@ class NumericalResponse(LoncapaResponse):
self.tolerance = contextualize_text(self.tolerance_xml, context)
except IndexError: # xpath found an empty list, so (...)[0] is the error
self.tolerance = '0'
try:
self.answer_id = xml.xpath('//*[@id=$id]//textline/@id',
id=xml.get('id'))[0]
except IndexError: # Same as above
self.answer_id = None
def get_score(self, student_answers):
'''Grade a numeric response '''
@@ -936,7 +931,7 @@ class CustomResponse(LoncapaResponse):
'chemicalequationinput', 'vsepr_input',
'drag_and_drop_input', 'editamoleculeinput',
'designprotein2dinput', 'editageneinput',
'annotationinput', 'jsinput']
'annotationinput', 'jsinput', 'formulaequationinput']
def setup_response(self):
xml = self.xml
@@ -1692,7 +1687,7 @@ class FormulaResponse(LoncapaResponse):
response_tag = 'formularesponse'
hint_tag = 'formulahint'
allowed_inputfields = ['textline']
allowed_inputfields = ['textline', 'formulaequationinput']
required_attributes = ['answer', 'samples']
max_inputfields = 1
@@ -1737,7 +1732,7 @@ class FormulaResponse(LoncapaResponse):
samples.split('@')[1].split('#')[0].split(':')))
ranges = dict(zip(variables, sranges))
for i in range(numsamples):
for _ in range(numsamples):
instructor_variables = self.strip_dict(dict(self.context))
student_variables = dict()
# ranges give numerical ranges for testing
@@ -1748,38 +1743,58 @@ class FormulaResponse(LoncapaResponse):
student_variables[str(var)] = value
# log.debug('formula: instructor_vars=%s, expected=%s' %
# (instructor_variables,expected))
instructor_result = evaluator(instructor_variables, dict(),
expected, cs=self.case_sensitive)
# Call `evaluator` on the instructor's answer and get a number
instructor_result = evaluator(
instructor_variables, dict(),
expected, case_sensitive=self.case_sensitive
)
try:
# log.debug('formula: student_vars=%s, given=%s' %
# (student_variables,given))
student_result = evaluator(student_variables,
dict(),
given,
cs=self.case_sensitive)
# Call `evaluator` on the student's answer; look for exceptions
student_result = evaluator(
student_variables,
dict(),
given,
case_sensitive=self.case_sensitive
)
except UndefinedVariable as uv:
log.debug(
'formularesponse: undefined variable in given=%s' % given)
'formularesponse: undefined variable in given=%s',
given
)
raise StudentInputError(
"Invalid input: " + uv.message + " not permitted in answer")
"Invalid input: " + uv.message + " not permitted in answer"
)
except ValueError as ve:
if 'factorial' in ve.message:
# This is thrown when fact() or factorial() is used in a formularesponse answer
# that tests on negative and/or non-integer inputs
# ve.message will be: `factorial() only accepts integral values` or `factorial() not defined for negative values`
# ve.message will be: `factorial() only accepts integral values` or
# `factorial() not defined for negative values`
log.debug(
'formularesponse: factorial function used in response that tests negative and/or non-integer inputs. given={0}'.format(given))
('formularesponse: factorial function used in response '
'that tests negative and/or non-integer inputs. '
'given={0}').format(given)
)
raise StudentInputError(
"factorial function not permitted in answer for this problem. Provided answer was: {0}".format(given))
("factorial function not permitted in answer "
"for this problem. Provided answer was: "
"{0}").format(cgi.escape(given))
)
# If non-factorial related ValueError thrown, handle it the same as any other Exception
log.debug('formularesponse: error {0} in formula'.format(ve))
raise StudentInputError("Invalid input: Could not parse '%s' as a formula" %
cgi.escape(given))
except Exception as err:
# traceback.print_exc()
log.debug('formularesponse: error %s in formula' % err)
log.debug('formularesponse: error %s in formula', err)
raise StudentInputError("Invalid input: Could not parse '%s' as a formula" %
cgi.escape(given))
# No errors in student's response--actually test for correctness
if not compare_with_tolerance(student_result, instructor_result, self.tolerance):
return "incorrect"
return "correct"

View File

@@ -0,0 +1,20 @@
<section id="formulaequationinput_${id}" class="formulaequationinput">
<div class="${reported_status}" id="status_${id}">
<input type="text" name="input_${id}" id="input_${id}"
data-input-id="${id}" value="${value|h}"
% if size:
size="${size}"
% endif
/>
<p class="status">${reported_status}</p>
<div id="input_${id}_preview" class="equation">
\[\]
<img src="/static/images/spinner.gif" class="loading"/>
</div>
<p id="answer_${id}" class="answer"></p>
</div>
<div class="script_placeholder" data-src="${previewer}"/>
</section>

View File

@@ -448,6 +448,32 @@ class TextlineTemplateTest(TemplateTestCase):
self.assert_has_text(xml, xpath, self.context['msg'])
class FormulaEquationInputTemplateTest(TemplateTestCase):
"""
Test make template for `<formulaequationinput>`s.
"""
TEMPLATE_NAME = 'formulaequationinput.html'
def setUp(self):
self.context = {
'id': 2,
'value': 'PREFILLED_VALUE',
'status': 'unsubmitted',
'previewer': 'file.js',
'reported_status': 'REPORTED_STATUS',
}
super(FormulaEquationInputTemplateTest, self).setUp()
def test_no_size(self):
xml = self.render_to_xml(self.context)
self.assert_no_xpath(xml, "//input[@size]", self.context)
def test_size(self):
self.context['size'] = '40'
xml = self.render_to_xml(self.context)
self.assert_has_xpath(xml, "//input[@size='40']", self.context)
class AnnotationInputTemplateTest(TemplateTestCase):
"""
Test mako template for `<annotationinput>` input.

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
"""
Tests of input types.
@@ -23,7 +24,8 @@ import xml.sax.saxutils as saxutils
from . import test_system
from capa import inputtypes
from mock import ANY
from mock import ANY, patch
from pyparsing import ParseException
# just a handy shortcut
lookup_tag = inputtypes.registry.get_class_for_tag
@@ -47,7 +49,7 @@ class OptionInputTest(unittest.TestCase):
'status': 'answered'}
option_input = lookup_tag('optioninput')(test_system(), element, state)
context = option_input._get_render_context()
context = option_input._get_render_context() # pylint: disable=W0212
expected = {'value': 'Down',
'options': [('Up', 'Up'), ('Down', 'Down')],
@@ -94,7 +96,7 @@ class ChoiceGroupTest(unittest.TestCase):
the_input = lookup_tag(tag)(test_system(), element, state)
context = the_input._get_render_context()
context = the_input._get_render_context() # pylint: disable=W0212
expected = {'id': 'sky_input',
'value': 'foil3',
@@ -144,7 +146,7 @@ class JavascriptInputTest(unittest.TestCase):
state = {'value': '3', }
the_input = lookup_tag('javascriptinput')(test_system(), element, state)
context = the_input._get_render_context()
context = the_input._get_render_context() # pylint: disable=W0212
expected = {'id': 'prob_1_2',
'status': 'unanswered',
@@ -172,7 +174,7 @@ class TextLineTest(unittest.TestCase):
state = {'value': 'BumbleBee', }
the_input = lookup_tag('textline')(test_system(), element, state)
context = the_input._get_render_context()
context = the_input._get_render_context() # pylint: disable=W0212
expected = {'id': 'prob_1_2',
'value': 'BumbleBee',
@@ -200,7 +202,7 @@ class TextLineTest(unittest.TestCase):
state = {'value': 'BumbleBee', }
the_input = lookup_tag('textline')(test_system(), element, state)
context = the_input._get_render_context()
context = the_input._get_render_context() # pylint: disable=W0212
expected = {'id': 'prob_1_2',
'value': 'BumbleBee',
@@ -238,7 +240,7 @@ class TextLineTest(unittest.TestCase):
state = {'value': 'BumbleBee', }
the_input = lookup_tag('textline')(test_system(), element, state)
context = the_input._get_render_context()
context = the_input._get_render_context() # pylint: disable=W0212
expected = {'id': 'prob_1_2',
'value': 'BumbleBee',
@@ -276,7 +278,7 @@ class FileSubmissionTest(unittest.TestCase):
input_class = lookup_tag('filesubmission')
the_input = input_class(test_system(), element, state)
context = the_input._get_render_context()
context = the_input._get_render_context() # pylint: disable=W0212
expected = {'id': 'prob_1_2',
'status': 'queued',
@@ -321,7 +323,7 @@ class CodeInputTest(unittest.TestCase):
input_class = lookup_tag('codeinput')
the_input = input_class(test_system(), element, state)
context = the_input._get_render_context()
context = the_input._get_render_context() # pylint: disable=W0212
expected = {'id': 'prob_1_2',
'value': 'print "good evening"',
@@ -371,7 +373,7 @@ class MatlabTest(unittest.TestCase):
self.the_input = self.input_class(test_system(), elt, state)
def test_rendering(self):
context = self.the_input._get_render_context()
context = self.the_input._get_render_context() # pylint: disable=W0212
expected = {'id': 'prob_1_2',
'value': 'print "good evening"',
@@ -397,7 +399,7 @@ class MatlabTest(unittest.TestCase):
elt = etree.fromstring(self.xml)
the_input = self.input_class(test_system(), elt, state)
context = the_input._get_render_context()
context = the_input._get_render_context() # pylint: disable=W0212
expected = {'id': 'prob_1_2',
'value': 'print "good evening"',
@@ -424,7 +426,7 @@ class MatlabTest(unittest.TestCase):
elt = etree.fromstring(self.xml)
the_input = self.input_class(test_system(), elt, state)
context = the_input._get_render_context()
context = the_input._get_render_context() # pylint: disable=W0212
expected = {'id': 'prob_1_2',
'value': 'print "good evening"',
'status': status,
@@ -449,7 +451,7 @@ class MatlabTest(unittest.TestCase):
elt = etree.fromstring(self.xml)
the_input = self.input_class(test_system(), elt, state)
context = the_input._get_render_context()
context = the_input._get_render_context() # pylint: disable=W0212
expected = {'id': 'prob_1_2',
'value': 'print "good evening"',
'status': 'queued',
@@ -554,7 +556,7 @@ class SchematicTest(unittest.TestCase):
the_input = lookup_tag('schematic')(test_system(), element, state)
context = the_input._get_render_context()
context = the_input._get_render_context() # pylint: disable=W0212
expected = {'id': 'prob_1_2',
'value': value,
@@ -593,7 +595,7 @@ class ImageInputTest(unittest.TestCase):
the_input = lookup_tag('imageinput')(test_system(), element, state)
context = the_input._get_render_context()
context = the_input._get_render_context() # pylint: disable=W0212
expected = {'id': 'prob_1_2',
'value': value,
@@ -644,7 +646,7 @@ class CrystallographyTest(unittest.TestCase):
the_input = lookup_tag('crystallography')(test_system(), element, state)
context = the_input._get_render_context()
context = the_input._get_render_context() # pylint: disable=W0212
expected = {'id': 'prob_1_2',
'value': value,
@@ -682,7 +684,7 @@ class VseprTest(unittest.TestCase):
the_input = lookup_tag('vsepr_input')(test_system(), element, state)
context = the_input._get_render_context()
context = the_input._get_render_context() # pylint: disable=W0212
expected = {'id': 'prob_1_2',
'value': value,
@@ -711,7 +713,7 @@ class ChemicalEquationTest(unittest.TestCase):
def test_rendering(self):
''' Verify that the render context matches the expected render context'''
context = self.the_input._get_render_context()
context = self.the_input._get_render_context() # pylint: disable=W0212
expected = {'id': 'prob_1_2',
'value': 'H2OYeah',
@@ -727,10 +729,168 @@ class ChemicalEquationTest(unittest.TestCase):
data = {'formula': "H"}
response = self.the_input.handle_ajax("preview_chemcalc", data)
self.assertTrue('preview' in response)
self.assertIn('preview', response)
self.assertNotEqual(response['preview'], '')
self.assertEqual(response['error'], "")
def test_ajax_bad_method(self):
"""
With a bad dispatch, we shouldn't recieve anything
"""
response = self.the_input.handle_ajax("obviously_not_real", {})
self.assertEqual(response, {})
def test_ajax_no_formula(self):
"""
When we ask for a formula rendering, there should be an error if no formula
"""
response = self.the_input.handle_ajax("preview_chemcalc", {})
self.assertIn('error', response)
self.assertEqual(response['error'], "No formula specified.")
def test_ajax_parse_err(self):
"""
With parse errors, ChemicalEquationInput should give an error message
"""
# Simulate answering a problem that raises the exception
with patch('capa.inputtypes.chemcalc.render_to_html') as mock_render:
mock_render.side_effect = ParseException(u"ȧƈƈḗƞŧḗḓ ŧḗẋŧ ƒǿř ŧḗşŧīƞɠ")
response = self.the_input.handle_ajax(
"preview_chemcalc",
{'formula': 'H2O + invalid chemistry'}
)
self.assertIn('error', response)
self.assertTrue("Couldn't parse formula" in response['error'])
@patch('capa.inputtypes.log')
def test_ajax_other_err(self, mock_log):
"""
With other errors, test that ChemicalEquationInput also logs it
"""
with patch('capa.inputtypes.chemcalc.render_to_html') as mock_render:
mock_render.side_effect = Exception()
response = self.the_input.handle_ajax(
"preview_chemcalc",
{'formula': 'H2O + superterrible chemistry'}
)
mock_log.warning.assert_called_once_with(
"Error while previewing chemical formula", exc_info=True
)
self.assertIn('error', response)
self.assertEqual(response['error'], "Error while rendering preview")
class FormulaEquationTest(unittest.TestCase):
"""
Check that formula equation inputs work.
"""
def setUp(self):
self.size = "42"
xml_str = """<formulaequationinput id="prob_1_2" size="{size}"/>""".format(size=self.size)
element = etree.fromstring(xml_str)
state = {'value': 'x^2+1/2'}
self.the_input = lookup_tag('formulaequationinput')(test_system(), element, state)
def test_rendering(self):
"""
Verify that the render context matches the expected render context
"""
context = self.the_input._get_render_context() # pylint: disable=W0212
expected = {
'id': 'prob_1_2',
'value': 'x^2+1/2',
'status': 'unanswered',
'reported_status': '',
'msg': '',
'size': self.size,
'previewer': '/static/js/capa/src/formula_equation_preview.js',
}
self.assertEqual(context, expected)
def test_rendering_reported_status(self):
"""
Verify that the 'reported status' matches expectations.
"""
test_values = {
'': '', # Default
'unsubmitted': 'unanswered',
'correct': 'correct',
'incorrect': 'incorrect',
'incomplete': 'incomplete',
'not a status': ''
}
for self_status, reported_status in test_values.iteritems():
self.the_input.status = self_status
context = self.the_input._get_render_context() # pylint: disable=W0212
self.assertEqual(context['reported_status'], reported_status)
def test_formcalc_ajax_sucess(self):
"""
Verify that using the correct dispatch and valid data produces a valid response
"""
data = {'formula': "x^2+1/2", 'request_start': 0}
response = self.the_input.handle_ajax("preview_formcalc", data)
self.assertIn('preview', response)
self.assertNotEqual(response['preview'], '')
self.assertEqual(response['error'], "")
self.assertEqual(response['request_start'], data['request_start'])
def test_ajax_bad_method(self):
"""
With a bad dispatch, we shouldn't recieve anything
"""
response = self.the_input.handle_ajax("obviously_not_real", {})
self.assertEqual(response, {})
def test_ajax_no_formula(self):
"""
When we ask for a formula rendering, there should be an error if no formula
"""
response = self.the_input.handle_ajax(
"preview_formcalc",
{'request_start': 1, }
)
self.assertIn('error', response)
self.assertEqual(response['error'], "No formula specified.")
def test_ajax_parse_err(self):
"""
With parse errors, FormulaEquationInput should give an error message
"""
# Simulate answering a problem that raises the exception
with patch('capa.inputtypes.latex_preview') as mock_preview:
mock_preview.side_effect = ParseException("Oopsie")
response = self.the_input.handle_ajax(
"preview_formcalc",
{'formula': 'x^2+1/2', 'request_start': 1, }
)
self.assertIn('error', response)
self.assertEqual(response['error'], "Sorry, couldn't parse formula")
@patch('capa.inputtypes.log')
def test_ajax_other_err(self, mock_log):
"""
With other errors, test that FormulaEquationInput also logs it
"""
with patch('capa.inputtypes.latex_preview') as mock_preview:
mock_preview.side_effect = Exception()
response = self.the_input.handle_ajax(
"preview_formcalc",
{'formula': 'x^2+1/2', 'request_start': 1, }
)
mock_log.warning.assert_called_once_with(
"Error while previewing formula", exc_info=True
)
self.assertIn('error', response)
self.assertEqual(response['error'], "Error while rendering preview")
class DragAndDropTest(unittest.TestCase):
'''
@@ -784,7 +944,7 @@ class DragAndDropTest(unittest.TestCase):
the_input = lookup_tag('drag_and_drop_input')(test_system(), element, state)
context = the_input._get_render_context()
context = the_input._get_render_context() # pylint: disable=W0212
expected = {'id': 'prob_1_2',
'value': value,
'status': 'unsubmitted',
@@ -833,7 +993,7 @@ class AnnotationInputTest(unittest.TestCase):
the_input = lookup_tag(tag)(test_system(), element, state)
context = the_input._get_render_context()
context = the_input._get_render_context() # pylint: disable=W0212
expected = {
'id': 'annotation_input',
@@ -920,7 +1080,7 @@ class TestChoiceText(unittest.TestCase):
}
expected.update(state)
the_input = lookup_tag(tag)(test_system(), element, state)
context = the_input._get_render_context()
context = the_input._get_render_context() # pylint: disable=W0212
self.assertEqual(context, expected)
def test_radiotextgroup(self):

View File

@@ -72,7 +72,7 @@ def get_logger_config(log_dir,
'level': console_loglevel,
'class': 'logging.StreamHandler',
'formatter': 'standard',
'stream': sys.stdout,
'stream': sys.stderr,
},
'syslogger-remote': {
'level': 'INFO',

View File

@@ -40,7 +40,7 @@ setup(
"timelimit = xmodule.timelimit_module:TimeLimitDescriptor",
"vertical = xmodule.vertical_module:VerticalDescriptor",
"video = xmodule.video_module:VideoDescriptor",
"videoalpha = xmodule.videoalpha_module:VideoAlphaDescriptor",
"videoalpha = xmodule.video_module:VideoDescriptor",
"videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"videosequence = xmodule.seq_module:SequenceDescriptor",
"discussion = xmodule.discussion_module:DiscussionDescriptor",

View File

@@ -32,7 +32,7 @@ $wrongans</tt> to see a hint.</p>
<formularesponse samples="x@-5:5#11" id="11" answer="$answer">
<responseparam description="Numerical Tolerance" type="tolerance" default="0.001" name="tol" />
<text>y = <textline size="25" /></text>
<text>y = <formulaequationinput size="25" /></text>
<hintgroup>
<formulahint samples="x@-5:5#11" answer="$wrongans" name="inversegrad">
</formulahint>

View File

@@ -173,7 +173,7 @@ section.problem {
}
}
&.incorrect, &.ui-icon-close {
&.incorrect, &.incomplete, &.ui-icon-close {
p.status {
@include inline-block();
background: url('../images/incorrect-icon.png') center center no-repeat;
@@ -214,6 +214,16 @@ section.problem {
clear: both;
margin-top: 3px;
.MathJax_Display {
display: inline-block;
width: auto;
}
img.loading {
display: inline-block;
padding-left: 10px;
}
span {
margin-bottom: 0;
@@ -265,7 +275,7 @@ section.problem {
width: 25px;
}
&.incorrect, &.ui-icon-close {
&.incorrect, &.incomplete, &.ui-icon-close {
@include inline-block();
background: url('../images/incorrect-icon.png') center center no-repeat;
height: 20px;

View File

@@ -10,11 +10,30 @@ div.video {
padding: 12px;
border-radius: 5px;
div.tc-wrapper {
position: relative;
@include clearfix;
}
article.video-wrapper {
float: left;
margin-right: flex-gutter(9);
width: flex-grid(6, 9);
background-color: black;
position: relative;
div.video-player-pre {
height: 50px;
background-color: black;
}
div.video-player-post {
height: 50px;
background-color: black;
}
section.video-player {
height: 0;
overflow: hidden;
@@ -52,10 +71,19 @@ div.video {
border-radius: 0;
border-top: 1px solid #000;
box-shadow: inset 0 1px 0 #eee, 0 1px 0 #555;
height: 7px;
position: absolute;
z-index: 1;
bottom: 100%;
left: 0;
right: 0;
height: 14px;
margin-left: -1px;
margin-right: -1px;
@include transition(height 2.0s ease-in-out 0s);
-webkit-transition: -webkit-transform 0.7s ease-in-out;
-moz-transition: -moz-transform 0.7s ease-in-out;
-ms-transition: -ms-transform 0.7s ease-in-out;
transition: transform 0.7s ease-in-out;
@include transform(scaleY(0.5) translate3d(0, 50%, 0));
div.ui-widget-header {
background: #777;
@@ -66,14 +94,18 @@ div.video {
background: $pink url(../images/slider-handle.png) center center no-repeat;
background-size: 50%;
border: 1px solid darken($pink, 20%);
border-radius: 15px;
border-radius: 50%;
box-shadow: inset 0 1px 0 lighten($pink, 10%);
cursor: pointer;
height: 15px;
margin-left: -7px;
top: -4px;
@include transition(height 2.0s ease-in-out 0s, width 2.0s ease-in-out 0s);
width: 15px;
height: 20px;
margin-left: 0;
top: 0;
-webkit-transition: -webkit-transform 0.7s ease-in-out;
-moz-transition: -moz-transform 0.7s ease-in-out;
-ms-transition: -ms-transform 0.7s ease-in-out;
transition: transform 0.7s ease-in-out;
@include transform(scale(.7, 1.3) translate3d(-80%, -15%, 0));
width: 20px;
&:focus, &:hover {
background-color: lighten($pink, 10%);
@@ -101,7 +133,6 @@ div.video {
line-height: 46px;
padding: 0 lh(.75);
text-indent: -9999px;
@include transition(background-color 0.75s linear 0s, opacity 0.75s linear 0s);
width: 14px;
background: url('../images/vcr.png') 15px 15px no-repeat;
outline: 0;
@@ -118,7 +149,7 @@ div.video {
&.play {
background-position: 17px -114px;
&:hover {
&:hover, &:focus {
background-color: #444;
}
}
@@ -126,7 +157,7 @@ div.video {
&.pause {
background-position: 16px -50px;
&:hover {
&:hover, &:focus {
background-color: #444;
}
}
@@ -213,7 +244,7 @@ div.video {
// fix for now
ol.video_speeds {
box-shadow: inset 1px 0 0 #555, 0 3px 0 #444;
box-shadow: inset 1px 0 0 #555, 0 4px 0 #444;
@include transition(none);
background-color: #444;
border: 1px solid #000;
@@ -221,7 +252,7 @@ div.video {
display: none;
opacity: 0.0;
position: absolute;
width: 133px;
width: 131px;
z-index: 10;
li {
@@ -268,12 +299,15 @@ div.video {
&.muted {
&>a {
background: url('../images/mute.png') 10px center no-repeat;
background-image: url('../images/mute.png');
}
}
> a {
background: url('../images/volume.png') 10px center no-repeat;
background-image: url('../images/volume.png');
background-position: 10px center;
background-repeat: no-repeat;
border-right: 1px solid #000;
box-shadow: 1px 0 0 #555, inset 1px 0 0 #555;
@include clearfix();
@@ -350,7 +384,7 @@ div.video {
@include transition(none);
width: 30px;
&:hover {
&:hover, &:active, &:focus {
background-color: #444;
color: #fff;
text-decoration: none;
@@ -362,7 +396,7 @@ div.video {
border-right: 1px solid #000;
box-shadow: 1px 0 0 #555, inset 1px 0 0 #555;
color: #797979;
display: block;
display: none;
float: left;
line-height: 46px; //height of play pause buttons
margin-left: 0;
@@ -371,7 +405,7 @@ div.video {
@include transition(none);
width: 30px;
&:hover {
&:hover, &:focus {
background-color: #444;
color: #fff;
text-decoration: none;
@@ -387,8 +421,6 @@ div.video {
a.hide-subtitles {
background: url('../images/cc.png') center no-repeat;
color: #797979;
display: block;
float: left;
font-weight: 800;
line-height: 46px; //height of play pause buttons
@@ -401,7 +433,7 @@ div.video {
-webkit-font-smoothing: antialiased;
width: 30px;
&:hover {
&:hover, &:focus {
background-color: #444;
color: #fff;
text-decoration: none;
@@ -410,6 +442,8 @@ div.video {
&.off {
opacity: 0.7;
}
color: #797979;
}
}
}
@@ -420,15 +454,10 @@ div.video {
}
div.slider {
height: 14px;
margin-top: -7px;
@include transform(scaleY(1) translate3d(0, 0, 0));
a.ui-slider-handle {
border-radius: 20px;
height: 20px;
margin-left: -10px;
top: -4px;
width: 20px;
@include transform(scale(1) translate3d(-50%, -15%, 0));
}
}
}
@@ -471,22 +500,47 @@ div.video {
article.video-wrapper {
width: flex-grid(9,9);
background-color: inherit;
}
article.video-wrapper section.video-controls.html5 {
bottom: 0px;
left: 0px;
right: 0px;
position: absolute;
z-index: 1;
}
article.video-wrapper div.video-player-pre, article.video-wrapper div.video-player-post {
height: 0px;
}
ol.subtitles {
width: 0;
height: 0;
width: 0;
height: 0;
}
ol.subtitles.html5 {
background-color: rgba(243, 243, 243, 0.8);
height: 100%;
position: absolute;
right: 0;
bottom: 0;
top: 0;
width: 275px;
padding: 0 20px;
z-index: 0;
}
}
&.fullscreen {
&.video-fullscreen {
background: rgba(#000, .95);
border: 0;
bottom: 0;
height: 100%;
left: 0;
margin: 0;
overflow: hidden;
padding: 0;
position: fixed;
top: 0;
@@ -501,12 +555,22 @@ div.video {
}
}
article.video-wrapper div.video-player-pre, article.video-wrapper div.video-player-post {
height: 0px;
}
article.video-wrapper {
position: static;
}
div.tc-wrapper {
@include clearfix;
display: table;
width: 100%;
height: 100%;
position: static;
article.video-wrapper {
width: 100%;
display: table-cell;
@@ -536,7 +600,7 @@ div.video {
background: rgba(#000, .8);
bottom: 0;
height: 100%;
max-height: 100%;
max-height: 460px;
max-width: flex-grid(3);
padding: lh();
position: fixed;

View File

@@ -1,620 +0,0 @@
& {
margin-bottom: 30px;
}
div.videoalpha {
@include clearfix();
background: #f3f3f3;
display: block;
margin: 0 -12px;
padding: 12px;
border-radius: 5px;
div.tc-wrapper {
position: relative;
@include clearfix;
}
article.video-wrapper {
float: left;
margin-right: flex-gutter(9);
width: flex-grid(6, 9);
background-color: black;
position: relative;
div.video-player-pre {
height: 50px;
background-color: black;
}
div.video-player-post {
height: 50px;
background-color: black;
}
section.video-player {
height: 0;
overflow: hidden;
padding-bottom: 56.25%;
position: relative;
object, iframe {
border: none;
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
}
}
section.video-controls {
@include clearfix();
background: #333;
border: 1px solid #000;
border-top: 0;
color: #ccc;
position: relative;
&:hover {
ul, div {
opacity: 1.0;
}
}
div.slider {
@include clearfix();
background: #c2c2c2;
border: 1px solid #000;
border-radius: 0;
border-top: 1px solid #000;
box-shadow: inset 0 1px 0 #eee, 0 1px 0 #555;
position: absolute;
z-index: 1;
bottom: 100%;
left: 0;
right: 0;
height: 14px;
margin-left: -1px;
margin-right: -1px;
-webkit-transition: -webkit-transform 0.7s ease-in-out;
-moz-transition: -moz-transform 0.7s ease-in-out;
-ms-transition: -ms-transform 0.7s ease-in-out;
transition: transform 0.7s ease-in-out;
@include transform(scaleY(0.5) translate3d(0, 50%, 0));
div.ui-widget-header {
background: #777;
box-shadow: inset 0 1px 0 #999;
}
a.ui-slider-handle {
background: $pink url(../images/slider-handle.png) center center no-repeat;
background-size: 50%;
border: 1px solid darken($pink, 20%);
border-radius: 50%;
box-shadow: inset 0 1px 0 lighten($pink, 10%);
cursor: pointer;
height: 20px;
margin-left: 0;
top: 0;
-webkit-transition: -webkit-transform 0.7s ease-in-out;
-moz-transition: -moz-transform 0.7s ease-in-out;
-ms-transition: -ms-transform 0.7s ease-in-out;
transition: transform 0.7s ease-in-out;
@include transform(scale(.7, 1.3) translate3d(-80%, -15%, 0));
width: 20px;
&:focus, &:hover {
background-color: lighten($pink, 10%);
outline: none;
}
}
}
ul.vcr {
float: left;
list-style: none;
margin: 0 lh() 0 0;
padding: 0;
li {
float: left;
margin-bottom: 0;
a {
border-bottom: none;
border-right: 1px solid #000;
box-shadow: 1px 0 0 #555;
cursor: pointer;
display: block;
line-height: 46px;
padding: 0 lh(.75);
text-indent: -9999px;
width: 14px;
background: url('../images/vcr.png') 15px 15px no-repeat;
outline: 0;
&:focus {
outline: 0;
}
&:empty {
height: 46px;
background: url('../images/vcr.png') 15px 15px no-repeat;
}
&.play {
background-position: 17px -114px;
&:hover, &:focus {
background-color: #444;
}
}
&.pause {
background-position: 16px -50px;
&:hover, &:focus {
background-color: #444;
}
}
}
div.vidtime {
padding-left: lh(.75);
font-weight: bold;
line-height: 46px; //height of play pause buttons
padding-left: lh(.75);
-webkit-font-smoothing: antialiased;
}
}
}
div.secondary-controls {
float: right;
div.speeds {
float: left;
position: relative;
&.open {
&>a {
background: url('../images/open-arrow.png') 10px center no-repeat;
}
ol.video_speeds {
display: block;
opacity: 1.0;
padding: 0;
margin: 0;
list-style: none;
}
}
&>a {
background: url('../images/closed-arrow.png') 10px center no-repeat;
border-left: 1px solid #000;
border-right: 1px solid #000;
box-shadow: 1px 0 0 #555, inset 1px 0 0 #555;
@include clearfix();
color: #fff;
cursor: pointer;
display: block;
line-height: 46px; //height of play pause buttons
margin-right: 0;
padding-left: 15px;
position: relative;
@include transition(none);
-webkit-font-smoothing: antialiased;
width: 116px;
outline: 0;
&:focus {
outline: 0;
}
h3 {
color: #999;
float: left;
font-size: em(14);
font-weight: normal;
letter-spacing: 1px;
padding: 0 lh(.25) 0 lh(.5);
line-height: 46px;
text-transform: uppercase;
}
p.active {
float: left;
font-weight: bold;
margin-bottom: 0;
padding: 0 lh(.5) 0 0;
line-height: 46px;
color: #fff;
}
&:hover, &:active, &:focus {
opacity: 1.0;
background-color: #444;
}
}
// fix for now
ol.video_speeds {
box-shadow: inset 1px 0 0 #555, 0 4px 0 #444;
@include transition(none);
background-color: #444;
border: 1px solid #000;
bottom: 46px;
display: none;
opacity: 0.0;
position: absolute;
width: 131px;
z-index: 10;
li {
box-shadow: 0 1px 0 #555;
border-bottom: 1px solid #000;
color: #fff;
cursor: pointer;
a {
border: 0;
color: #fff;
display: block;
padding: lh(.5);
&:hover {
background-color: #666;
color: #aaa;
}
}
&.active {
font-weight: bold;
}
&:last-child {
box-shadow: none;
border-bottom: 0;
margin-top: 0;
}
}
}
}
div.volume {
float: left;
position: relative;
&.open {
.volume-slider-container {
display: block;
opacity: 1.0;
}
}
&.muted {
&>a {
background-image: url('../images/mute.png');
}
}
> a {
background-image: url('../images/volume.png');
background-position: 10px center;
background-repeat: no-repeat;
border-right: 1px solid #000;
box-shadow: 1px 0 0 #555, inset 1px 0 0 #555;
@include clearfix();
color: #fff;
cursor: pointer;
display: block;
height: 46px;
margin-right: 0;
padding-left: 15px;
position: relative;
@include transition(none);
-webkit-font-smoothing: antialiased;
width: 30px;
&:hover, &:active, &:focus {
background-color: #444;
}
}
.volume-slider-container {
box-shadow: inset 1px 0 0 #555, 0 3px 0 #444;
@include transition(none);
background-color: #444;
border: 1px solid #000;
bottom: 46px;
display: none;
opacity: 0.0;
position: absolute;
width: 45px;
height: 125px;
margin-left: -1px;
z-index: 10;
.volume-slider {
height: 100px;
border: 0;
width: 5px;
margin: 14px auto;
background: #666;
border: 1px solid #000;
box-shadow: 0 1px 0 #333;
a.ui-slider-handle {
background: $pink url(../images/slider-handle.png) center center no-repeat;
background-size: 50%;
border: 1px solid darken($pink, 20%);
border-radius: 15px;
box-shadow: inset 0 1px 0 lighten($pink, 10%);
cursor: pointer;
height: 15px;
left: -6px;
@include transition(height 2.0s ease-in-out 0s, width 2.0s ease-in-out 0s);
width: 15px;
}
.ui-slider-range {
background: #ddd;
}
}
}
}
a.add-fullscreen {
background: url(../images/fullscreen.png) center no-repeat;
border-right: 1px solid #000;
box-shadow: 1px 0 0 #555, inset 1px 0 0 #555;
color: #797979;
display: block;
float: left;
line-height: 46px; //height of play pause buttons
margin-left: 0;
padding: 0 lh(.5);
text-indent: -9999px;
@include transition(none);
width: 30px;
&:hover, &:active, &:focus {
background-color: #444;
color: #fff;
text-decoration: none;
}
}
a.quality_control {
background: url(../images/hd.png) center no-repeat;
border-right: 1px solid #000;
box-shadow: 1px 0 0 #555, inset 1px 0 0 #555;
color: #797979;
display: none;
float: left;
line-height: 46px; //height of play pause buttons
margin-left: 0;
padding: 0 lh(.5);
text-indent: -9999px;
@include transition(none);
width: 30px;
&:hover, &:focus {
background-color: #444;
color: #fff;
text-decoration: none;
}
&.active {
background-color: #F44;
color: #0ff;
text-decoration: none;
}
}
a.hide-subtitles {
background: url('../images/cc.png') center no-repeat;
float: left;
font-weight: 800;
line-height: 46px; //height of play pause buttons
margin-left: 0;
opacity: 1.0;
padding: 0 lh(.5);
position: relative;
text-indent: -9999px;
@include transition(none);
-webkit-font-smoothing: antialiased;
width: 30px;
&:hover, &:focus {
background-color: #444;
color: #fff;
text-decoration: none;
}
&.off {
opacity: 0.7;
}
color: #797979;
}
}
}
&:hover section.video-controls {
ul, div {
opacity: 1.0;
}
div.slider {
@include transform(scaleY(1) translate3d(0, 0, 0));
a.ui-slider-handle {
@include transform(scale(1) translate3d(-50%, -15%, 0));
}
}
}
}
ol.subtitles {
padding-left: 0;
float: left;
max-height: 460px;
overflow: auto;
width: flex-grid(3, 9);
margin: 0;
font-size: 14px;
list-style: none;
li {
border: 0;
color: #666;
cursor: pointer;
margin-bottom: 8px;
padding: 0;
line-height: lh();
&.current {
color: #333;
font-weight: 700;
}
&:hover {
color: $blue;
}
&:empty {
margin-bottom: 0px;
}
}
}
&.closed {
article.video-wrapper {
width: flex-grid(9,9);
background-color: inherit;
}
article.video-wrapper section.video-controls.html5 {
bottom: 0px;
left: 0px;
right: 0px;
position: absolute;
z-index: 1;
}
article.video-wrapper div.video-player-pre, article.video-wrapper div.video-player-post {
height: 0px;
}
ol.subtitles {
width: 0;
height: 0;
}
ol.subtitles.html5 {
background-color: rgba(243, 243, 243, 0.8);
height: 100%;
position: absolute;
right: 0;
bottom: 0;
top: 0;
width: 275px;
padding: 0 20px;
z-index: 0;
}
}
&.video-fullscreen {
background: rgba(#000, .95);
border: 0;
bottom: 0;
height: 100%;
left: 0;
margin: 0;
padding: 0;
position: fixed;
top: 0;
width: 100%;
z-index: 999;
vertical-align: middle;
&.closed {
ol.subtitles {
right: -(flex-grid(4));
width: auto;
}
}
article.video-wrapper div.video-player-pre, article.video-wrapper div.video-player-post {
height: 0px;
}
article.video-wrapper {
position: static;
}
div.tc-wrapper {
@include clearfix;
display: table;
width: 100%;
height: 100%;
position: static;
article.video-wrapper {
width: 100%;
display: table-cell;
vertical-align: middle;
float: none;
}
object, iframe {
bottom: 0;
height: 100%;
left: 0;
overflow: hidden;
position: fixed;
top: 0;
}
section.video-controls {
bottom: 0;
left: 0;
position: absolute;
width: 100%;
z-index: 9999;
}
}
ol.subtitles {
background: rgba(#000, .8);
bottom: 0;
height: 100%;
max-height: 460px;
max-width: flex-grid(3);
padding: lh();
position: fixed;
right: 0;
top: 0;
@include transition(none);
li {
color: #aaa;
&.current {
color: #fff;
}
}
}
}
}

View File

@@ -1,20 +1,53 @@
<div class="course-content">
<div id="video_example">
<div id="example">
<div id="video_id" class="video"
data-youtube-id-0-75="7tqY6eQzVhE"
data-youtube-id-1-0="cogebirgzzM"
<div
id="video_id"
class="video"
data-streams="0.75:7tqY6eQzVhE,1.0:cogebirgzzM"
data-show-captions="true"
data-start=""
data-end=""
data-caption-asset-path="/static/subs/">
data-caption-asset-path="/static/subs/"
data-autoplay="False"
>
<div class="tc-wrapper">
<article class="video-wrapper">
<div class="video-player-pre"></div>
<section class="video-player">
<div id="id"></div>
</section>
<section class="video-controls"></section>
<div class="video-player-post"></div>
<section class="video-controls">
<div class="slider"></div>
<div>
<ul class="vcr">
<li><a class="video_control" href="#" title="Play"></a></li>
<li><div class="vidtime">0:00 / 0:00</div></li>
</ul>
<div class="secondary-controls">
<div class="speeds">
<a href="#">
<h3>Speed</h3>
<p class="active"></p>
</a>
<ol class="video_speeds"></ol>
</div>
<div class="volume">
<a href="#"></a>
<div class="volume-slider-container">
<div class="volume-slider"></div>
</div>
</div>
<a href="#" class="add-fullscreen" title="Fill browser">Fill Browser</a>
<a href="#" class="quality_control" title="HD">HD</a>
<a href="#" class="hide-subtitles" title="Turn off captions">Captions</a>
</div>
</div>
</section>
</article>
<ol class="subtitles"><li></li></ol>
</div>
</div>
</div>

View File

@@ -3,7 +3,7 @@
<div id="example">
<div
id="video_id"
class="videoalpha"
class="video"
data-show-captions="true"
data-start=""
data-end=""

View File

@@ -3,7 +3,7 @@
<div id="example">
<div
id="video_id"
class="videoalpha"
class="video"
data-show-captions="true"
data-start=""
data-end=""

View File

@@ -3,7 +3,7 @@
<div id="example">
<div
id="video_id"
class="videoalpha"
class="video"
data-streams="0.75:7tqY6eQzVhE,1.0:cogebirgzzM"
data-show-captions="false"
data-start=""

View File

@@ -1,55 +0,0 @@
<div class="course-content">
<div id="video_example">
<div id="example">
<div
id="video_id"
class="videoalpha"
data-streams="0.75:7tqY6eQzVhE,1.0:cogebirgzzM"
data-show-captions="true"
data-start=""
data-end=""
data-caption-asset-path="/static/subs/"
data-autoplay="False"
>
<div class="tc-wrapper">
<article class="video-wrapper">
<div class="video-player-pre"></div>
<section class="video-player">
<div id="id"></div>
</section>
<div class="video-player-post"></div>
<section class="video-controls">
<div class="slider"></div>
<div>
<ul class="vcr">
<li><a class="video_control" href="#" title="Play"></a></li>
<li><div class="vidtime">0:00 / 0:00</div></li>
</ul>
<div class="secondary-controls">
<div class="speeds">
<a href="#">
<h3>Speed</h3>
<p class="active"></p>
</a>
<ol class="video_speeds"></ol>
</div>
<div class="volume">
<a href="#"></a>
<div class="volume-slider-container">
<div class="volume-slider"></div>
</div>
</div>
<a href="#" class="add-fullscreen" title="Fill browser">Fill Browser</a>
<a href="#" class="quality_control" title="HD">HD</a>
<a href="#" class="hide-subtitles" title="Turn off captions">Captions</a>
</div>
</div>
</section>
</article>
<ol class="subtitles"><li></li></ol>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
*.js
# Tests for videoalpha are written in pure JavaScript.
!videoalpha/*.js
# Tests for video are written in pure JavaScript.
!video/*.js

View File

@@ -111,34 +111,18 @@ jasmine.stubYoutubePlayer = ->
obj['getAvailablePlaybackRates'] = jasmine.createSpy('getAvailablePlaybackRates').andReturn [0.75, 1.0, 1.25, 1.5]
obj
jasmine.stubVideoPlayer = (context, enableParts, createPlayer=true) ->
enableParts = [enableParts] unless $.isArray(enableParts)
suite = context.suite
currentPartName = suite.description while suite = suite.parentSuite
enableParts.push currentPartName
loadFixtures 'video.html'
jasmine.stubRequests()
YT.Player = undefined
videosDefinition = '0.75:7tqY6eQzVhE,1.0:cogebirgzzM'
context.video = new Video '#example', videosDefinition
jasmine.stubYoutubePlayer()
if createPlayer
return new VideoPlayer(video: context.video)
jasmine.stubVideoPlayerAlpha = (context, enableParts, html5=false) ->
console.log('stubVideoPlayerAlpha called')
jasmine.stubVideoPlayer = (context, enableParts, html5=false) ->
suite = context.suite
currentPartName = suite.description while suite = suite.parentSuite
if html5 == false
loadFixtures 'videoalpha.html'
loadFixtures 'video.html'
else
loadFixtures 'videoalpha_html5.html'
loadFixtures 'video_html5.html'
jasmine.stubRequests()
YT.Player = undefined
window.OldVideoPlayerAlpha = undefined
window.OldVideoPlayer = undefined
jasmine.stubYoutubePlayer()
return new VideoAlpha '#example', '.75:7tqY6eQzVhE,1.0:cogebirgzzM'
return new Video '#example', '.75:7tqY6eQzVhE,1.0:cogebirgzzM'
# Stub jQuery.cookie

View File

@@ -121,18 +121,18 @@ describe 'MarkdownEditingDescriptor', ->
<p>Enter the numerical value of Pi:</p>
<numericalresponse answer="3.14159">
<responseparam type="tolerance" default=".02" />
<textline />
<formulaequationinput />
</numericalresponse>
<p>Enter the approximate value of 502*9:</p>
<numericalresponse answer="4518">
<responseparam type="tolerance" default="15%" />
<textline />
<formulaequationinput />
</numericalresponse>
<p>Enter the number of fingers on a human hand:</p>
<numericalresponse answer="5">
<textline />
<formulaequationinput />
</numericalresponse>
<solution>
@@ -157,7 +157,7 @@ describe 'MarkdownEditingDescriptor', ->
<p>Enter 0 with a tolerance:</p>
<numericalresponse answer="0">
<responseparam type="tolerance" default=".02" />
<textline />
<formulaequationinput />
</numericalresponse>

View File

@@ -1,361 +0,0 @@
describe 'VideoCaption', ->
beforeEach ->
spyOn(VideoCaption.prototype, 'fetchCaption').andCallThrough()
spyOn($, 'ajaxWithPrefix').andCallThrough()
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false
afterEach ->
YT.Player = undefined
$.fn.scrollTo.reset()
$('.subtitles').remove()
describe 'constructor', ->
describe 'always', ->
beforeEach ->
@player = jasmine.stubVideoPlayer @
@caption = @player.caption
it 'set the youtube id', ->
expect(@caption.youtubeId).toEqual 'cogebirgzzM'
it 'create the caption element', ->
expect($('.video')).toContain 'ol.subtitles'
it 'add caption control to video player', ->
expect($('.video')).toContain 'a.hide-subtitles'
it 'fetch the caption', ->
expect(@caption.loaded).toBeTruthy()
expect(@caption.fetchCaption).toHaveBeenCalled()
expect($.ajaxWithPrefix).toHaveBeenCalledWith
url: @caption.captionURL()
notifyOnError: false
success: jasmine.any(Function)
it 'bind window resize event', ->
expect($(window)).toHandleWith 'resize', @caption.resize
it 'bind the hide caption button', ->
expect($('.hide-subtitles')).toHandleWith 'click', @caption.toggle
it 'bind the mouse movement', ->
expect($('.subtitles')).toHandleWith 'mouseover', @caption.onMouseEnter
expect($('.subtitles')).toHandleWith 'mouseout', @caption.onMouseLeave
expect($('.subtitles')).toHandleWith 'mousemove', @caption.onMovement
expect($('.subtitles')).toHandleWith 'mousewheel', @caption.onMovement
expect($('.subtitles')).toHandleWith 'DOMMouseScroll', @caption.onMovement
describe 'when on a non touch-based device', ->
beforeEach ->
@player = jasmine.stubVideoPlayer @
@caption = @player.caption
it 'render the caption', ->
captionsData = jasmine.stubbedCaption
$('.subtitles li[data-index]').each (index, link) =>
expect($(link)).toHaveData 'index', index
expect($(link)).toHaveData 'start', captionsData.start[index]
expect($(link)).toHaveText captionsData.text[index]
it 'add a padding element to caption', ->
expect($('.subtitles li:first')).toBe '.spacing'
expect($('.subtitles li:last')).toBe '.spacing'
it 'bind all the caption link', ->
$('.subtitles li[data-index]').each (index, link) =>
expect($(link)).toHandleWith 'click', @caption.seekPlayer
it 'set rendered to true', ->
expect(@caption.rendered).toBeTruthy()
describe 'when on a touch-based device', ->
beforeEach ->
window.onTouchBasedDevice.andReturn true
@player = jasmine.stubVideoPlayer @
@caption = @player.caption
it 'show explaination message', ->
expect($('.subtitles li')).toHaveHtml "Caption will be displayed when you start playing the video."
it 'does not set rendered to true', ->
expect(@caption.rendered).toBeFalsy()
describe 'mouse movement', ->
beforeEach ->
@player = jasmine.stubVideoPlayer @
@caption = @player.caption
window.setTimeout.andReturn(100)
spyOn window, 'clearTimeout'
describe 'when cursor is outside of the caption box', ->
beforeEach ->
$(window).trigger jQuery.Event 'mousemove'
it 'does not set freezing timeout', ->
expect(@caption.frozen).toBeFalsy()
describe 'when cursor is in the caption box', ->
beforeEach ->
$('.subtitles').trigger jQuery.Event 'mouseenter'
it 'set the freezing timeout', ->
expect(@caption.frozen).toEqual 100
describe 'when the cursor is moving', ->
beforeEach ->
$('.subtitles').trigger jQuery.Event 'mousemove'
it 'reset the freezing timeout', ->
expect(window.clearTimeout).toHaveBeenCalledWith 100
describe 'when the mouse is scrolling', ->
beforeEach ->
$('.subtitles').trigger jQuery.Event 'mousewheel'
it 'reset the freezing timeout', ->
expect(window.clearTimeout).toHaveBeenCalledWith 100
describe 'when cursor is moving out of the caption box', ->
beforeEach ->
@caption.frozen = 100
$.fn.scrollTo.reset()
describe 'always', ->
beforeEach ->
$('.subtitles').trigger jQuery.Event 'mouseout'
it 'reset the freezing timeout', ->
expect(window.clearTimeout).toHaveBeenCalledWith 100
it 'unfreeze the caption', ->
expect(@caption.frozen).toBeNull()
describe 'when the player is playing', ->
beforeEach ->
@caption.playing = true
$('.subtitles li[data-index]:first').addClass 'current'
$('.subtitles').trigger jQuery.Event 'mouseout'
it 'scroll the caption', ->
expect($.fn.scrollTo).toHaveBeenCalled()
describe 'when the player is not playing', ->
beforeEach ->
@caption.playing = false
$('.subtitles').trigger jQuery.Event 'mouseout'
it 'does not scroll the caption', ->
expect($.fn.scrollTo).not.toHaveBeenCalled()
describe 'search', ->
beforeEach ->
@player = jasmine.stubVideoPlayer @
@caption = @player.caption
it 'return a correct caption index', ->
expect(@caption.search(0)).toEqual 0
expect(@caption.search(9999)).toEqual 2
expect(@caption.search(10000)).toEqual 2
expect(@caption.search(15000)).toEqual 3
expect(@caption.search(30000)).toEqual 7
expect(@caption.search(30001)).toEqual 7
describe 'play', ->
describe 'when the caption was not rendered', ->
beforeEach ->
window.onTouchBasedDevice.andReturn true
@player = jasmine.stubVideoPlayer @
@caption = @player.caption
@caption.play()
it 'render the caption', ->
captionsData = jasmine.stubbedCaption
$('.subtitles li[data-index]').each (index, link) =>
expect($(link)).toHaveData 'index', index
expect($(link)).toHaveData 'start', captionsData.start[index]
expect($(link)).toHaveText captionsData.text[index]
it 'add a padding element to caption', ->
expect($('.subtitles li:first')).toBe '.spacing'
expect($('.subtitles li:last')).toBe '.spacing'
it 'bind all the caption link', ->
$('.subtitles li[data-index]').each (index, link) =>
expect($(link)).toHandleWith 'click', @caption.seekPlayer
it 'set rendered to true', ->
expect(@caption.rendered).toBeTruthy()
it 'set playing to true', ->
expect(@caption.playing).toBeTruthy()
describe 'pause', ->
beforeEach ->
@player = jasmine.stubVideoPlayer @
@caption = @player.caption
@caption.playing = true
@caption.pause()
it 'set playing to false', ->
expect(@caption.playing).toBeFalsy()
describe 'updatePlayTime', ->
beforeEach ->
@player = jasmine.stubVideoPlayer @
@caption = @player.caption
describe 'when the video speed is 1.0x', ->
beforeEach ->
@caption.currentSpeed = '1.0'
@caption.updatePlayTime 25.000
it 'search the caption based on time', ->
expect(@caption.currentIndex).toEqual 5
describe 'when the video speed is not 1.0x', ->
beforeEach ->
@caption.currentSpeed = '0.75'
@caption.updatePlayTime 25.000
it 'search the caption based on 1.0x speed', ->
expect(@caption.currentIndex).toEqual 3
describe 'when the index is not the same', ->
beforeEach ->
@caption.currentIndex = 1
$('.subtitles li[data-index=1]').addClass 'current'
@caption.updatePlayTime 25.000
it 'deactivate the previous caption', ->
expect($('.subtitles li[data-index=1]')).not.toHaveClass 'current'
it 'activate new caption', ->
expect($('.subtitles li[data-index=5]')).toHaveClass 'current'
it 'save new index', ->
expect(@caption.currentIndex).toEqual 5
it 'scroll caption to new position', ->
expect($.fn.scrollTo).toHaveBeenCalled()
describe 'when the index is the same', ->
beforeEach ->
@caption.currentIndex = 1
$('.subtitles li[data-index=3]').addClass 'current'
@caption.updatePlayTime 15.000
it 'does not change current subtitle', ->
expect($('.subtitles li[data-index=3]')).toHaveClass 'current'
describe 'resize', ->
beforeEach ->
@player = jasmine.stubVideoPlayer @
@caption = @player.caption
$('.subtitles li[data-index=1]').addClass 'current'
@caption.resize()
it 'set the height of caption container', ->
expect(parseInt($('.subtitles').css('maxHeight'))).toBeCloseTo $('.video-wrapper').height(), 2
it 'set the height of caption spacing', ->
expect(Math.abs(parseInt($('.subtitles .spacing:first').css('height')) - @caption.topSpacingHeight())).toBeLessThan 1
expect(Math.abs(parseInt($('.subtitles .spacing:last').css('height')) - @caption.bottomSpacingHeight())).toBeLessThan 1
it 'scroll caption to new position', ->
expect($.fn.scrollTo).toHaveBeenCalled()
describe 'scrollCaption', ->
beforeEach ->
@player = jasmine.stubVideoPlayer @
@caption = @player.caption
describe 'when frozen', ->
beforeEach ->
@caption.frozen = true
$('.subtitles li[data-index=1]').addClass 'current'
@caption.scrollCaption()
it 'does not scroll the caption', ->
expect($.fn.scrollTo).not.toHaveBeenCalled()
describe 'when not frozen', ->
beforeEach ->
@caption.frozen = false
describe 'when there is no current caption', ->
beforeEach ->
@caption.scrollCaption()
it 'does not scroll the caption', ->
expect($.fn.scrollTo).not.toHaveBeenCalled()
describe 'when there is a current caption', ->
beforeEach ->
$('.subtitles li[data-index=1]').addClass 'current'
@caption.scrollCaption()
it 'scroll to current caption', ->
expect($.fn.scrollTo).toHaveBeenCalledWith $('.subtitles .current:first', @caption.el),
offset: - ($('.video-wrapper').height() / 2 - $('.subtitles .current:first').height() / 2)
describe 'seekPlayer', ->
beforeEach ->
@player = jasmine.stubVideoPlayer @
@caption = @player.caption
@time = null
$(@caption).bind 'seek', (event, time) => @time = time
describe 'when the video speed is 1.0x', ->
beforeEach ->
@caption.currentSpeed = '1.0'
$('.subtitles li[data-start="27900"]').trigger('click')
it 'trigger seek event with the correct time', ->
expect(@time).toEqual 28.000
describe 'when the video speed is not 1.0x', ->
beforeEach ->
@caption.currentSpeed = '0.75'
$('.subtitles li[data-start="27900"]').trigger('click')
it 'trigger seek event with the correct time', ->
expect(@time).toEqual 37.000
describe 'toggle', ->
beforeEach ->
@player = jasmine.stubVideoPlayer @
@caption = @player.caption
$('.subtitles li[data-index=1]').addClass 'current'
describe 'when the caption is visible', ->
beforeEach ->
@caption.el.removeClass 'closed'
@caption.toggle jQuery.Event('click')
it 'hide the caption', ->
expect(@caption.el).toHaveClass 'closed'
describe 'when the caption is hidden', ->
beforeEach ->
@caption.el.addClass 'closed'
@caption.toggle jQuery.Event('click')
it 'show the caption', ->
expect(@caption.el).not.toHaveClass 'closed'
it 'scroll the caption', ->
expect($.fn.scrollTo).toHaveBeenCalled()

View File

@@ -1,103 +0,0 @@
describe 'VideoControl', ->
beforeEach ->
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false
loadFixtures 'video.html'
$('.video-controls').html ''
describe 'constructor', ->
it 'render the video controls', ->
@control = new window.VideoControl(el: $('.video-controls'))
expect($('.video-controls')).toContain
['.slider', 'ul.vcr', 'a.play', '.vidtime', '.add-fullscreen'].join(',')
expect($('.video-controls').find('.vidtime')).toHaveText '0:00 / 0:00'
it 'bind the playback button', ->
@control = new window.VideoControl(el: $('.video-controls'))
expect($('.video_control')).toHandleWith 'click', @control.togglePlayback
describe 'when on a touch based device', ->
beforeEach ->
window.onTouchBasedDevice.andReturn true
@control = new window.VideoControl(el: $('.video-controls'))
it 'does not add the play class to video control', ->
expect($('.video_control')).not.toHaveClass 'play'
expect($('.video_control')).not.toHaveHtml 'Play'
describe 'when on a non-touch based device', ->
beforeEach ->
@control = new window.VideoControl(el: $('.video-controls'))
it 'add the play class to video control', ->
expect($('.video_control')).toHaveClass 'play'
expect($('.video_control')).toHaveHtml 'Play'
describe 'play', ->
beforeEach ->
@control = new window.VideoControl(el: $('.video-controls'))
@control.play()
it 'switch playback button to play state', ->
expect($('.video_control')).not.toHaveClass 'play'
expect($('.video_control')).toHaveClass 'pause'
expect($('.video_control')).toHaveHtml 'Pause'
describe 'pause', ->
beforeEach ->
@control = new window.VideoControl(el: $('.video-controls'))
@control.pause()
it 'switch playback button to pause state', ->
expect($('.video_control')).not.toHaveClass 'pause'
expect($('.video_control')).toHaveClass 'play'
expect($('.video_control')).toHaveHtml 'Play'
describe 'togglePlayback', ->
beforeEach ->
@control = new window.VideoControl(el: $('.video-controls'))
describe 'when the control does not have play or pause class', ->
beforeEach ->
$('.video_control').removeClass('play').removeClass('pause')
describe 'when the video is playing', ->
beforeEach ->
$('.video_control').addClass('play')
spyOnEvent @control, 'pause'
@control.togglePlayback jQuery.Event('click')
it 'does not trigger the pause event', ->
expect('pause').not.toHaveBeenTriggeredOn @control
describe 'when the video is paused', ->
beforeEach ->
$('.video_control').addClass('pause')
spyOnEvent @control, 'play'
@control.togglePlayback jQuery.Event('click')
it 'does not trigger the play event', ->
expect('play').not.toHaveBeenTriggeredOn @control
describe 'when the video is playing', ->
beforeEach ->
spyOnEvent @control, 'pause'
$('.video_control').addClass 'pause'
@control.togglePlayback jQuery.Event('click')
it 'trigger the pause event', ->
expect('pause').toHaveBeenTriggeredOn @control
describe 'when the video is paused', ->
beforeEach ->
spyOnEvent @control, 'play'
$('.video_control').addClass 'play'
@control.togglePlayback jQuery.Event('click')
it 'trigger the play event', ->
expect('play').toHaveBeenTriggeredOn @control

View File

@@ -1,466 +0,0 @@
describe 'VideoPlayer', ->
beforeEach ->
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false
# It tries to call methods of VideoProgressSlider on Spy
for part in ['VideoCaption', 'VideoSpeedControl', 'VideoVolumeControl', 'VideoProgressSlider', 'VideoControl']
spyOn(window[part].prototype, 'initialize').andCallThrough()
jasmine.stubVideoPlayer @, [], false
afterEach ->
YT.Player = undefined
describe 'constructor', ->
beforeEach ->
spyOn YT, 'Player'
$.fn.qtip.andCallFake ->
$(this).data('qtip', true)
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
describe 'always', ->
beforeEach ->
@player = new VideoPlayer video: @video
it 'instanticate current time to zero', ->
expect(@player.currentTime).toEqual 0
it 'set the element', ->
expect(@player.el).toHaveId 'video_id'
it 'create video control', ->
expect(window.VideoControl.prototype.initialize).toHaveBeenCalled()
expect(@player.control).toBeDefined()
expect(@player.control.el).toBe $('.video-controls', @player.el)
it 'create video caption', ->
expect(window.VideoCaption.prototype.initialize).toHaveBeenCalled()
expect(@player.caption).toBeDefined()
expect(@player.caption.el).toBe @player.el
expect(@player.caption.youtubeId).toEqual 'cogebirgzzM'
expect(@player.caption.currentSpeed).toEqual '1.0'
expect(@player.caption.captionAssetPath).toEqual '/static/subs/'
it 'create video speed control', ->
expect(window.VideoSpeedControl.prototype.initialize).toHaveBeenCalled()
expect(@player.speedControl).toBeDefined()
expect(@player.speedControl.el).toBe $('.secondary-controls', @player.el)
expect(@player.speedControl.speeds).toEqual ['0.75', '1.0']
expect(@player.speedControl.currentSpeed).toEqual '1.0'
it 'create video progress slider', ->
expect(window.VideoSpeedControl.prototype.initialize).toHaveBeenCalled()
expect(@player.progressSlider).toBeDefined()
expect(@player.progressSlider.el).toBe $('.slider', @player.el)
it 'create Youtube player', ->
expect(YT.Player).toHaveBeenCalledWith('id', {
playerVars:
controls: 0
wmode: 'transparent'
rel: 0
showinfo: 0
enablejsapi: 1
modestbranding: 1
videoId: 'cogebirgzzM'
events:
onReady: @player.onReady
onStateChange: @player.onStateChange
onPlaybackQualityChange: @player.onPlaybackQualityChange
})
it 'bind to video control play event', ->
expect($(@player.control)).toHandleWith 'play', @player.play
it 'bind to video control pause event', ->
expect($(@player.control)).toHandleWith 'pause', @player.pause
it 'bind to video caption seek event', ->
expect($(@player.caption)).toHandleWith 'seek', @player.onSeek
it 'bind to video speed control speedChange event', ->
expect($(@player.speedControl)).toHandleWith 'speedChange', @player.onSpeedChange
it 'bind to video progress slider seek event', ->
expect($(@player.progressSlider)).toHandleWith 'seek', @player.onSeek
it 'bind to video volume control volumeChange event', ->
expect($(@player.volumeControl)).toHandleWith 'volumeChange', @player.onVolumeChange
it 'bind to key press', ->
expect($(document.documentElement)).toHandleWith 'keyup', @player.bindExitFullScreen
it 'bind to fullscreen switching button', ->
expect($('.add-fullscreen')).toHandleWith 'click', @player.toggleFullScreen
describe 'when not on a touch based device', ->
beforeEach ->
$('.add-fullscreen, .hide-subtitles').removeData 'qtip'
@player = new VideoPlayer video: @video
it 'add the tooltip to fullscreen and subtitle button', ->
expect($('.add-fullscreen')).toHaveData 'qtip'
expect($('.hide-subtitles')).toHaveData 'qtip'
it 'create video volume control', ->
expect(window.VideoVolumeControl.prototype.initialize).toHaveBeenCalled()
expect(@player.volumeControl).toBeDefined()
expect(@player.volumeControl.el).toBe $('.secondary-controls', @player.el)
describe 'when on a touch based device', ->
beforeEach ->
window.onTouchBasedDevice.andReturn true
$('.add-fullscreen, .hide-subtitles').removeData 'qtip'
@player = new VideoPlayer video: @video
it 'does not add the tooltip to fullscreen and subtitle button', ->
expect($('.add-fullscreen')).not.toHaveData 'qtip'
expect($('.hide-subtitles')).not.toHaveData 'qtip'
it 'does not create video volume control', ->
expect(window.VideoVolumeControl.prototype.initialize).not.toHaveBeenCalled()
expect(@player.volumeControl).not.toBeDefined()
describe 'onReady', ->
beforeEach ->
@video.embed()
@player = @video.player
spyOnEvent @player, 'ready'
spyOnEvent @player, 'updatePlayTime'
@player.onReady()
describe 'when not on a touch based device', ->
beforeEach ->
spyOn @player, 'play'
@player.onReady()
it 'autoplay the first video', ->
expect(@player.play).toHaveBeenCalled()
describe 'when on a touch based device', ->
beforeEach ->
window.onTouchBasedDevice.andReturn true
spyOn @player, 'play'
@player.onReady()
it 'does not autoplay the first video', ->
expect(@player.play).not.toHaveBeenCalled()
describe 'onStateChange', ->
beforeEach ->
@player = new VideoPlayer video: @video
describe 'when the video is unstarted', ->
beforeEach ->
spyOn @player.control, 'pause'
@player.caption.pause = jasmine.createSpy('VideoCaption.pause')
@player.onStateChange data: YT.PlayerState.UNSTARTED
it 'pause the video control', ->
expect(@player.control.pause).toHaveBeenCalled()
it 'pause the video caption', ->
expect(@player.caption.pause).toHaveBeenCalled()
describe 'when the video is playing', ->
beforeEach ->
@anotherPlayer = jasmine.createSpyObj 'AnotherPlayer', ['pauseVideo']
window.player = @anotherPlayer
spyOn @video, 'log'
spyOn(window, 'setInterval').andReturn 100
spyOn @player.control, 'play'
@player.caption.play = jasmine.createSpy('VideoCaption.play')
@player.progressSlider.play = jasmine.createSpy('VideoProgressSlider.play')
@player.player.getVideoEmbedCode.andReturn 'embedCode'
@player.onStateChange data: YT.PlayerState.PLAYING
it 'log the play_video event', ->
expect(@video.log).toHaveBeenCalledWith 'play_video'
it 'pause other video player', ->
expect(@anotherPlayer.pauseVideo).toHaveBeenCalled()
it 'set current video player as active player', ->
expect(window.player).toEqual @player.player
it 'set update interval', ->
expect(window.setInterval).toHaveBeenCalledWith @player.update, 200
expect(@player.player.interval).toEqual 100
it 'play the video control', ->
expect(@player.control.play).toHaveBeenCalled()
it 'play the video caption', ->
expect(@player.caption.play).toHaveBeenCalled()
it 'play the video progress slider', ->
expect(@player.progressSlider.play).toHaveBeenCalled()
describe 'when the video is paused', ->
beforeEach ->
@player = new VideoPlayer video: @video
window.player = @player.player
spyOn @video, 'log'
spyOn window, 'clearInterval'
spyOn @player.control, 'pause'
@player.caption.pause = jasmine.createSpy('VideoCaption.pause')
@player.player.interval = 100
@player.player.getVideoEmbedCode.andReturn 'embedCode'
@player.onStateChange data: YT.PlayerState.PAUSED
it 'log the pause_video event', ->
expect(@video.log).toHaveBeenCalledWith 'pause_video'
it 'set current video player as inactive', ->
expect(window.player).toBeNull()
it 'clear update interval', ->
expect(window.clearInterval).toHaveBeenCalledWith 100
expect(@player.player.interval).toBeNull()
it 'pause the video control', ->
expect(@player.control.pause).toHaveBeenCalled()
it 'pause the video caption', ->
expect(@player.caption.pause).toHaveBeenCalled()
describe 'when the video is ended', ->
beforeEach ->
spyOn @player.control, 'pause'
@player.caption.pause = jasmine.createSpy('VideoCaption.pause')
@player.onStateChange data: YT.PlayerState.ENDED
it 'pause the video control', ->
expect(@player.control.pause).toHaveBeenCalled()
it 'pause the video caption', ->
expect(@player.caption.pause).toHaveBeenCalled()
describe 'onSeek', ->
beforeEach ->
@player = new VideoPlayer video: @video
spyOn window, 'clearInterval'
@player.player.interval = 100
spyOn @player, 'updatePlayTime'
@player.onSeek {}, 60
it 'seek the player', ->
expect(@player.player.seekTo).toHaveBeenCalledWith 60, true
it 'call updatePlayTime on player', ->
expect(@player.updatePlayTime).toHaveBeenCalledWith 60
describe 'when the player is playing', ->
beforeEach ->
@player.player.getPlayerState.andReturn YT.PlayerState.PLAYING
@player.onSeek {}, 60
it 'reset the update interval', ->
expect(window.clearInterval).toHaveBeenCalledWith 100
describe 'when the player is not playing', ->
beforeEach ->
@player.player.getPlayerState.andReturn YT.PlayerState.PAUSED
@player.onSeek {}, 60
it 'set the current time', ->
expect(@player.currentTime).toEqual 60
describe 'onSpeedChange', ->
beforeEach ->
@player = new VideoPlayer video: @video
@player.currentTime = 60
spyOn @player, 'updatePlayTime'
spyOn(@video, 'setSpeed').andCallThrough()
describe 'always', ->
beforeEach ->
@player.onSpeedChange {}, '0.75'
it 'convert the current time to the new speed', ->
expect(@player.currentTime).toEqual '80.000'
it 'set video speed to the new speed', ->
expect(@video.setSpeed).toHaveBeenCalledWith '0.75'
it 'tell video caption that the speed has changed', ->
expect(@player.caption.currentSpeed).toEqual '0.75'
describe 'when the video is playing', ->
beforeEach ->
@player.player.getPlayerState.andReturn YT.PlayerState.PLAYING
@player.onSpeedChange {}, '0.75'
it 'load the video', ->
expect(@player.player.loadVideoById).toHaveBeenCalledWith '7tqY6eQzVhE', '80.000'
it 'trigger updatePlayTime event', ->
expect(@player.updatePlayTime).toHaveBeenCalledWith '80.000'
describe 'when the video is not playing', ->
beforeEach ->
@player.player.getPlayerState.andReturn YT.PlayerState.PAUSED
@player.onSpeedChange {}, '0.75'
it 'cue the video', ->
expect(@player.player.cueVideoById).toHaveBeenCalledWith '7tqY6eQzVhE', '80.000'
it 'trigger updatePlayTime event', ->
expect(@player.updatePlayTime).toHaveBeenCalledWith '80.000'
describe 'onVolumeChange', ->
beforeEach ->
@player = new VideoPlayer video: @video
@player.onVolumeChange undefined, 60
it 'set the volume on player', ->
expect(@player.player.setVolume).toHaveBeenCalledWith 60
describe 'update', ->
beforeEach ->
@player = new VideoPlayer video: @video
spyOn @player, 'updatePlayTime'
describe 'when the current time is unavailable from the player', ->
beforeEach ->
@player.player.getCurrentTime.andReturn undefined
@player.update()
it 'does not trigger updatePlayTime event', ->
expect(@player.updatePlayTime).not.toHaveBeenCalled()
describe 'when the current time is available from the player', ->
beforeEach ->
@player.player.getCurrentTime.andReturn 60
@player.update()
it 'trigger updatePlayTime event', ->
expect(@player.updatePlayTime).toHaveBeenCalledWith(60)
describe 'updatePlayTime', ->
beforeEach ->
@player = new VideoPlayer video: @video
spyOn(@video, 'getDuration').andReturn 1800
@player.caption.updatePlayTime = jasmine.createSpy('VideoCaption.updatePlayTime')
@player.progressSlider.updatePlayTime = jasmine.createSpy('VideoProgressSlider.updatePlayTime')
@player.updatePlayTime 60
it 'update the video playback time', ->
expect($('.vidtime')).toHaveHtml '1:00 / 30:00'
it 'update the playback time on caption', ->
expect(@player.caption.updatePlayTime).toHaveBeenCalledWith 60
it 'update the playback time on progress slider', ->
expect(@player.progressSlider.updatePlayTime).toHaveBeenCalledWith 60, 1800
describe 'toggleFullScreen', ->
beforeEach ->
@player = new VideoPlayer video: @video
@player.caption.resize = jasmine.createSpy('VideoCaption.resize')
describe 'when the video player is not full screen', ->
beforeEach ->
@player.el.removeClass 'fullscreen'
@player.toggleFullScreen(jQuery.Event("click"))
it 'replace the full screen button tooltip', ->
expect($('.add-fullscreen')).toHaveAttr 'title', 'Exit fill browser'
it 'add the fullscreen class', ->
expect(@player.el).toHaveClass 'fullscreen'
it 'tell VideoCaption to resize', ->
expect(@player.caption.resize).toHaveBeenCalled()
describe 'when the video player already full screen', ->
beforeEach ->
@player.el.addClass 'fullscreen'
@player.toggleFullScreen(jQuery.Event("click"))
it 'replace the full screen button tooltip', ->
expect($('.add-fullscreen')).toHaveAttr 'title', 'Fill browser'
it 'remove exit full screen button', ->
expect(@player.el).not.toContain 'a.exit'
it 'remove the fullscreen class', ->
expect(@player.el).not.toHaveClass 'fullscreen'
it 'tell VideoCaption to resize', ->
expect(@player.caption.resize).toHaveBeenCalled()
describe 'play', ->
beforeEach ->
@player = new VideoPlayer video: @video
describe 'when the player is not ready', ->
beforeEach ->
@player.player.playVideo = undefined
@player.play()
it 'does nothing', ->
expect(@player.player.playVideo).toBeUndefined()
describe 'when the player is ready', ->
beforeEach ->
@player.player.playVideo.andReturn true
@player.play()
it 'delegate to the Youtube player', ->
expect(@player.player.playVideo).toHaveBeenCalled()
describe 'isPlaying', ->
beforeEach ->
@player = new VideoPlayer video: @video
describe 'when the video is playing', ->
beforeEach ->
@player.player.getPlayerState.andReturn YT.PlayerState.PLAYING
it 'return true', ->
expect(@player.isPlaying()).toBeTruthy()
describe 'when the video is not playing', ->
beforeEach ->
@player.player.getPlayerState.andReturn YT.PlayerState.PAUSED
it 'return false', ->
expect(@player.isPlaying()).toBeFalsy()
describe 'pause', ->
beforeEach ->
@player = new VideoPlayer video: @video
@player.pause()
it 'delegate to the Youtube player', ->
expect(@player.player.pauseVideo).toHaveBeenCalled()
describe 'duration', ->
beforeEach ->
@player = new VideoPlayer video: @video
spyOn @video, 'getDuration'
@player.duration()
it 'delegate to the video', ->
expect(@video.getDuration).toHaveBeenCalled()
describe 'currentSpeed', ->
beforeEach ->
@player = new VideoPlayer video: @video
@video.speed = '3.0'
it 'delegate to the video', ->
expect(@player.currentSpeed()).toEqual '3.0'
describe 'volume', ->
beforeEach ->
@player = new VideoPlayer video: @video
@player.player.getVolume.andReturn 42
describe 'without value', ->
it 'return current volume', ->
expect(@player.volume()).toEqual 42
describe 'with value', ->
it 'set player volume', ->
@player.volume(60)
expect(@player.player.setVolume).toHaveBeenCalledWith(60)

View File

@@ -1,169 +0,0 @@
describe 'VideoProgressSlider', ->
beforeEach ->
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false
describe 'constructor', ->
describe 'on a non-touch based device', ->
beforeEach ->
spyOn($.fn, 'slider').andCallThrough()
@player = jasmine.stubVideoPlayer @
@progressSlider = @player.progressSlider
it 'build the slider', ->
expect(@progressSlider.slider).toBe '.slider'
expect($.fn.slider).toHaveBeenCalledWith
range: 'min'
change: @progressSlider.onChange
slide: @progressSlider.onSlide
stop: @progressSlider.onStop
it 'build the seek handle', ->
expect(@progressSlider.handle).toBe '.slider .ui-slider-handle'
expect($.fn.qtip).toHaveBeenCalledWith
content: "0:00"
position:
my: 'bottom center'
at: 'top center'
container: @progressSlider.handle
hide:
delay: 700
style:
classes: 'ui-tooltip-slider'
widget: true
describe 'on a touch-based device', ->
beforeEach ->
window.onTouchBasedDevice.andReturn true
spyOn($.fn, 'slider').andCallThrough()
@player = jasmine.stubVideoPlayer @
@progressSlider = @player.progressSlider
it 'does not build the slider', ->
expect(@progressSlider.slider).toBeUndefined
expect($.fn.slider).not.toHaveBeenCalled()
describe 'play', ->
beforeEach ->
spyOn(VideoProgressSlider.prototype, 'buildSlider').andCallThrough()
@player = jasmine.stubVideoPlayer @
@progressSlider = @player.progressSlider
describe 'when the slider was already built', ->
beforeEach ->
@progressSlider.play()
it 'does not build the slider', ->
expect(@progressSlider.buildSlider.calls.length).toEqual 1
describe 'when the slider was not already built', ->
beforeEach ->
spyOn($.fn, 'slider').andCallThrough()
@progressSlider.slider = null
@progressSlider.play()
it 'build the slider', ->
expect(@progressSlider.slider).toBe '.slider'
expect($.fn.slider).toHaveBeenCalledWith
range: 'min'
change: @progressSlider.onChange
slide: @progressSlider.onSlide
stop: @progressSlider.onStop
it 'build the seek handle', ->
expect(@progressSlider.handle).toBe '.ui-slider-handle'
expect($.fn.qtip).toHaveBeenCalledWith
content: "0:00"
position:
my: 'bottom center'
at: 'top center'
container: @progressSlider.handle
hide:
delay: 700
style:
classes: 'ui-tooltip-slider'
widget: true
describe 'updatePlayTime', ->
beforeEach ->
@player = jasmine.stubVideoPlayer @
@progressSlider = @player.progressSlider
describe 'when frozen', ->
beforeEach ->
spyOn($.fn, 'slider').andCallThrough()
@progressSlider.frozen = true
@progressSlider.updatePlayTime 20, 120
it 'does not update the slider', ->
expect($.fn.slider).not.toHaveBeenCalled()
describe 'when not frozen', ->
beforeEach ->
spyOn($.fn, 'slider').andCallThrough()
@progressSlider.frozen = false
@progressSlider.updatePlayTime 20, 120
it 'update the max value of the slider', ->
expect($.fn.slider).toHaveBeenCalledWith 'option', 'max', 120
it 'update current value of the slider', ->
expect($.fn.slider).toHaveBeenCalledWith 'value', 20
describe 'onSlide', ->
beforeEach ->
@player = jasmine.stubVideoPlayer @
@progressSlider = @player.progressSlider
@time = null
$(@progressSlider).bind 'seek', (event, time) => @time = time
spyOnEvent @progressSlider, 'seek'
@progressSlider.onSlide {}, value: 20
it 'freeze the slider', ->
expect(@progressSlider.frozen).toBeTruthy()
it 'update the tooltip', ->
expect($.fn.qtip).toHaveBeenCalled()
it 'trigger seek event', ->
expect('seek').toHaveBeenTriggeredOn @progressSlider
expect(@time).toEqual 20
describe 'onChange', ->
beforeEach ->
@player = jasmine.stubVideoPlayer @
@progressSlider = @player.progressSlider
@progressSlider.onChange {}, value: 20
it 'update the tooltip', ->
expect($.fn.qtip).toHaveBeenCalled()
describe 'onStop', ->
beforeEach ->
@player = jasmine.stubVideoPlayer @
@progressSlider = @player.progressSlider
@time = null
$(@progressSlider).bind 'seek', (event, time) => @time = time
spyOnEvent @progressSlider, 'seek'
@progressSlider.onStop {}, value: 20
it 'freeze the slider', ->
expect(@progressSlider.frozen).toBeTruthy()
it 'trigger seek event', ->
expect('seek').toHaveBeenTriggeredOn @progressSlider
expect(@time).toEqual 20
it 'set timeout to unfreeze the slider', ->
expect(window.setTimeout).toHaveBeenCalledWith jasmine.any(Function), 200
window.setTimeout.mostRecentCall.args[0]()
expect(@progressSlider.frozen).toBeFalsy()
describe 'updateTooltip', ->
beforeEach ->
@player = jasmine.stubVideoPlayer @
@progressSlider = @player.progressSlider
@progressSlider.updateTooltip 90
it 'set the tooltip value', ->
expect($.fn.qtip).toHaveBeenCalledWith 'option', 'content.text', '1:30'

View File

@@ -1,91 +0,0 @@
describe 'VideoSpeedControl', ->
beforeEach ->
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false
jasmine.stubVideoPlayer @
$('.speeds').remove()
describe 'constructor', ->
describe 'always', ->
beforeEach ->
@speedControl = new VideoSpeedControl el: $('.secondary-controls'), speeds: @video.speeds, currentSpeed: '1.0'
it 'add the video speed control to player', ->
secondaryControls = $('.secondary-controls')
li = secondaryControls.find('.video_speeds li')
expect(secondaryControls).toContain '.speeds'
expect(secondaryControls).toContain '.video_speeds'
expect(secondaryControls.find('p.active').text()).toBe '1.0x'
expect(li.filter('.active')).toHaveData 'speed', @speedControl.currentSpeed
expect(li.length).toBe @speedControl.speeds.length
$.each li.toArray().reverse(), (index, link) =>
expect($(link)).toHaveData 'speed', @speedControl.speeds[index]
expect($(link).find('a').text()).toBe @speedControl.speeds[index] + 'x'
it 'bind to change video speed link', ->
expect($('.video_speeds a')).toHandleWith 'click', @speedControl.changeVideoSpeed
describe 'when running on touch based device', ->
beforeEach ->
window.onTouchBasedDevice.andReturn true
$('.speeds').removeClass 'open'
@speedControl = new VideoSpeedControl el: $('.secondary-controls'), speeds: @video.speeds, currentSpeed: '1.0'
it 'open the speed toggle on click', ->
$('.speeds').click()
expect($('.speeds')).toHaveClass 'open'
$('.speeds').click()
expect($('.speeds')).not.toHaveClass 'open'
describe 'when running on non-touch based device', ->
beforeEach ->
$('.speeds').removeClass 'open'
@speedControl = new VideoSpeedControl el: $('.secondary-controls'), speeds: @video.speeds, currentSpeed: '1.0'
it 'open the speed toggle on hover', ->
$('.speeds').mouseenter()
expect($('.speeds')).toHaveClass 'open'
$('.speeds').mouseleave()
expect($('.speeds')).not.toHaveClass 'open'
it 'close the speed toggle on mouse out', ->
$('.speeds').mouseenter().mouseleave()
expect($('.speeds')).not.toHaveClass 'open'
it 'close the speed toggle on click', ->
$('.speeds').mouseenter().click()
expect($('.speeds')).not.toHaveClass 'open'
describe 'changeVideoSpeed', ->
beforeEach ->
@speedControl = new VideoSpeedControl el: $('.secondary-controls'), speeds: @video.speeds, currentSpeed: '1.0'
@video.setSpeed '1.0'
describe 'when new speed is the same', ->
beforeEach ->
spyOnEvent @speedControl, 'speedChange'
$('li[data-speed="1.0"] a').click()
it 'does not trigger speedChange event', ->
expect('speedChange').not.toHaveBeenTriggeredOn @speedControl
describe 'when new speed is not the same', ->
beforeEach ->
@newSpeed = null
$(@speedControl).bind 'speedChange', (event, newSpeed) => @newSpeed = newSpeed
spyOnEvent @speedControl, 'speedChange'
$('li[data-speed="0.75"] a').click()
it 'trigger speedChange event', ->
expect('speedChange').toHaveBeenTriggeredOn @speedControl
expect(@newSpeed).toEqual 0.75
describe 'onSpeedChange', ->
beforeEach ->
@speedControl = new VideoSpeedControl el: $('.secondary-controls'), speeds: @video.speeds, currentSpeed: '1.0'
$('li[data-speed="1.0"] a').addClass 'active'
@speedControl.setSpeed '0.75'
it 'set the new speed as active', ->
expect($('.video_speeds li[data-speed="1.0"]')).not.toHaveClass 'active'
expect($('.video_speeds li[data-speed="0.75"]')).toHaveClass 'active'
expect($('.speeds p.active')).toHaveHtml '0.75x'

View File

@@ -1,94 +0,0 @@
describe 'VideoVolumeControl', ->
beforeEach ->
jasmine.stubVideoPlayer @
$('.volume').remove()
describe 'constructor', ->
beforeEach ->
spyOn($.fn, 'slider')
@volumeControl = new VideoVolumeControl el: $('.secondary-controls')
it 'initialize currentVolume to 100', ->
expect(@volumeControl.currentVolume).toEqual 100
it 'render the volume control', ->
expect($('.secondary-controls').html()).toContain """
<div class="volume">
<a href="#"></a>
<div class="volume-slider-container">
<div class="volume-slider"></div>
</div>
</div>
"""
it 'create the slider', ->
expect($.fn.slider).toHaveBeenCalledWith
orientation: "vertical"
range: "min"
min: 0
max: 100
value: 100
change: @volumeControl.onChange
slide: @volumeControl.onChange
it 'bind the volume control', ->
expect($('.volume>a')).toHandleWith 'click', @volumeControl.toggleMute
expect($('.volume')).not.toHaveClass 'open'
$('.volume').mouseenter()
expect($('.volume')).toHaveClass 'open'
$('.volume').mouseleave()
expect($('.volume')).not.toHaveClass 'open'
describe 'onChange', ->
beforeEach ->
spyOnEvent @volumeControl, 'volumeChange'
@newVolume = undefined
@volumeControl = new VideoVolumeControl el: $('.secondary-controls')
$(@volumeControl).bind 'volumeChange', (event, volume) => @newVolume = volume
describe 'when the new volume is more than 0', ->
beforeEach ->
@volumeControl.onChange undefined, value: 60
it 'set the player volume', ->
expect(@newVolume).toEqual 60
it 'remote muted class', ->
expect($('.volume')).not.toHaveClass 'muted'
describe 'when the new volume is 0', ->
beforeEach ->
@volumeControl.onChange undefined, value: 0
it 'set the player volume', ->
expect(@newVolume).toEqual 0
it 'add muted class', ->
expect($('.volume')).toHaveClass 'muted'
describe 'toggleMute', ->
beforeEach ->
@newVolume = undefined
@volumeControl = new VideoVolumeControl el: $('.secondary-controls')
$(@volumeControl).bind 'volumeChange', (event, volume) => @newVolume = volume
describe 'when the current volume is more than 0', ->
beforeEach ->
@volumeControl.currentVolume = 60
@volumeControl.toggleMute()
it 'save the previous volume', ->
expect(@volumeControl.previousVolume).toEqual 60
it 'set the player volume', ->
expect(@newVolume).toEqual 0
describe 'when the current volume is 0', ->
beforeEach ->
@volumeControl.currentVolume = 0
@volumeControl.previousVolume = 60
@volumeControl.toggleMute()
it 'set the player volume to previous volume', ->
expect(@newVolume).toEqual 60

View File

@@ -1,153 +0,0 @@
describe 'Video', ->
metadata = undefined
beforeEach ->
loadFixtures 'video.html'
jasmine.stubRequests()
@['7tqY6eQzVhE'] = '7tqY6eQzVhE'
@['cogebirgzzM'] = 'cogebirgzzM'
metadata =
'7tqY6eQzVhE':
id: @['7tqY6eQzVhE']
duration: 300
'cogebirgzzM':
id: @['cogebirgzzM']
duration: 200
afterEach ->
window.player = undefined
window.onYouTubePlayerAPIReady = undefined
describe 'constructor', ->
beforeEach ->
@stubVideoPlayer = jasmine.createSpy('VideoPlayer')
$.cookie.andReturn '0.75'
window.player = undefined
describe 'by default', ->
beforeEach ->
spyOn(window.Video.prototype, 'fetchMetadata').andCallFake ->
@metadata = metadata
@video = new Video '#example'
it 'reset the current video player', ->
expect(window.player).toBeNull()
it 'set the elements', ->
expect(@video.el).toBe '#video_id'
it 'parse the videos', ->
expect(@video.videos).toEqual
'0.75': @['7tqY6eQzVhE']
'1.0': @['cogebirgzzM']
it 'fetch the video metadata', ->
expect(@video.fetchMetadata).toHaveBeenCalled
expect(@video.metadata).toEqual metadata
it 'parse available video speeds', ->
expect(@video.speeds).toEqual ['0.75', '1.0']
it 'set current video speed via cookie', ->
expect(@video.speed).toEqual '0.75'
it 'store a reference for this video player in the element', ->
expect($('.video').data('video')).toEqual @video
describe 'when the Youtube API is already available', ->
beforeEach ->
@originalYT = window.YT
window.YT = { Player: true }
spyOn(window, 'VideoPlayer').andReturn(@stubVideoPlayer)
@video = new Video '#example'
afterEach ->
window.YT = @originalYT
it 'create the Video Player', ->
expect(window.VideoPlayer).toHaveBeenCalledWith(video: @video)
expect(@video.player).toEqual @stubVideoPlayer
describe 'when the Youtube API is not ready', ->
beforeEach ->
@originalYT = window.YT
window.YT = {}
@video = new Video '#example'
afterEach ->
window.YT = @originalYT
it 'set the callback on the window object', ->
expect(window.onYouTubePlayerAPIReady).toEqual jasmine.any(Function)
describe 'when the Youtube API becoming ready', ->
beforeEach ->
@originalYT = window.YT
window.YT = {}
spyOn(window, 'VideoPlayer').andReturn(@stubVideoPlayer)
@video = new Video '#example'
window.onYouTubePlayerAPIReady()
afterEach ->
window.YT = @originalYT
it 'create the Video Player for all video elements', ->
expect(window.VideoPlayer).toHaveBeenCalledWith(video: @video)
expect(@video.player).toEqual @stubVideoPlayer
describe 'youtubeId', ->
beforeEach ->
$.cookie.andReturn '1.0'
@video = new Video '#example'
describe 'with speed', ->
it 'return the video id for given speed', ->
expect(@video.youtubeId('0.75')).toEqual @['7tqY6eQzVhE']
expect(@video.youtubeId('1.0')).toEqual @['cogebirgzzM']
describe 'without speed', ->
it 'return the video id for current speed', ->
expect(@video.youtubeId()).toEqual @cogebirgzzM
describe 'setSpeed', ->
beforeEach ->
@video = new Video '#example'
describe 'when new speed is available', ->
beforeEach ->
@video.setSpeed '0.75'
it 'set new speed', ->
expect(@video.speed).toEqual '0.75'
it 'save setting for new speed', ->
expect($.cookie).toHaveBeenCalledWith 'video_speed', '0.75', expires: 3650, path: '/'
describe 'when new speed is not available', ->
beforeEach ->
@video.setSpeed '1.75'
it 'set speed to 1.0x', ->
expect(@video.speed).toEqual '1.0'
describe 'getDuration', ->
beforeEach ->
@video = new Video '#example'
it 'return duration for current video', ->
expect(@video.getDuration()).toEqual 200
describe 'log', ->
beforeEach ->
@video = new Video '#example'
@video.setSpeed '1.0'
spyOn Logger, 'log'
@video.player = { currentTime: 25 }
@video.log 'someEvent'
it 'call the logger with valid parameters', ->
expect(Logger.log).toHaveBeenCalledWith 'someEvent',
id: 'id'
code: @cogebirgzzM
currentTime: 25
speed: '1.0'

View File

@@ -1,5 +1,5 @@
(function () {
xdescribe('VideoAlpha', function () {
xdescribe('Video', function () {
var oldOTBD;
beforeEach(function () {
@@ -12,7 +12,7 @@
});
afterEach(function () {
window.OldVideoPlayerAlpha = undefined;
window.OldVideoPlayer = undefined;
window.onYouTubePlayerAPIReady = undefined;
window.onHTML5PlayerAPIReady = undefined;
$('source').remove();
@@ -22,13 +22,13 @@
describe('constructor', function () {
describe('YT', function () {
beforeEach(function () {
loadFixtures('videoalpha.html');
loadFixtures('video.html');
$.cookie.andReturn('0.75');
});
describe('by default', function () {
beforeEach(function () {
this.state = new window.VideoAlpha('#example');
this.state = new window.Video('#example');
});
it('check videoType', function () {
@@ -36,7 +36,7 @@
});
it('reset the current video player', function () {
expect(window.OldVideoPlayerAlpha).toBeUndefined();
expect(window.OldVideoPlayer).toBeUndefined();
});
it('set the elements', function () {
@@ -64,14 +64,14 @@
var state;
beforeEach(function () {
loadFixtures('videoalpha_html5.html');
this.stubVideoPlayerAlpha = jasmine.createSpy('VideoPlayerAlpha');
loadFixtures('video_html5.html');
this.stubVideoPlayer = jasmine.createSpy('VideoPlayer');
$.cookie.andReturn('0.75');
});
describe('by default', function () {
beforeEach(function () {
state = new window.VideoAlpha('#example');
state = new window.Video('#example');
});
afterEach(function () {
@@ -83,7 +83,7 @@
});
it('reset the current video player', function () {
expect(window.OldVideoPlayerAlpha).toBeUndefined();
expect(window.OldVideoPlayer).toBeUndefined();
});
it('set the elements', function () {
@@ -104,8 +104,8 @@
it('parse the videos if subtitles do not exist', function () {
var sub = '';
$('#example').find('.videoalpha').data('sub', '');
state = new window.VideoAlpha('#example');
$('#example').find('.video').data('sub', '');
state = new window.Video('#example');
expect(state.videos).toEqual({
'0.75': sub,
@@ -142,7 +142,7 @@
// is required.
describe('HTML5 API is available', function () {
beforeEach(function () {
state = new VideoAlpha('#example');
state = new Video('#example');
});
afterEach(function () {
@@ -158,9 +158,9 @@
describe('youtubeId', function () {
beforeEach(function () {
loadFixtures('videoalpha.html');
loadFixtures('video.html');
$.cookie.andReturn('1.0');
state = new VideoAlpha('#example');
state = new Video('#example');
});
describe('with speed', function () {
@@ -180,13 +180,13 @@
describe('setSpeed', function () {
describe('YT', function () {
beforeEach(function () {
loadFixtures('videoalpha.html');
state = new VideoAlpha('#example');
loadFixtures('video.html');
state = new Video('#example');
});
describe('when new speed is available', function () {
beforeEach(function () {
state.setSpeed('0.75');
state.setSpeed('0.75', true);
});
it('set new speed', function () {
@@ -214,13 +214,13 @@
describe('HTML5', function () {
beforeEach(function () {
loadFixtures('videoalpha_html5.html');
state = new VideoAlpha('#example');
loadFixtures('video_html5.html');
state = new Video('#example');
});
describe('when new speed is available', function () {
beforeEach(function () {
state.setSpeed('0.75');
state.setSpeed('0.75', true);
});
it('set new speed', function () {
@@ -249,8 +249,8 @@
describe('getDuration', function () {
beforeEach(function () {
loadFixtures('videoalpha.html');
state = new VideoAlpha('#example');
loadFixtures('video.html');
state = new Video('#example');
});
it('return duration for current video', function () {
@@ -260,8 +260,8 @@
describe('log', function () {
beforeEach(function () {
loadFixtures('videoalpha_html5.html');
state = new VideoAlpha('#example');
loadFixtures('video_html5.html');
state = new Video('#example');
spyOn(Logger, 'log');
state.videoPlayer.log('someEvent', {
currentTime: 25,

View File

@@ -1,10 +1,10 @@
(function () {
xdescribe('VideoAlpha HTML5Video', function () {
xdescribe('Video HTML5Video', function () {
var state, player, oldOTBD, playbackRates = [0.75, 1.0, 1.25, 1.5];
function initialize() {
loadFixtures('videoalpha_html5.html');
state = new VideoAlpha('#example');
loadFixtures('video_html5.html');
state = new Video('#example');
player = state.videoPlayer.player;
}

View File

@@ -1,10 +1,10 @@
(function() {
xdescribe('VideoCaptionAlpha', function() {
xdescribe('VideoCaption', function() {
var state, videoPlayer, videoCaption, videoSpeedControl, oldOTBD;
function initialize() {
loadFixtures('videoalpha_all.html');
state = new VideoAlpha('#example');
loadFixtures('video_all.html');
state = new Video('#example');
videoPlayer = state.videoPlayer;
videoCaption = state.videoCaption;
videoSpeedControl = state.videoSpeedControl;
@@ -33,11 +33,11 @@
});
it('create the caption element', function() {
expect($('.videoalpha')).toContain('ol.subtitles');
expect($('.video')).toContain('ol.subtitles');
});
it('add caption control to video player', function() {
expect($('.videoalpha')).toContain('a.hide-subtitles');
expect($('.video')).toContain('a.hide-subtitles');
});
it('fetch the caption', function() {

View File

@@ -1,10 +1,10 @@
(function() {
xdescribe('VideoControlAlpha', function() {
xdescribe('VideoControl', function() {
var state, videoControl, oldOTBD;
function initialize() {
loadFixtures('videoalpha_all.html');
state = new VideoAlpha('#example');
loadFixtures('video_all.html');
state = new Video('#example');
videoControl = state.videoControl;
}

View File

@@ -1,15 +1,15 @@
(function() {
xdescribe('VideoPlayerAlpha', function() {
xdescribe('VideoPlayer', function() {
var state, videoPlayer, player, videoControl, videoCaption, videoProgressSlider, videoSpeedControl, videoVolumeControl, oldOTBD;
function initialize(fixture) {
if (typeof fixture === 'undefined') {
loadFixtures('videoalpha_all.html');
loadFixtures('video_all.html');
} else {
loadFixtures(fixture);
}
state = new VideoAlpha('#example');
state = new Video('#example');
videoPlayer = state.videoPlayer;
player = videoPlayer.player;
videoControl = state.videoControl;
@@ -20,7 +20,7 @@
}
function initializeYouTube() {
initialize('videoalpha.html');
initialize('video.html');
}
beforeEach(function () {
@@ -71,9 +71,9 @@
expect(videoProgressSlider.el).toHaveClass('slider');
});
// All the toHandleWith() expect tests are not necessary for this version of Video Alpha.
// All the toHandleWith() expect tests are not necessary for this version of Video.
// jQuery event system is not used to trigger and invoke methods. This is an artifact from
// previous version of Video Alpha.
// previous version of Video.
});
it('create Youtube player', function() {

View File

@@ -1,10 +1,10 @@
(function() {
xdescribe('VideoProgressSliderAlpha', function() {
xdescribe('VideoProgressSlider', function() {
var state, videoPlayer, videoProgressSlider, oldOTBD;
function initialize() {
loadFixtures('videoalpha_all.html');
state = new VideoAlpha('#example');
loadFixtures('video_all.html');
state = new Video('#example');
videoPlayer = state.videoPlayer;
videoProgressSlider = state.videoProgressSlider;
}
@@ -53,7 +53,7 @@
expect(videoProgressSlider.slider).toBeUndefined();
// We can't expect $.fn.slider not to have been called,
// because sliders are used in other parts of VideoAlpha.
// because sliders are used in other parts of Video.
});
});
});

View File

@@ -1,10 +1,10 @@
(function() {
xdescribe('VideoQualityControlAlpha', function() {
xdescribe('VideoQualityControl', function() {
var state, videoControl, videoQualityControl, oldOTBD;
function initialize() {
loadFixtures('videoalpha.html');
state = new VideoAlpha('#example');
loadFixtures('video.html');
state = new Video('#example');
videoControl = state.videoControl;
videoQualityControl = state.videoQualityControl;
}

View File

@@ -1,10 +1,10 @@
(function() {
xdescribe('VideoSpeedControlAlpha', function() {
xdescribe('VideoSpeedControl', function() {
var state, videoPlayer, videoControl, videoSpeedControl;
function initialize() {
loadFixtures('videoalpha_all.html');
state = new VideoAlpha('#example');
loadFixtures('video_all.html');
state = new Video('#example');
videoPlayer = state.videoPlayer;
videoControl = state.videoControl;
videoSpeedControl = state.videoSpeedControl;

View File

@@ -1,10 +1,10 @@
(function() {
xdescribe('VideoVolumeControlAlpha', function() {
xdescribe('VideoVolumeControl', function() {
var state, videoControl, videoVolumeControl, oldOTBD;
function initialize() {
loadFixtures('videoalpha_all.html');
state = new VideoAlpha('#example');
loadFixtures('video_all.html');
state = new Video('#example');
videoControl = state.videoControl;
videoVolumeControl = state.videoVolumeControl;
}

View File

@@ -4,5 +4,5 @@
*.js
# Videoalpha are written in pure JavaScript.
!videoalpha/*.js
# Video are written in pure JavaScript.
!video/*.js

View File

@@ -239,7 +239,7 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
} else {
string = '<numericalresponse answer="' + floatValue + '">\n';
}
string += ' <textline />\n';
string += ' <formulaequationinput />\n';
string += '</numericalresponse>\n\n';
} else {
string = '<stringresponse answer="' + p + '" type="ci">\n <textline size="20"/>\n</stringresponse>\n\n';

View File

@@ -88,7 +88,7 @@ class @Sequence
$.postWithPrefix modx_full_url, position: new_position
# On Sequence change, fire custom event "sequence:change" on element.
# Added for aborting video bufferization, see ../videoalpha/10_main.js
# Added for aborting video bufferization, see ../video/10_main.js
@el.trigger "sequence:change"
@mark_active new_position
@$('#seq_content').html @contents.eq(new_position - 1).text()

View File

@@ -12,8 +12,8 @@
(function (requirejs, require, define) {
define(
'videoalpha/01_initialize.js',
['videoalpha/03_video_player.js'],
'video/01_initialize.js',
['video/03_video_player.js'],
function (VideoPlayer) {
if (typeof(window.gettext) == "undefined") {
@@ -25,8 +25,8 @@ function (VideoPlayer) {
*
* Initialize module exports this function.
*
* @param {Object} state A place for all properties, and methods of Video Alpha.
* @param {DOM element} element Container of the entire Video Alpha DOM element.
* @param {Object} state A place for all properties, and methods of Video.
* @param {DOM element} element Container of the entire Video DOM element.
*/
return function (state, element) {
_makeFunctionsPublic(state);
@@ -44,7 +44,7 @@ function (VideoPlayer) {
* Functions which will be accessible via 'state' object. When called, these functions will get the 'state'
* object as a context.
*
* @param {Object} state A place for all properties, and methods of Video Alpha.
* @param {Object} state A place for all properties, and methods of Video.
*/
function _makeFunctionsPublic(state) {
state.setSpeed = _.bind(setSpeed, state);
@@ -70,7 +70,7 @@ function (VideoPlayer) {
state.isFullScreen = false;
// The parent element of the video, and the ID.
state.el = $(element).find('.videoalpha');
state.el = $(element).find('.video');
state.id = state.el.attr('id').replace(/video_/, '');
// We store all settings passed to us by the server in one place. These are "read only", so don't

View File

@@ -14,7 +14,7 @@
(function (requirejs, require, define) {
define(
'videoalpha/02_html5_video.js',
'video/02_html5_video.js',
[],
function () {
var HTML5Video = {};

View File

@@ -2,8 +2,8 @@
// VideoPlayer module.
define(
'videoalpha/03_video_player.js',
['videoalpha/02_html5_video.js'],
'video/03_video_player.js',
['video/02_html5_video.js'],
function (HTML5Video) {
// VideoPlayer() function - what this module "exports".
@@ -359,7 +359,7 @@ function (HTML5Video) {
this.videoPlayer.player.setPlaybackRate(this.speed);
}
if (!onTouchBasedDevice() && $('.videoalpha:first').data('autoplay') === 'True') {
if (!onTouchBasedDevice() && $('.video:first').data('autoplay') === 'True') {
this.videoPlayer.play();
}
}

View File

@@ -2,7 +2,7 @@
// VideoControl module.
define(
'videoalpha/04_video_control.js',
'video/04_video_control.js',
[],
function () {

View File

@@ -2,7 +2,7 @@
// VideoQualityControl module.
define(
'videoalpha/05_video_quality_control.js',
'video/05_video_quality_control.js',
[],
function () {

View File

@@ -9,7 +9,7 @@ mind, or whether to act, and in acting, to live."
// VideoProgressSlider module.
define(
'videoalpha/06_video_progress_slider.js',
'video/06_video_progress_slider.js',
[],
function () {

View File

@@ -2,7 +2,7 @@
// VideoVolumeControl module.
define(
'videoalpha/07_video_volume_control.js',
'video/07_video_volume_control.js',
[],
function () {

View File

@@ -2,7 +2,7 @@
// VideoSpeedControl module.
define(
'videoalpha/08_video_speed_control.js',
'video/08_video_speed_control.js',
[],
function () {

View File

@@ -2,7 +2,7 @@
// VideoCaption module.
define(
'videoalpha/09_video_caption.js',
'video/09_video_caption.js',
[],
function () {

View File

@@ -3,13 +3,13 @@
// Main module.
require(
[
'videoalpha/01_initialize.js',
'videoalpha/04_video_control.js',
'videoalpha/05_video_quality_control.js',
'videoalpha/06_video_progress_slider.js',
'videoalpha/07_video_volume_control.js',
'videoalpha/08_video_speed_control.js',
'videoalpha/09_video_caption.js'
'video/01_initialize.js',
'video/04_video_control.js',
'video/05_video_quality_control.js',
'video/06_video_progress_slider.js',
'video/07_video_volume_control.js',
'video/08_video_speed_control.js',
'video/09_video_caption.js'
],
function (
Initialize,
@@ -31,7 +31,7 @@ function (
// afterwards (expecting the DOM elements to be present) must be stopped by hand.
previousState = null;
window.VideoAlpha = function (element) {
window.Video = function (element) {
var state;
// Stop bufferization of previous video on sequence change.
@@ -64,7 +64,7 @@ function (
// Because the 'state' object is only available inside this closure, we will also make
// it available to the caller by returning it. This is necessary so that we can test
// VideoAlpha with Jasmine.
// Video with Jasmine.
return state;
};
});

View File

@@ -1,66 +0,0 @@
class @Video
constructor: (element) ->
@el = $(element).find('.video')
@id = @el.attr('id').replace(/video_/, '')
@start = @el.data('start')
@end = @el.data('end')
@caption_asset_path = @el.data('caption-asset-path')
@show_captions = @el.data('show-captions')
window.player = null
@el = $("#video_#{@id}")
@parseVideos()
@fetchMetadata()
@parseSpeed()
$("#video_#{@id}").data('video', this).addClass('video-load-complete')
@hide_captions = $.cookie('hide_captions') == 'true' or (not @show_captions)
if YT.Player
@embed()
else
window.onYouTubePlayerAPIReady = =>
@el.each ->
$(this).data('video').embed()
youtubeId: (speed)->
@videos[speed || @speed]
parseVideos: (videos) ->
@videos = {}
if @el.data('youtube-id-0-75')
@videos['0.75'] = @el.data('youtube-id-0-75')
if @el.data('youtube-id-1-0')
@videos['1.0'] = @el.data('youtube-id-1-0')
if @el.data('youtube-id-1-25')
@videos['1.25'] = @el.data('youtube-id-1-25')
if @el.data('youtube-id-1-5')
@videos['1.50'] = @el.data('youtube-id-1-5')
parseSpeed: ->
@setSpeed($.cookie('video_speed'))
@speeds = ($.map @videos, (url, speed) -> speed).sort()
setSpeed: (newSpeed) ->
if @videos[newSpeed] != undefined
@speed = newSpeed
$.cookie('video_speed', "#{newSpeed}", expires: 3650, path: '/')
else
@speed = '1.0'
embed: ->
@player = new VideoPlayer video: this
fetchMetadata: (url) ->
@metadata = {}
$.each @videos, (speed, url) =>
$.get "https://gdata.youtube.com/feeds/api/videos/#{url}?v=2&alt=jsonc", ((data) => @metadata[data.data.id] = data.data) , 'jsonp'
getDuration: ->
@metadata[@youtubeId()].duration
log: (eventName) ->
Logger.log eventName,
id: @id
code: @youtubeId()
currentTime: @player.currentTime
speed: @speed

View File

@@ -1,14 +0,0 @@
class @Subview
constructor: (options) ->
$.each options, (key, value) =>
@[key] = value
@initialize()
@render()
@bind()
$: (selector) ->
$(selector, @el)
initialize: ->
render: ->
bind: ->

View File

@@ -1,155 +0,0 @@
class @VideoCaption extends Subview
initialize: ->
@loaded = false
bind: ->
$(window).bind('resize', @resize)
@$('.hide-subtitles').click @toggle
@$('.subtitles').mouseenter(@onMouseEnter).mouseleave(@onMouseLeave)
.mousemove(@onMovement).bind('mousewheel', @onMovement)
.bind('DOMMouseScroll', @onMovement)
captionURL: ->
"#{@captionAssetPath}#{@youtubeId}.srt.sjson"
render: ->
# TODO: make it so you can have a video with no captions.
#@$('.video-wrapper').after """
# <ol class="subtitles"><li>Attempting to load captions...</li></ol>
# """
@$('.video-wrapper').after """
<ol class="subtitles"></ol>
"""
@$('.video-controls .secondary-controls').append """
<a href="#" class="hide-subtitles" title="Turn off captions">Captions</a>
"""#"
@$('.subtitles').css maxHeight: @$('.video-wrapper').height() - 5
@fetchCaption()
fetchCaption: ->
$.ajaxWithPrefix
url: @captionURL()
notifyOnError: false
success: (captions) =>
@captions = captions.text
@start = captions.start
@loaded = true
if onTouchBasedDevice()
$('.subtitles').html "<li>Caption will be displayed when you start playing the video.</li>"
else
@renderCaption()
renderCaption: ->
container = $('<ol>')
$.each @captions, (index, text) =>
container.append $('<li>').html(text).attr
'data-index': index
'data-start': @start[index]
@$('.subtitles').html(container.html())
@$('.subtitles li[data-index]').click @seekPlayer
# prepend and append an empty <li> for cosmetic reason
@$('.subtitles').prepend($('<li class="spacing">').height(@topSpacingHeight()))
.append($('<li class="spacing">').height(@bottomSpacingHeight()))
@rendered = true
search: (time) ->
if @loaded
min = 0
max = @start.length - 1
while min < max
index = Math.ceil((max + min) / 2)
if time < @start[index]
max = index - 1
if time >= @start[index]
min = index
return min
play: ->
if @loaded
@renderCaption() unless @rendered
@playing = true
pause: ->
if @loaded
@playing = false
updatePlayTime: (time) ->
if @loaded
# This 250ms offset is required to match the video speed
time = Math.round(Time.convert(time, @currentSpeed, '1.0') * 1000 + 250)
newIndex = @search time
if newIndex != undefined && @currentIndex != newIndex
if @currentIndex
@$(".subtitles li.current").removeClass('current')
@$(".subtitles li[data-index='#{newIndex}']").addClass('current')
@currentIndex = newIndex
@scrollCaption()
resize: =>
@$('.subtitles').css maxHeight: @captionHeight()
@$('.subtitles .spacing:first').height(@topSpacingHeight())
@$('.subtitles .spacing:last').height(@bottomSpacingHeight())
@scrollCaption()
onMouseEnter: =>
clearTimeout @frozen if @frozen
@frozen = setTimeout @onMouseLeave, 10000
onMovement: =>
@onMouseEnter()
onMouseLeave: =>
clearTimeout @frozen if @frozen
@frozen = null
@scrollCaption() if @playing
scrollCaption: ->
if !@frozen && @$('.subtitles .current:first').length
@$('.subtitles').scrollTo @$('.subtitles .current:first'),
offset: - @calculateOffset(@$('.subtitles .current:first'))
seekPlayer: (event) =>
event.preventDefault()
time = Math.round(Time.convert($(event.target).data('start'), '1.0', @currentSpeed) / 1000)
$(@).trigger('seek', time)
calculateOffset: (element) ->
@captionHeight() / 2 - element.height() / 2
topSpacingHeight: ->
@calculateOffset(@$('.subtitles li:not(.spacing):first'))
bottomSpacingHeight: ->
@calculateOffset(@$('.subtitles li:not(.spacing):last'))
toggle: (event) =>
event.preventDefault()
if @el.hasClass('closed') # Captions are "closed" e.g. turned off
@hideCaptions(false)
else # Captions are on
@hideCaptions(true)
hideCaptions: (hide_captions) =>
if hide_captions
@$('.hide-subtitles').attr('title', 'Turn on captions')
@el.addClass('closed')
else
@$('.hide-subtitles').attr('title', 'Turn off captions')
@el.removeClass('closed')
@scrollCaption()
$.cookie('hide_captions', hide_captions, expires: 3650, path: '/')
captionHeight: ->
if @el.hasClass('fullscreen')
$(window).height() - @$('.video-controls').height()
else
@$('.video-wrapper').height()

View File

@@ -1,35 +0,0 @@
class @VideoControl extends Subview
bind: ->
@$('.video_control').click @togglePlayback
render: ->
@el.append """
<div class="slider"></div>
<div>
<ul class="vcr">
<li><a class="video_control" href="#"></a></li>
<li>
<div class="vidtime">0:00 / 0:00</div>
</li>
</ul>
<div class="secondary-controls">
<a href="#" class="add-fullscreen" title="Fill browser">Fill Browser</a>
</div>
</div>
"""#"
unless onTouchBasedDevice()
@$('.video_control').addClass('play').html('Play')
play: ->
@$('.video_control').removeClass('play').addClass('pause').html('Pause')
pause: ->
@$('.video_control').removeClass('pause').addClass('play').html('Play')
togglePlayback: (event) =>
event.preventDefault()
if @$('.video_control').hasClass('play')
$(@).trigger('play')
else if @$('.video_control').hasClass('pause')
$(@).trigger('pause')

View File

@@ -1,180 +0,0 @@
class @VideoPlayer extends Subview
initialize: ->
# Define a missing constant of Youtube API
YT.PlayerState.UNSTARTED = -1
@currentTime = 0
@el = $("#video_#{@video.id}")
bind: ->
$(@control).bind('play', @play)
.bind('pause', @pause)
$(@qualityControl).bind('changeQuality', @handlePlaybackQualityChange)
$(@caption).bind('seek', @onSeek)
$(@speedControl).bind('speedChange', @onSpeedChange)
$(@progressSlider).bind('seek', @onSeek)
if @volumeControl
$(@volumeControl).bind('volumeChange', @onVolumeChange)
$(document.documentElement).keyup @bindExitFullScreen
@$('.add-fullscreen').click @toggleFullScreen
@addToolTip() unless onTouchBasedDevice()
bindExitFullScreen: (event) =>
if @el.hasClass('fullscreen') && event.keyCode == 27
@toggleFullScreen(event)
render: ->
@control = new VideoControl el: @$('.video-controls')
@qualityControl = new VideoQualityControl el: @$('.secondary-controls')
@caption = new VideoCaption
el: @el
youtubeId: @video.youtubeId('1.0')
currentSpeed: @currentSpeed()
captionAssetPath: @video.caption_asset_path
unless onTouchBasedDevice()
@volumeControl = new VideoVolumeControl el: @$('.secondary-controls')
@speedControl = new VideoSpeedControl el: @$('.secondary-controls'), speeds: @video.speeds, currentSpeed: @currentSpeed()
@progressSlider = new VideoProgressSlider el: @$('.slider')
@playerVars =
controls: 0
wmode: 'transparent'
rel: 0
showinfo: 0
enablejsapi: 1
modestbranding: 1
if @video.start
@playerVars.start = @video.start
@playerVars.wmode = 'window'
if @video.end
# work in AS3, not HMLT5. but iframe use AS3
@playerVars.end = @video.end
@player = new YT.Player @video.id,
playerVars: @playerVars
videoId: @video.youtubeId()
events:
onReady: @onReady
onStateChange: @onStateChange
onPlaybackQualityChange: @onPlaybackQualityChange
@caption.hideCaptions(@['video'].hide_captions)
addToolTip: ->
@$('.add-fullscreen, .hide-subtitles').qtip
position:
my: 'top right'
at: 'top center'
onReady: (event) =>
unless onTouchBasedDevice() or $('.video:first').data('autoplay') == 'False'
$('.video-load-complete:first').data('video').player.play()
onStateChange: (event) =>
switch event.data
when YT.PlayerState.UNSTARTED
@onUnstarted()
when YT.PlayerState.PLAYING
@onPlay()
when YT.PlayerState.PAUSED
@onPause()
when YT.PlayerState.ENDED
@onEnded()
onPlaybackQualityChange: (event, value) =>
quality = @player.getPlaybackQuality()
@qualityControl.onQualityChange(quality)
handlePlaybackQualityChange: (event, value) =>
@player.setPlaybackQuality(value)
onUnstarted: =>
@control.pause()
@caption.pause()
onPlay: =>
@video.log 'play_video'
window.player.pauseVideo() if window.player && window.player != @player
window.player = @player
unless @player.interval
@player.interval = setInterval(@update, 200)
@caption.play()
@control.play()
@progressSlider.play()
onPause: =>
@video.log 'pause_video'
window.player = null if window.player == @player
clearInterval(@player.interval)
@player.interval = null
@caption.pause()
@control.pause()
onEnded: =>
@control.pause()
@caption.pause()
onSeek: (event, time) =>
@player.seekTo(time, true)
if @isPlaying()
clearInterval(@player.interval)
@player.interval = setInterval(@update, 200)
else
@currentTime = time
@updatePlayTime time
onSpeedChange: (event, newSpeed) =>
@currentTime = Time.convert(@currentTime, parseFloat(@currentSpeed()), newSpeed)
newSpeed = parseFloat(newSpeed).toFixed(2).replace /\.00$/, '.0'
@video.setSpeed(newSpeed)
@caption.currentSpeed = newSpeed
if @isPlaying()
@player.loadVideoById(@video.youtubeId(), @currentTime)
else
@player.cueVideoById(@video.youtubeId(), @currentTime)
@updatePlayTime @currentTime
onVolumeChange: (event, volume) =>
@player.setVolume volume
update: =>
if @currentTime = @player.getCurrentTime()
@updatePlayTime @currentTime
updatePlayTime: (time) ->
progress = Time.format(time) + ' / ' + Time.format(@duration())
@$(".vidtime").html(progress)
@caption.updatePlayTime(time)
@progressSlider.updatePlayTime(time, @duration())
toggleFullScreen: (event) =>
event.preventDefault()
if @el.hasClass('fullscreen')
@$('.add-fullscreen').attr('title', 'Fill browser')
@el.removeClass('fullscreen')
else
@el.addClass('fullscreen')
@$('.add-fullscreen').attr('title', 'Exit fill browser')
@caption.resize()
# Delegates
play: =>
@player.playVideo() if @player.playVideo
isPlaying: ->
@player.getPlayerState() == YT.PlayerState.PLAYING
pause: =>
@player.pauseVideo() if @player.pauseVideo
duration: ->
@video.getDuration()
currentSpeed: ->
@video.speed
volume: (value) ->
if value?
@player.setVolume value
else
@player.getVolume()

View File

@@ -1,49 +0,0 @@
class @VideoProgressSlider extends Subview
initialize: ->
@buildSlider() unless onTouchBasedDevice()
buildSlider: ->
@slider = @el.slider
range: 'min'
change: @onChange
slide: @onSlide
stop: @onStop
@buildHandle()
buildHandle: ->
@handle = @$('.ui-slider-handle')
@handle.qtip
content: "#{Time.format(@slider.slider('value'))}"
position:
my: 'bottom center'
at: 'top center'
container: @handle
hide:
delay: 700
style:
classes: 'ui-tooltip-slider'
widget: true
play: =>
@buildSlider() unless @slider
updatePlayTime: (currentTime, duration) ->
if @slider && !@frozen
@slider.slider('option', 'max', duration)
@slider.slider('value', currentTime)
onSlide: (event, ui) =>
@frozen = true
@updateTooltip(ui.value)
$(@).trigger('seek', ui.value)
onChange: (event, ui) =>
@updateTooltip(ui.value)
onStop: (event, ui) =>
@frozen = true
$(@).trigger('seek', ui.value)
setTimeout (=> @frozen = false), 200
updateTooltip: (value)->
@handle.qtip('option', 'content.text', "#{Time.format(value)}")

View File

@@ -1,26 +0,0 @@
class @VideoQualityControl extends Subview
initialize: ->
@quality = null;
bind: ->
@$('.quality_control').click @toggleQuality
render: ->
@el.append """
<a href="#" class="quality_control" title="HD">HD</a>
"""#"
onQualityChange: (value) ->
@quality = value
if @quality in ['hd720', 'hd1080', 'highres']
@el.addClass('active')
else
@el.removeClass('active')
toggleQuality: (event) =>
event.preventDefault()
if @quality in ['hd720', 'hd1080', 'highres']
newQuality = 'large'
else
newQuality = 'hd720'
$(@).trigger('changeQuality', newQuality)

View File

@@ -1,43 +0,0 @@
class @VideoSpeedControl extends Subview
bind: ->
@$('.video_speeds a').click @changeVideoSpeed
if onTouchBasedDevice()
@$('.speeds').click (event) ->
event.preventDefault()
$(this).toggleClass('open')
else
@$('.speeds').mouseenter ->
$(this).addClass('open')
@$('.speeds').mouseleave ->
$(this).removeClass('open')
@$('.speeds').click (event) ->
event.preventDefault()
$(this).removeClass('open')
render: ->
@el.prepend """
<div class="speeds">
<a href="#">
<h3>Speed</h3>
<p class="active"></p>
</a>
<ol class="video_speeds"></ol>
</div>
"""
$.each @speeds, (index, speed) =>
link = $('<a>').attr(href: "#").html("#{speed}x")
@$('.video_speeds').prepend($('<li>').attr('data-speed', speed).html(link))
@setSpeed(@currentSpeed)
changeVideoSpeed: (event) =>
event.preventDefault()
unless $(event.target).parent().hasClass('active')
@currentSpeed = $(event.target).parent().data('speed')
$(@).trigger 'speedChange', $(event.target).parent().data('speed')
@setSpeed(parseFloat(@currentSpeed).toFixed(2).replace /\.00$/, '.0')
setSpeed: (speed) ->
@$('.video_speeds li').removeClass('active')
@$(".video_speeds li[data-speed='#{speed}']").addClass('active')
@$('.speeds p.active').html("#{speed}x")

View File

@@ -1,40 +0,0 @@
class @VideoVolumeControl extends Subview
initialize: ->
@currentVolume = 100
bind: ->
@$('.volume').mouseenter ->
$(this).addClass('open')
@$('.volume').mouseleave ->
$(this).removeClass('open')
@$('.volume>a').click(@toggleMute)
render: ->
@el.prepend """
<div class="volume">
<a href="#"></a>
<div class="volume-slider-container">
<div class="volume-slider"></div>
</div>
</div>
"""#"
@slider = @$('.volume-slider').slider
orientation: "vertical"
range: "min"
min: 0
max: 100
value: 100
change: @onChange
slide: @onChange
onChange: (event, ui) =>
@currentVolume = ui.value
$(@).trigger 'volumeChange', @currentVolume
@$('.volume').toggleClass 'muted', @currentVolume == 0
toggleMute: =>
if @currentVolume > 0
@previousVolume = @currentVolume
@slider.slider 'option', 'value', 0
else
@slider.slider 'option', 'value', @previousVolume

View File

@@ -24,15 +24,15 @@ data: |
</script>
<p>Give an equation for the relativistic energy of an object with mass m. Explicitly indicate multiplication with a <tt>*</tt> symbol.</p>
<formularesponse type="cs" samples="m,c@1,2:3,4#10" answer="m*c^2">
<responseparam type="tolerance" default="0.00001"/>
<br/><text>E =</text> <textline size="40" math="1" />
<responseparam type="tolerance" default="0.00001"/>
<br/><text>E =</text> <formulaequationinput size="40" />
</formularesponse>
<p>The answer to this question is (R_1*R_2)/R_3. </p>
<formularesponse type="ci" samples="R_1,R_2,R_3@1,2,3:3,4,5#10" answer="$VoVi">
<responseparam type="tolerance" default="0.00001"/>
<textline size="40" math="1" />
<formulaequationinput size="40" />
</formularesponse>
<solution>
<div class="detailed-solution">

View File

@@ -119,9 +119,8 @@ data: |
<p>
<p style="display:inline">Energy saved = </p>
<numericalresponse inline="1" answer="0.52">
<textline inline="1">
<responseparam description="Numerical Tolerance" type="tolerance" default="0.02" name="tol"/>
</textline>
<responseparam description="Numerical Tolerance" type="tolerance" default="0.02" name="tol"/>
<formulaequationinput/>
</numericalresponse>
<p style="display:inline">&#xA0;EJ/year</p>
</p>

View File

@@ -47,19 +47,19 @@ data: |
<p>Enter the numerical value of Pi:
<numericalresponse answer="3.14159">
<responseparam type="tolerance" default=".02" />
<textline />
<formulaequationinput />
</numericalresponse>
</p>
<p>Enter the approximate value of 502*9:
<numericalresponse answer="$computed_response">
<responseparam type="tolerance" default="15%"/>
<textline />
<formulaequationinput />
</numericalresponse>
</p>
<p>Enter the number of fingers on a human hand:
<numericalresponse answer="5">
<textline />
<formulaequationinput />
</numericalresponse>
</p>
<solution>

View File

@@ -71,51 +71,6 @@ class ModelsTest(unittest.TestCase):
vc_str = "<class 'xmodule.video_module.VideoDescriptor'>"
self.assertEqual(str(vc), vc_str)
def test_calc(self):
variables = {'R1': 2.0, 'R3': 4.0}
functions = {'sin': numpy.sin, 'cos': numpy.cos}
self.assertTrue(abs(calc.evaluator(variables, functions, "10000||sin(7+5)+0.5356")) < 0.01)
self.assertEqual(calc.evaluator({'R1': 2.0, 'R3': 4.0}, {}, "13"), 13)
self.assertEqual(calc.evaluator(variables, functions, "13"), 13)
self.assertEqual(calc.evaluator({'a': 2.2997471478310274, 'k': 9, 'm': 8, 'x': 0.66009498411213041}, {}, "5"), 5)
self.assertEqual(calc.evaluator({}, {}, "-1"), -1)
self.assertEqual(calc.evaluator({}, {}, "-0.33"), -.33)
self.assertEqual(calc.evaluator({}, {}, "-.33"), -.33)
self.assertEqual(calc.evaluator(variables, functions, "R1*R3"), 8.0)
self.assertTrue(abs(calc.evaluator(variables, functions, "sin(e)-0.41")) < 0.01)
self.assertTrue(abs(calc.evaluator(variables, functions, "k*T/q-0.025")) < 0.001)
self.assertTrue(abs(calc.evaluator(variables, functions, "e^(j*pi)") + 1) < 0.00001)
self.assertTrue(abs(calc.evaluator(variables, functions, "j||1") - 0.5 - 0.5j) < 0.00001)
variables['t'] = 1.0
# Use self.assertAlmostEqual here...
self.assertTrue(abs(calc.evaluator(variables, functions, "t") - 1.0) < 0.00001)
self.assertTrue(abs(calc.evaluator(variables, functions, "T") - 1.0) < 0.00001)
self.assertTrue(abs(calc.evaluator(variables, functions, "t", cs=True) - 1.0) < 0.00001)
self.assertTrue(abs(calc.evaluator(variables, functions, "T", cs=True) - 298) < 0.2)
# Use self.assertRaises here...
exception_happened = False
try:
calc.evaluator({}, {}, "5+7 QWSEKO")
except:
exception_happened = True
self.assertTrue(exception_happened)
try:
calc.evaluator({'r1': 5}, {}, "r1+r2")
except calc.UndefinedVariable:
pass
self.assertEqual(calc.evaluator(variables, functions, "r1*r3"), 8.0)
exception_happened = False
try:
calc.evaluator(variables, functions, "r1*r3", cs=True)
except:
exception_happened = True
self.assertTrue(exception_happened)
class PostData(object):
"""Class which emulate postdata."""
def __init__(self, dict_data):

View File

@@ -28,7 +28,7 @@ class CHModuleFactory(object):
<p>The answer is correct if it is within a specified numerical tolerance of the expected answer.</p>
<p>Enter the number of fingers on a human hand:</p>
<numericalresponse answer="5">
<textline/>
<formulaequationinput/>
</numericalresponse>
<solution>
<div class="detailed-solution">
@@ -114,7 +114,7 @@ class VerticalWithModulesFactory(object):
<problem display_name="Numerical Input" markdown=" " rerandomize="never" showanswer="finished">
<p>Test numerical problem.</p>
<numericalresponse answer="5">
<textline/>
<formulaequationinput/>
</numericalresponse>
<solution>
<div class="detailed-solution">
@@ -129,7 +129,7 @@ class VerticalWithModulesFactory(object):
<problem display_name="Numerical Input" markdown=" " rerandomize="never" showanswer="finished">
<p>Another test numerical problem.</p>
<numericalresponse answer="5">
<textline/>
<formulaequationinput/>
</numericalresponse>
<solution>
<div class="detailed-solution">

View File

@@ -33,7 +33,7 @@ class TabsEditingDescriptorTestCase(unittest.TestCase):
},
{
'name': "Subtitles",
'template': "videoalpha/subtitles.html",
'template': "video/subtitles.html",
},
{
'name': "Settings",

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
#pylint: disable=W0212
"""Test for Video Alpha Xmodule functional logic.
"""Test for Video Xmodule functional logic.
These test data read from xml, not from mongo.
We have a ModuleStoreTestCase class defined in
@@ -17,37 +17,36 @@ import unittest
from . import LogicTest
from .import get_test_system
from xmodule.modulestore import Location
from xmodule.videoalpha_module import VideoAlphaDescriptor, _create_youtube_string
from xmodule.video_module import VideoDescriptor
from xmodule.video_module import VideoDescriptor, _create_youtube_string
from .test_import import DummySystem
from textwrap import dedent
class VideoAlphaModuleTest(LogicTest):
"""Logic tests for VideoAlpha Xmodule."""
descriptor_class = VideoAlphaDescriptor
class VideoModuleTest(LogicTest):
"""Logic tests for Video Xmodule."""
descriptor_class = VideoDescriptor
raw_model_data = {
'data': '<videoalpha />'
'data': '<video />'
}
def test_parse_time_empty(self):
"""Ensure parse_time returns correctly with None or empty string."""
expected = ''
self.assertEqual(VideoAlphaDescriptor._parse_time(None), expected)
self.assertEqual(VideoAlphaDescriptor._parse_time(''), expected)
self.assertEqual(VideoDescriptor._parse_time(None), expected)
self.assertEqual(VideoDescriptor._parse_time(''), expected)
def test_parse_time(self):
"""Ensure that times are parsed correctly into seconds."""
expected = 247
output = VideoAlphaDescriptor._parse_time('00:04:07')
output = VideoDescriptor._parse_time('00:04:07')
self.assertEqual(output, expected)
def test_parse_youtube(self):
"""Test parsing old-style Youtube ID strings into a dict."""
youtube_str = '0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg'
output = VideoAlphaDescriptor._parse_youtube(youtube_str)
output = VideoDescriptor._parse_youtube(youtube_str)
self.assertEqual(output, {'0.75': 'jNCf2gIqpeE',
'1.00': 'ZwkTiUPN0mg',
'1.25': 'rsq9auxASqI',
@@ -59,7 +58,7 @@ class VideoAlphaModuleTest(LogicTest):
empty string.
"""
youtube_str = '0.75:jNCf2gIqpeE'
output = VideoAlphaDescriptor._parse_youtube(youtube_str)
output = VideoDescriptor._parse_youtube(youtube_str)
self.assertEqual(output, {'0.75': 'jNCf2gIqpeE',
'1.00': '',
'1.25': '',
@@ -72,8 +71,8 @@ class VideoAlphaModuleTest(LogicTest):
youtube_str = '1.00:p2Q6BrNhdh8'
youtube_str_hack = '1.0:p2Q6BrNhdh8'
self.assertEqual(
VideoAlphaDescriptor._parse_youtube(youtube_str),
VideoAlphaDescriptor._parse_youtube(youtube_str_hack)
VideoDescriptor._parse_youtube(youtube_str),
VideoDescriptor._parse_youtube(youtube_str_hack)
)
def test_parse_youtube_empty(self):
@@ -82,7 +81,7 @@ class VideoAlphaModuleTest(LogicTest):
that well.
"""
self.assertEqual(
VideoAlphaDescriptor._parse_youtube(''),
VideoDescriptor._parse_youtube(''),
{'0.75': '',
'1.00': '',
'1.25': '',
@@ -90,12 +89,12 @@ class VideoAlphaModuleTest(LogicTest):
)
class VideoAlphaDescriptorTest(unittest.TestCase):
"""Test for VideoAlphaDescriptor"""
class VideoDescriptorTest(unittest.TestCase):
"""Test for VideoDescriptor"""
def setUp(self):
system = get_test_system()
self.descriptor = VideoAlphaDescriptor(
self.descriptor = VideoDescriptor(
runtime=system,
model_data={})
@@ -117,9 +116,9 @@ class VideoAlphaDescriptorTest(unittest.TestCase):
back out to XML.
"""
system = DummySystem(load_error_modules=True)
location = Location(["i4x", "edX", "videoalpha", "default", "SampleProblem1"])
location = Location(["i4x", "edX", "video", "default", "SampleProblem1"])
model_data = {'location': location}
descriptor = VideoAlphaDescriptor(system, model_data)
descriptor = VideoDescriptor(system, model_data)
descriptor.youtube_id_0_75 = 'izygArpw-Qo'
descriptor.youtube_id_1_0 = 'p2Q6BrNhdh8'
descriptor.youtube_id_1_25 = '1EeWXzPdhSA'
@@ -133,9 +132,9 @@ class VideoAlphaDescriptorTest(unittest.TestCase):
in the output string.
"""
system = DummySystem(load_error_modules=True)
location = Location(["i4x", "edX", "videoalpha", "default", "SampleProblem1"])
location = Location(["i4x", "edX", "video", "default", "SampleProblem1"])
model_data = {'location': location}
descriptor = VideoAlphaDescriptor(system, model_data)
descriptor = VideoDescriptor(system, model_data)
descriptor.youtube_id_0_75 = 'izygArpw-Qo'
descriptor.youtube_id_1_0 = 'p2Q6BrNhdh8'
descriptor.youtube_id_1_25 = '1EeWXzPdhSA'
@@ -143,9 +142,9 @@ class VideoAlphaDescriptorTest(unittest.TestCase):
self.assertEqual(_create_youtube_string(descriptor), expected)
class VideoAlphaDescriptorImportTestCase(unittest.TestCase):
class VideoDescriptorImportTestCase(unittest.TestCase):
"""
Make sure that VideoAlphaDescriptor can import an old XML-based video correctly.
Make sure that VideoDescriptor can import an old XML-based video correctly.
"""
def assert_attributes_equal(self, video, attrs):
@@ -158,7 +157,7 @@ class VideoAlphaDescriptorImportTestCase(unittest.TestCase):
def test_constructor(self):
sample_xml = '''
<videoalpha display_name="Test Video"
<video display_name="Test Video"
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
show_captions="false"
start_time="00:00:01"
@@ -166,14 +165,14 @@ class VideoAlphaDescriptorImportTestCase(unittest.TestCase):
<source src="http://www.example.com/source.mp4"/>
<source src="http://www.example.com/source.ogg"/>
<track src="http://www.example.com/track"/>
</videoalpha>
</video>
'''
location = Location(["i4x", "edX", "videoalpha", "default",
location = Location(["i4x", "edX", "video", "default",
"SampleProblem1"])
model_data = {'data': sample_xml,
'location': location}
system = DummySystem(load_error_modules=True)
descriptor = VideoAlphaDescriptor(system, model_data)
descriptor = VideoDescriptor(system, model_data)
self.assert_attributes_equal(descriptor, {
'youtube_id_0_75': 'izygArpw-Qo',
'youtube_id_1_0': 'p2Q6BrNhdh8',
@@ -190,16 +189,16 @@ class VideoAlphaDescriptorImportTestCase(unittest.TestCase):
def test_from_xml(self):
module_system = DummySystem(load_error_modules=True)
xml_data = '''
<videoalpha display_name="Test Video"
<video display_name="Test Video"
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
show_captions="false"
start_time="00:00:01"
end_time="00:01:00">
<source src="http://www.example.com/source.mp4"/>
<track src="http://www.example.com/track"/>
</videoalpha>
</video>
'''
output = VideoAlphaDescriptor.from_xml(xml_data, module_system)
output = VideoDescriptor.from_xml(xml_data, module_system)
self.assert_attributes_equal(output, {
'youtube_id_0_75': 'izygArpw-Qo',
'youtube_id_1_0': 'p2Q6BrNhdh8',
@@ -221,14 +220,14 @@ class VideoAlphaDescriptorImportTestCase(unittest.TestCase):
"""
module_system = DummySystem(load_error_modules=True)
xml_data = '''
<videoalpha display_name="Test Video"
<video display_name="Test Video"
youtube="1.0:p2Q6BrNhdh8,1.25:1EeWXzPdhSA"
show_captions="true">
<source src="http://www.example.com/source.mp4"/>
<track src="http://www.example.com/track"/>
</videoalpha>
</video>
'''
output = VideoAlphaDescriptor.from_xml(xml_data, module_system)
output = VideoDescriptor.from_xml(xml_data, module_system)
self.assert_attributes_equal(output, {
'youtube_id_0_75': '',
'youtube_id_1_0': 'p2Q6BrNhdh8',
@@ -248,8 +247,8 @@ class VideoAlphaDescriptorImportTestCase(unittest.TestCase):
Make sure settings are correct if none are explicitly set in XML.
"""
module_system = DummySystem(load_error_modules=True)
xml_data = '<videoalpha></videoalpha>'
output = VideoAlphaDescriptor.from_xml(xml_data, module_system)
xml_data = '<video></video>'
output = VideoDescriptor.from_xml(xml_data, module_system)
self.assert_attributes_equal(output, {
'youtube_id_0_75': '',
'youtube_id_1_0': 'OEoXaMPEzfM',
@@ -270,16 +269,16 @@ class VideoAlphaDescriptorImportTestCase(unittest.TestCase):
"""
module_system = DummySystem(load_error_modules=True)
xml_data = """
<videoalpha display_name="Test Video"
<video display_name="Test Video"
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
show_captions="false"
from="00:00:01"
to="00:01:00">
<source src="http://www.example.com/source.mp4"/>
<track src="http://www.example.com/track"/>
</videoalpha>
</video>
"""
output = VideoAlphaDescriptor.from_xml(xml_data, module_system)
output = VideoDescriptor.from_xml(xml_data, module_system)
self.assert_attributes_equal(output, {
'youtube_id_0_75': 'izygArpw-Qo',
'youtube_id_1_0': 'p2Q6BrNhdh8',
@@ -295,7 +294,7 @@ class VideoAlphaDescriptorImportTestCase(unittest.TestCase):
def test_old_video_data(self):
"""
Ensure that Video Alpha is able to read VideoModule's model data.
Ensure that Video is able to read VideoModule's model data.
"""
module_system = DummySystem(load_error_modules=True)
xml_data = """
@@ -309,8 +308,7 @@ class VideoAlphaDescriptorImportTestCase(unittest.TestCase):
</video>
"""
video = VideoDescriptor.from_xml(xml_data, module_system)
video_alpha = VideoAlphaDescriptor(module_system, video._model_data)
self.assert_attributes_equal(video_alpha, {
self.assert_attributes_equal(video, {
'youtube_id_0_75': 'izygArpw-Qo',
'youtube_id_1_0': 'p2Q6BrNhdh8',
'youtube_id_1_25': '1EeWXzPdhSA',
@@ -324,17 +322,17 @@ class VideoAlphaDescriptorImportTestCase(unittest.TestCase):
})
class VideoAlphaExportTestCase(unittest.TestCase):
class VideoExportTestCase(unittest.TestCase):
"""
Make sure that VideoAlphaDescriptor can export itself to XML
Make sure that VideoDescriptor can export itself to XML
correctly.
"""
def test_export_to_xml(self):
"""Test that we write the correct XML on export."""
module_system = DummySystem(load_error_modules=True)
location = Location(["i4x", "edX", "videoalpha", "default", "SampleProblem1"])
desc = VideoAlphaDescriptor(module_system, {'location': location})
location = Location(["i4x", "edX", "video", "default", "SampleProblem1"])
desc = VideoDescriptor(module_system, {'location': location})
desc.youtube_id_0_75 = 'izygArpw-Qo'
desc.youtube_id_1_0 = 'p2Q6BrNhdh8'
@@ -348,11 +346,11 @@ class VideoAlphaExportTestCase(unittest.TestCase):
xml = desc.export_to_xml(None) # We don't use the `resource_fs` parameter
expected = dedent('''\
<videoalpha display_name="Video Alpha" start_time="0:00:01" youtube="0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8" show_captions="false" end_time="0:01:00">
<video url_name="SampleProblem1" start_time="0:00:01" youtube="0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8" show_captions="false" end_time="0:01:00">
<source src="http://www.example.com/source.mp4"/>
<source src="http://www.example.com/source.ogg"/>
<track src="http://www.example.com/track"/>
</videoalpha>
</video>
''')
self.assertEquals(expected, xml)
@@ -360,10 +358,10 @@ class VideoAlphaExportTestCase(unittest.TestCase):
def test_export_to_xml_empty_parameters(self):
"""Test XML export with defaults."""
module_system = DummySystem(load_error_modules=True)
location = Location(["i4x", "edX", "videoalpha", "default", "SampleProblem1"])
desc = VideoAlphaDescriptor(module_system, {'location': location})
location = Location(["i4x", "edX", "video", "default", "SampleProblem1"])
desc = VideoDescriptor(module_system, {'location': location})
xml = desc.export_to_xml(None)
expected = '<videoalpha display_name="Video Alpha" youtube="1.00:OEoXaMPEzfM" show_captions="true"/>\n'
expected = '<video url_name="SampleProblem1"/>\n'
self.assertEquals(expected, xml)

View File

@@ -1,104 +0,0 @@
# -*- coding: utf-8 -*-
import unittest
from xmodule.modulestore import Location
from xmodule.video_module import VideoDescriptor
from .test_import import DummySystem
class VideoDescriptorImportTestCase(unittest.TestCase):
"""
Make sure that VideoDescriptor can import an old XML-based video correctly.
"""
def test_constructor(self):
sample_xml = '''
<video display_name="Test Video"
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
show_captions="false"
from="00:00:01"
to="00:01:00">
<source src="http://www.example.com/source.mp4"/>
<track src="http://www.example.com/track"/>
</video>
'''
location = Location(["i4x", "edX", "video", "default",
"SampleProblem1"])
model_data = {'data': sample_xml,
'location': location}
system = DummySystem(load_error_modules=True)
descriptor = VideoDescriptor(system, model_data)
self.assertEquals(descriptor.youtube_id_0_75, 'izygArpw-Qo')
self.assertEquals(descriptor.youtube_id_1_0, 'p2Q6BrNhdh8')
self.assertEquals(descriptor.youtube_id_1_25, '1EeWXzPdhSA')
self.assertEquals(descriptor.youtube_id_1_5, 'rABDYkeK0x8')
self.assertEquals(descriptor.show_captions, False)
self.assertEquals(descriptor.start_time, 1.0)
self.assertEquals(descriptor.end_time, 60)
self.assertEquals(descriptor.track, 'http://www.example.com/track')
self.assertEquals(descriptor.source, 'http://www.example.com/source.mp4')
def test_from_xml(self):
module_system = DummySystem(load_error_modules=True)
xml_data = '''
<video display_name="Test Video"
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
show_captions="false"
from="00:00:01"
to="00:01:00">
<source src="http://www.example.com/source.mp4"/>
<track src="http://www.example.com/track"/>
</video>
'''
output = VideoDescriptor.from_xml(xml_data, module_system)
self.assertEquals(output.youtube_id_0_75, 'izygArpw-Qo')
self.assertEquals(output.youtube_id_1_0, 'p2Q6BrNhdh8')
self.assertEquals(output.youtube_id_1_25, '1EeWXzPdhSA')
self.assertEquals(output.youtube_id_1_5, 'rABDYkeK0x8')
self.assertEquals(output.show_captions, False)
self.assertEquals(output.start_time, 1.0)
self.assertEquals(output.end_time, 60)
self.assertEquals(output.track, 'http://www.example.com/track')
self.assertEquals(output.source, 'http://www.example.com/source.mp4')
def test_from_xml_missing_attributes(self):
"""
Ensure that attributes have the right values if they aren't
explicitly set in XML.
"""
module_system = DummySystem(load_error_modules=True)
xml_data = '''
<video display_name="Test Video"
youtube="1.0:p2Q6BrNhdh8,1.25:1EeWXzPdhSA"
show_captions="true">
<source src="http://www.example.com/source.mp4"/>
<track src="http://www.example.com/track"/>
</video>
'''
output = VideoDescriptor.from_xml(xml_data, module_system)
self.assertEquals(output.youtube_id_0_75, '')
self.assertEquals(output.youtube_id_1_0, 'p2Q6BrNhdh8')
self.assertEquals(output.youtube_id_1_25, '1EeWXzPdhSA')
self.assertEquals(output.youtube_id_1_5, '')
self.assertEquals(output.show_captions, True)
self.assertEquals(output.start_time, 0.0)
self.assertEquals(output.end_time, 0.0)
self.assertEquals(output.track, 'http://www.example.com/track')
self.assertEquals(output.source, 'http://www.example.com/source.mp4')
def test_from_xml_no_attributes(self):
"""
Make sure settings are correct if none are explicitly set in XML.
"""
module_system = DummySystem(load_error_modules=True)
xml_data = '<video></video>'
output = VideoDescriptor.from_xml(xml_data, module_system)
self.assertEquals(output.youtube_id_0_75, '')
self.assertEquals(output.youtube_id_1_0, 'OEoXaMPEzfM')
self.assertEquals(output.youtube_id_1_25, '')
self.assertEquals(output.youtube_id_1_5, '')
self.assertEquals(output.show_captions, True)
self.assertEquals(output.start_time, 0.0)
self.assertEquals(output.end_time, 0.0)
self.assertEquals(output.track, '')
self.assertEquals(output.source, '')

View File

@@ -16,10 +16,9 @@ from xmodule.gst_module import GraphicalSliderToolDescriptor
from xmodule.html_module import HtmlDescriptor
from xmodule.peer_grading_module import PeerGradingDescriptor
from xmodule.poll_module import PollDescriptor
from xmodule.video_module import VideoDescriptor
from xmodule.word_cloud_module import WordCloudDescriptor
from xmodule.crowdsource_hinter import CrowdsourceHinterDescriptor
from xmodule.videoalpha_module import VideoAlphaDescriptor
from xmodule.video_module import VideoDescriptor
from xmodule.seq_module import SequenceDescriptor
from xmodule.conditional_module import ConditionalDescriptor
from xmodule.randomize_module import RandomizeDescriptor
@@ -35,9 +34,8 @@ LEAF_XMODULES = (
HtmlDescriptor,
PeerGradingDescriptor,
PollDescriptor,
VideoDescriptor,
# This is being excluded because it has dependencies on django
#VideoAlphaDescriptor,
#VideoDescriptor,
WordCloudDescriptor,
)

View File

@@ -1,20 +1,36 @@
# pylint: disable=W0223
"""Video is ungraded Xmodule for support video content."""
"""Video is ungraded Xmodule for support video content.
It's new improved video module, which support additional feature:
- Can play non-YouTube video sources via in-browser HTML5 video player.
- YouTube defaults to HTML5 mode from the start.
- Speed changes in both YouTube and non-YouTube videos happen via
in-browser HTML5 video method (when in HTML5 mode).
- Navigational subtitles can be disabled altogether via an attribute
in XML.
"""
import json
import logging
from lxml import etree
from pkg_resources import resource_string, resource_listdir
import datetime
import time
from pkg_resources import resource_string
from django.http import Http404
from django.conf import settings
from xmodule.x_module import XModule
from xmodule.editing_module import TabsEditingDescriptor
from xmodule.raw_module import EmptyDataRawDescriptor
from xmodule.editing_module import MetadataOnlyEditingDescriptor
from xblock.core import Integer, Scope, String, Float, Boolean
from xmodule.xml_module import is_pointer_tag, name_to_pathname
from xmodule.modulestore import Location
from xmodule.modulestore.mongo import MongoModuleStore
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent
from xblock.core import Scope, String, Boolean, Float, List, Integer
import datetime
import time
log = logging.getLogger(__name__)
@@ -22,51 +38,118 @@ log = logging.getLogger(__name__)
class VideoFields(object):
"""Fields for `VideoModule` and `VideoDescriptor`."""
display_name = String(
display_name="Display Name",
help="This name appears in the horizontal navigation at the top of the page.",
display_name="Display Name", help="Display name for this module.",
default="Video",
scope=Scope.settings
)
position = Integer(
help="Current position in the video",
scope=Scope.user_state,
default=0
)
show_captions = Boolean(
help="This controls whether or not captions are shown by default.",
display_name="Show Captions",
scope=Scope.settings,
# it'd be nice to have a useful default but it screws up other things; so,
# use display_name_with_default for those
default="Video"
default=True
)
data = String(
help="XML data for the problem",
default='',
scope=Scope.content
# TODO: This should be moved to Scope.content, but this will
# require data migration to support the old video module.
youtube_id_1_0 = String(
help="This is the Youtube ID reference for the normal speed video.",
display_name="Youtube ID",
scope=Scope.settings,
default="OEoXaMPEzfM"
)
youtube_id_0_75 = String(
help="The Youtube ID for the .75x speed video.",
display_name="Youtube ID for .75x speed",
scope=Scope.settings,
default=""
)
youtube_id_1_25 = String(
help="The Youtube ID for the 1.25x speed video.",
display_name="Youtube ID for 1.25x speed",
scope=Scope.settings,
default=""
)
youtube_id_1_5 = String(
help="The Youtube ID for the 1.5x speed video.",
display_name="Youtube ID for 1.5x speed",
scope=Scope.settings,
default=""
)
start_time = Float(
help="Start time for the video.",
display_name="Start Time",
scope=Scope.settings,
default=0.0
)
end_time = Float(
help="End time for the video.",
display_name="End Time",
scope=Scope.settings,
default=0.0
)
source = String(
help="The external URL to download the video. This appears as a link beneath the video.",
display_name="Download Video",
scope=Scope.settings,
default=""
)
html5_sources = List(
help="A list of filenames to be used with HTML5 video. The first supported filetype will be displayed.",
display_name="Video Sources",
scope=Scope.settings,
default=[]
)
track = String(
help="The external URL to download the subtitle track. This appears as a link beneath the video.",
display_name="Download Track",
scope=Scope.settings,
default=""
)
sub = String(
help="The name of the subtitle track (for non-Youtube videos).",
display_name="HTML5 Subtitles",
scope=Scope.settings,
default=""
)
position = Integer(help="Current position in the video", scope=Scope.user_state, default=0)
show_captions = Boolean(help="This controls whether or not captions are shown by default.", display_name="Show Captions", scope=Scope.settings, default=True)
youtube_id_1_0 = String(help="This is the Youtube ID reference for the normal speed video.", display_name="Default Speed", scope=Scope.settings, default="OEoXaMPEzfM")
youtube_id_0_75 = String(help="The Youtube ID for the .75x speed video.", display_name="Speed: .75x", scope=Scope.settings, default="")
youtube_id_1_25 = String(help="The Youtube ID for the 1.25x speed video.", display_name="Speed: 1.25x", scope=Scope.settings, default="")
youtube_id_1_5 = String(help="The Youtube ID for the 1.5x speed video.", display_name="Speed: 1.5x", scope=Scope.settings, default="")
start_time = Float(help="Time the video starts", display_name="Start Time", scope=Scope.settings, default=0.0)
end_time = Float(help="Time the video ends", display_name="End Time", scope=Scope.settings, default=0.0)
source = String(help="The external URL to download the video. This appears as a link beneath the video.", display_name="Download Video", scope=Scope.settings, default="")
track = String(help="The external URL to download the subtitle track. This appears as a link beneath the video.", display_name="Download Track", scope=Scope.settings, default="")
class VideoModule(VideoFields, XModule):
"""Video Xmodule."""
"""
XML source example:
<video show_captions="true"
youtube="0.75:jNCf2gIqpeE,1.0:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg"
url_name="lecture_21_3" display_name="S19V3: Vacancies"
>
<source src=".../mit-3091x/M-3091X-FA12-L21-3_100.mp4"/>
<source src=".../mit-3091x/M-3091X-FA12-L21-3_100.webm"/>
<source src=".../mit-3091x/M-3091X-FA12-L21-3_100.ogv"/>
</video>
"""
video_time = 0
icon_class = 'video'
js = {
'coffee': [
resource_string(__name__, 'js/src/time.coffee'),
resource_string(__name__, 'js/src/video/display.coffee')
] +
[resource_string(__name__, 'js/src/video/display/' + filename)
for filename
in sorted(resource_listdir(__name__, 'js/src/video/display'))
if filename.endswith('.coffee')]
'js': [
resource_string(__name__, 'js/src/video/01_initialize.js'),
resource_string(__name__, 'js/src/video/02_html5_video.js'),
resource_string(__name__, 'js/src/video/03_video_player.js'),
resource_string(__name__, 'js/src/video/04_video_control.js'),
resource_string(__name__, 'js/src/video/05_video_quality_control.js'),
resource_string(__name__, 'js/src/video/06_video_progress_slider.js'),
resource_string(__name__, 'js/src/video/07_video_volume_control.js'),
resource_string(__name__, 'js/src/video/08_video_speed_control.js'),
resource_string(__name__, 'js/src/video/09_video_caption.js'),
resource_string(__name__, 'js/src/video/10_main.js')
]
}
css = {'scss': [resource_string(__name__, 'css/video/display.scss')]}
js_module_name = "Video"
def __init__(self, *args, **kwargs):
XModule.__init__(self, *args, **kwargs)
def handle_ajax(self, dispatch, data):
"""This is not being called right now and we raise 404 error."""
log.debug(u"GET {0}".format(data))
@@ -78,41 +161,59 @@ class VideoModule(VideoFields, XModule):
return json.dumps({'position': self.position})
def get_html(self):
if isinstance(modulestore(), MongoModuleStore):
caption_asset_path = StaticContent.get_base_url_path_for_course_assets(self.location) + '/subs_'
else:
# VS[compat]
# cdodge: filesystem static content support.
caption_asset_path = "/static/subs/"
get_ext = lambda filename: filename.rpartition('.')[-1]
sources = {get_ext(src): src for src in self.html5_sources}
sources['main'] = self.source
return self.system.render_template('video.html', {
'youtube_id_0_75': self.youtube_id_0_75,
'youtube_id_1_0': self.youtube_id_1_0,
'youtube_id_1_25': self.youtube_id_1_25,
'youtube_id_1_5': self.youtube_id_1_5,
'youtube_streams': _create_youtube_string(self),
'id': self.location.html_id(),
'position': self.position,
'source': self.source,
'sub': self.sub,
'sources': sources,
'track': self.track,
'display_name': self.display_name_with_default,
'caption_asset_path': "/static/subs/",
'show_captions': 'true' if self.show_captions else 'false',
# This won't work when we move to data that
# isn't on the filesystem
'data_dir': getattr(self, 'data_dir', None),
'caption_asset_path': caption_asset_path,
'show_captions': json.dumps(self.show_captions),
'start': self.start_time,
'end': self.end_time
'end': self.end_time,
'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True)
})
class VideoDescriptor(VideoFields,
MetadataOnlyEditingDescriptor,
EmptyDataRawDescriptor):
class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor):
"""Descriptor for `VideoModule`."""
module_class = VideoModule
tabs = [
# {
# 'name': "Subtitles",
# 'template': "video/subtitles.html",
# },
{
'name': "Settings",
'template': "tabs/metadata-edit-tab.html",
'current': True
}
]
def __init__(self, *args, **kwargs):
super(VideoDescriptor, self).__init__(*args, **kwargs)
# If we don't have a `youtube_id_1_0`, this is an XML course
# and we parse out the fields.
if self.data and 'youtube_id_1_0' not in self._model_data:
_parse_video_xml(self, self.data)
@property
def non_editable_metadata_fields(self):
non_editable_fields = super(MetadataOnlyEditingDescriptor, self).non_editable_metadata_fields
non_editable_fields.extend([VideoModule.start_time,
VideoModule.end_time])
return non_editable_fields
# For backwards compatibility -- if we've got XML data, parse
# it out and set the metadata fields
if self.data:
model_data = VideoDescriptor._parse_video_xml(self.data)
self._model_data.update(model_data)
del self.data
@classmethod
def from_xml(cls, xml_data, system, org=None, course=None):
@@ -126,102 +227,164 @@ class VideoDescriptor(VideoFields,
org and course are optional strings that will be used in the generated modules
url identifiers
"""
video = super(VideoDescriptor, cls).from_xml(xml_data, system, org, course)
_parse_video_xml(video, video.data)
xml_object = etree.fromstring(xml_data)
url_name = xml_object.get('url_name', xml_object.get('slug'))
location = Location(
'i4x', org, course, 'video', url_name
)
if is_pointer_tag(xml_object):
filepath = cls._format_filepath(xml_object.tag, name_to_pathname(url_name))
xml_data = etree.tostring(cls.load_file(filepath, system.resources_fs, location))
model_data = VideoDescriptor._parse_video_xml(xml_data)
model_data['location'] = location
video = cls(system, model_data)
return video
def export_to_xml(self, resource_fs):
"""
Returns an xml string representing this module.
"""
xml = etree.Element('video')
youtube_string = _create_youtube_string(self)
# Mild workaround to ensure that tests pass -- if a field
# is set to its default value, we don't need to write it out.
if youtube_string == '1.00:OEoXaMPEzfM':
youtube_string = ''
attrs = {
'display_name': self.display_name,
'show_captions': json.dumps(self.show_captions),
'youtube': youtube_string,
'start_time': datetime.timedelta(seconds=self.start_time),
'end_time': datetime.timedelta(seconds=self.end_time),
'sub': self.sub,
'url_name': self.url_name
}
fields = {field.name: field for field in self.fields}
for key, value in attrs.items():
# Mild workaround to ensure that tests pass -- if a field
# is set to its default value, we don't need to write it out.
if key in fields and fields[key].default == getattr(self, key):
continue
if value:
xml.set(key, str(value))
def _parse_video_xml(video, xml_data):
"""
Parse video fields out of xml_data. The fields are set if they are
present in the XML.
"""
if not xml_data:
return
for source in self.html5_sources:
ele = etree.Element('source')
ele.set('src', source)
xml.append(ele)
xml = etree.fromstring(xml_data)
if self.track:
ele = etree.Element('track')
ele.set('src', self.track)
xml.append(ele)
return etree.tostring(xml, pretty_print=True)
display_name = xml.get('display_name')
if display_name:
video.display_name = display_name
youtube = xml.get('youtube')
if youtube:
speeds = _parse_youtube(youtube)
if speeds['0.75']:
video.youtube_id_0_75 = speeds['0.75']
if speeds['1.00']:
video.youtube_id_1_0 = speeds['1.00']
if speeds['1.25']:
video.youtube_id_1_25 = speeds['1.25']
if speeds['1.50']:
video.youtube_id_1_5 = speeds['1.50']
show_captions = xml.get('show_captions')
if show_captions:
video.show_captions = json.loads(show_captions)
source = _get_first_external(xml, 'source')
if source:
video.source = source
track = _get_first_external(xml, 'track')
if track:
video.track = track
start_time = _parse_time(xml.get('from'))
if start_time:
video.start_time = start_time
end_time = _parse_time(xml.get('to'))
if end_time:
video.end_time = end_time
def _get_first_external(xmltree, tag):
"""
Returns the src attribute of the nested `tag` in `xmltree`, if it
exists.
"""
for element in xmltree.findall(tag):
src = element.get('src')
if src:
return src
return None
def _parse_youtube(data):
"""
Parses a string of Youtube IDs such as "1.0:AXdE34_U,1.5:VO3SxfeD"
into a dictionary. Necessary for backwards compatibility with
XML-based courses.
"""
ret = {'0.75': '', '1.00': '', '1.25': '', '1.50': ''}
if data == '':
@staticmethod
def _parse_youtube(data):
"""
Parses a string of Youtube IDs such as "1.0:AXdE34_U,1.5:VO3SxfeD"
into a dictionary. Necessary for backwards compatibility with
XML-based courses.
"""
ret = {'0.75': '', '1.00': '', '1.25': '', '1.50': ''}
if data == '':
return ret
videos = data.split(',')
for video in videos:
pieces = video.split(':')
# HACK
# To elaborate somewhat: in many LMS tests, the keys for
# Youtube IDs are inconsistent. Sometimes a particular
# speed isn't present, and formatting is also inconsistent
# ('1.0' versus '1.00'). So it's necessary to either do
# something like this or update all the tests to work
# properly.
ret['%.2f' % float(pieces[0])] = pieces[1]
return ret
videos = data.split(',')
for video in videos:
pieces = video.split(':')
# HACK
# To elaborate somewhat: in many LMS tests, the keys for
# Youtube IDs are inconsistent. Sometimes a particular
# speed isn't present, and formatting is also inconsistent
# ('1.0' versus '1.00'). So it's necessary to either do
# something like this or update all the tests to work
# properly.
ret['%.2f' % float(pieces[0])] = pieces[1]
return ret
@staticmethod
def _parse_video_xml(xml_data):
"""
Parse video fields out of xml_data. The fields are set if they are
present in the XML.
"""
xml = etree.fromstring(xml_data)
model_data = {}
conversions = {
'show_captions': json.loads,
'start_time': VideoDescriptor._parse_time,
'end_time': VideoDescriptor._parse_time
}
# Convert between key names for certain attributes --
# necessary for backwards compatibility.
compat_keys = {
'from': 'start_time',
'to': 'end_time'
}
sources = xml.findall('source')
if sources:
model_data['html5_sources'] = [ele.get('src') for ele in sources]
model_data['source'] = model_data['html5_sources'][0]
track = xml.find('track')
if track is not None:
model_data['track'] = track.get('src')
for attr, value in xml.items():
if attr in compat_keys:
attr = compat_keys[attr]
if attr in VideoDescriptor.metadata_to_strip + ('url_name', 'name'):
continue
if attr == 'youtube':
speeds = VideoDescriptor._parse_youtube(value)
for speed, youtube_id in speeds.items():
# should have made these youtube_id_1_00 for
# cleanliness, but hindsight doesn't need glasses
normalized_speed = speed[:-1] if speed.endswith('0') else speed
# If the user has specified html5 sources, make sure we don't use the default video
if youtube_id != '' or 'html5_sources' in model_data:
model_data['youtube_id_{0}'.format(normalized_speed.replace('.', '_'))] = youtube_id
else:
# Convert XML attrs into Python values.
if attr in conversions:
value = conversions[attr](value)
model_data[attr] = value
return model_data
@staticmethod
def _parse_time(str_time):
"""Converts s in '12:34:45' format to seconds. If s is
None, returns empty string"""
if not str_time:
return ''
else:
obj_time = time.strptime(str_time, '%H:%M:%S')
return datetime.timedelta(
hours=obj_time.tm_hour,
minutes=obj_time.tm_min,
seconds=obj_time.tm_sec
).total_seconds()
def _parse_time(str_time):
"""Converts s in '12:34:45' format to seconds. If s is
None, returns empty string"""
if str_time is None or str_time == '':
return ''
else:
obj_time = time.strptime(str_time, '%H:%M:%S')
return datetime.timedelta(
hours=obj_time.tm_hour,
minutes=obj_time.tm_min,
seconds=obj_time.tm_sec
).total_seconds()
def _create_youtube_string(module):
"""
Create a string of Youtube IDs from `module`'s metadata
attributes. Only writes a speed if an ID is present in the
module. Necessary for backwards compatibility with XML-based
courses.
"""
youtube_ids = [
module.youtube_id_0_75,
module.youtube_id_1_0,
module.youtube_id_1_25,
module.youtube_id_1_5
]
youtube_speeds = ['0.75', '1.00', '1.25', '1.50']
return ','.join([':'.join(pair)
for pair
in zip(youtube_speeds, youtube_ids)
if pair[1]])

View File

@@ -1,367 +0,0 @@
# pylint: disable=W0223
"""VideoAlpha is ungraded Xmodule for support video content.
It's new improved video module, which support additional feature:
- Can play non-YouTube video sources via in-browser HTML5 video player.
- YouTube defaults to HTML5 mode from the start.
- Speed changes in both YouTube and non-YouTube videos happen via
in-browser HTML5 video method (when in HTML5 mode).
- Navigational subtitles can be disabled altogether via an attribute
in XML.
"""
import json
import logging
from lxml import etree
from pkg_resources import resource_string
from django.http import Http404
from django.conf import settings
from xmodule.x_module import XModule
from xmodule.editing_module import TabsEditingDescriptor
from xmodule.raw_module import EmptyDataRawDescriptor
from xmodule.modulestore.mongo import MongoModuleStore
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent
from xblock.core import Scope, String, Boolean, Float, List, Integer
import datetime
import time
log = logging.getLogger(__name__)
class VideoAlphaFields(object):
"""Fields for `VideoAlphaModule` and `VideoAlphaDescriptor`."""
display_name = String(
display_name="Display Name", help="Display name for this module.",
default="Video Alpha",
scope=Scope.settings
)
position = Integer(
help="Current position in the video",
scope=Scope.user_state,
default=0
)
show_captions = Boolean(
help="This controls whether or not captions are shown by default.",
display_name="Show Captions",
scope=Scope.settings,
default=True
)
# TODO: This should be moved to Scope.content, but this will
# require data migration to support the old video module.
youtube_id_1_0 = String(
help="This is the Youtube ID reference for the normal speed video.",
display_name="Youtube ID",
scope=Scope.settings,
default="OEoXaMPEzfM"
)
youtube_id_0_75 = String(
help="The Youtube ID for the .75x speed video.",
display_name="Youtube ID for .75x speed",
scope=Scope.settings,
default=""
)
youtube_id_1_25 = String(
help="The Youtube ID for the 1.25x speed video.",
display_name="Youtube ID for 1.25x speed",
scope=Scope.settings,
default=""
)
youtube_id_1_5 = String(
help="The Youtube ID for the 1.5x speed video.",
display_name="Youtube ID for 1.5x speed",
scope=Scope.settings,
default=""
)
start_time = Float(
help="Start time for the video.",
display_name="Start Time",
scope=Scope.settings,
default=0.0
)
end_time = Float(
help="End time for the video.",
display_name="End Time",
scope=Scope.settings,
default=0.0
)
source = String(
help="The external URL to download the video. This appears as a link beneath the video.",
display_name="Download Video",
scope=Scope.settings,
default=""
)
html5_sources = List(
help="A list of filenames to be used with HTML5 video. The first supported filetype will be displayed.",
display_name="Video Sources",
scope=Scope.settings,
default=[]
)
track = String(
help="The external URL to download the subtitle track. This appears as a link beneath the video.",
display_name="Download Track",
scope=Scope.settings,
default=""
)
sub = String(
help="The name of the subtitle track (for non-Youtube videos).",
display_name="HTML5 Subtitles",
scope=Scope.settings,
default=""
)
class VideoAlphaModule(VideoAlphaFields, XModule):
"""
XML source example:
<videoalpha show_captions="true"
youtube="0.75:jNCf2gIqpeE,1.0:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg"
url_name="lecture_21_3" display_name="S19V3: Vacancies"
>
<source src=".../mit-3091x/M-3091X-FA12-L21-3_100.mp4"/>
<source src=".../mit-3091x/M-3091X-FA12-L21-3_100.webm"/>
<source src=".../mit-3091x/M-3091X-FA12-L21-3_100.ogv"/>
</videoalpha>
"""
video_time = 0
icon_class = 'video'
js = {
'js': [
resource_string(__name__, 'js/src/videoalpha/01_initialize.js'),
resource_string(__name__, 'js/src/videoalpha/02_html5_video.js'),
resource_string(__name__, 'js/src/videoalpha/03_video_player.js'),
resource_string(__name__, 'js/src/videoalpha/04_video_control.js'),
resource_string(__name__, 'js/src/videoalpha/05_video_quality_control.js'),
resource_string(__name__, 'js/src/videoalpha/06_video_progress_slider.js'),
resource_string(__name__, 'js/src/videoalpha/07_video_volume_control.js'),
resource_string(__name__, 'js/src/videoalpha/08_video_speed_control.js'),
resource_string(__name__, 'js/src/videoalpha/09_video_caption.js'),
resource_string(__name__, 'js/src/videoalpha/10_main.js')
]
}
css = {'scss': [resource_string(__name__, 'css/videoalpha/display.scss')]}
js_module_name = "VideoAlpha"
def handle_ajax(self, dispatch, data):
"""This is not being called right now and we raise 404 error."""
log.debug(u"GET {0}".format(data))
log.debug(u"DISPATCH {0}".format(dispatch))
raise Http404()
def get_instance_state(self):
"""Return information about state (position)."""
return json.dumps({'position': self.position})
def get_html(self):
if isinstance(modulestore(), MongoModuleStore):
caption_asset_path = StaticContent.get_base_url_path_for_course_assets(self.location) + '/subs_'
else:
# VS[compat]
# cdodge: filesystem static content support.
caption_asset_path = "/static/subs/"
get_ext = lambda filename: filename.rpartition('.')[-1]
sources = {get_ext(src): src for src in self.html5_sources}
sources['main'] = self.source
return self.system.render_template('videoalpha.html', {
'youtube_streams': _create_youtube_string(self),
'id': self.location.html_id(),
'sub': self.sub,
'sources': sources,
'track': self.track,
'display_name': self.display_name_with_default,
# This won't work when we move to data that
# isn't on the filesystem
'data_dir': getattr(self, 'data_dir', None),
'caption_asset_path': caption_asset_path,
'show_captions': json.dumps(self.show_captions),
'start': self.start_time,
'end': self.end_time,
'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True)
})
class VideoAlphaDescriptor(VideoAlphaFields, TabsEditingDescriptor, EmptyDataRawDescriptor):
"""Descriptor for `VideoAlphaModule`."""
module_class = VideoAlphaModule
tabs = [
# {
# 'name': "Subtitles",
# 'template': "videoalpha/subtitles.html",
# },
{
'name': "Settings",
'template': "tabs/metadata-edit-tab.html",
'current': True
}
]
def __init__(self, *args, **kwargs):
super(VideoAlphaDescriptor, self).__init__(*args, **kwargs)
# For backwards compatibility -- if we've got XML data, parse
# it out and set the metadata fields
if self.data:
model_data = VideoAlphaDescriptor._parse_video_xml(self.data)
self._model_data.update(model_data)
del self.data
@classmethod
def from_xml(cls, xml_data, system, org=None, course=None):
"""
Creates an instance of this descriptor from the supplied xml_data.
This may be overridden by subclasses
xml_data: A string of xml that will be translated into data and children for
this module
system: A DescriptorSystem for interacting with external resources
org and course are optional strings that will be used in the generated modules
url identifiers
"""
# Calling from_xml of XmlDescritor, to get right Location, when importing from XML
video = super(VideoAlphaDescriptor, cls).from_xml(xml_data, system, org, course)
return video
def export_to_xml(self, resource_fs):
"""
Returns an xml string representing this module.
"""
xml = etree.Element('videoalpha')
attrs = {
'display_name': self.display_name,
'show_captions': json.dumps(self.show_captions),
'youtube': _create_youtube_string(self),
'start_time': datetime.timedelta(seconds=self.start_time),
'end_time': datetime.timedelta(seconds=self.end_time),
'sub': self.sub
}
for key, value in attrs.items():
if value:
xml.set(key, str(value))
for source in self.html5_sources:
ele = etree.Element('source')
ele.set('src', source)
xml.append(ele)
if self.track:
ele = etree.Element('track')
ele.set('src', self.track)
xml.append(ele)
return etree.tostring(xml, pretty_print=True)
@staticmethod
def _parse_youtube(data):
"""
Parses a string of Youtube IDs such as "1.0:AXdE34_U,1.5:VO3SxfeD"
into a dictionary. Necessary for backwards compatibility with
XML-based courses.
"""
ret = {'0.75': '', '1.00': '', '1.25': '', '1.50': ''}
if data == '':
return ret
videos = data.split(',')
for video in videos:
pieces = video.split(':')
# HACK
# To elaborate somewhat: in many LMS tests, the keys for
# Youtube IDs are inconsistent. Sometimes a particular
# speed isn't present, and formatting is also inconsistent
# ('1.0' versus '1.00'). So it's necessary to either do
# something like this or update all the tests to work
# properly.
ret['%.2f' % float(pieces[0])] = pieces[1]
return ret
@staticmethod
def _parse_video_xml(xml_data):
"""
Parse video fields out of xml_data. The fields are set if they are
present in the XML.
"""
xml = etree.fromstring(xml_data)
model_data = {}
conversions = {
'show_captions': json.loads,
'start_time': VideoAlphaDescriptor._parse_time,
'end_time': VideoAlphaDescriptor._parse_time
}
# VideoModule and VideoAlphaModule use different names for
# these attributes -- need to convert between them
video_compat = {
'from': 'start_time',
'to': 'end_time'
}
sources = xml.findall('source')
if sources:
model_data['html5_sources'] = [ele.get('src') for ele in sources]
model_data['source'] = model_data['html5_sources'][0]
track = xml.find('track')
if track is not None:
model_data['track'] = track.get('src')
for attr, value in xml.items():
if attr in video_compat:
attr = video_compat[attr]
if attr == 'youtube':
speeds = VideoAlphaDescriptor._parse_youtube(value)
for speed, youtube_id in speeds.items():
# should have made these youtube_id_1_00 for
# cleanliness, but hindsight doesn't need glasses
normalized_speed = speed[:-1] if speed.endswith('0') else speed
# If the user has specified html5 sources, make sure we don't use the default video
if youtube_id != '' or 'html5_sources' in model_data:
model_data['youtube_id_{0}'.format(normalized_speed.replace('.', '_'))] = youtube_id
else:
# Convert XML attrs into Python values.
if attr in conversions:
value = conversions[attr](value)
model_data[attr] = value
return model_data
@staticmethod
def _parse_time(str_time):
"""Converts s in '12:34:45' format to seconds. If s is
None, returns empty string"""
if not str_time:
return ''
else:
obj_time = time.strptime(str_time, '%H:%M:%S')
return datetime.timedelta(
hours=obj_time.tm_hour,
minutes=obj_time.tm_min,
seconds=obj_time.tm_sec
).total_seconds()
def _create_youtube_string(module):
"""
Create a string of Youtube IDs from `module`'s metadata
attributes. Only writes a speed if an ID is present in the
module. Necessary for backwards compatibility with XML-based
courses.
"""
youtube_ids = [
module.youtube_id_0_75,
module.youtube_id_1_0,
module.youtube_id_1_25,
module.youtube_id_1_5
]
youtube_speeds = ['0.75', '1.00', '1.25', '1.50']
return ','.join([':'.join(pair)
for pair
in zip(youtube_speeds, youtube_ids)
if pair[1]])

View File

@@ -711,20 +711,20 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
# =============================== BUILTIN METHODS ==========================
def __eq__(self, other):
eq = (self.__class__ == other.__class__ and
return (self.__class__ == other.__class__ and
all(getattr(self, attr, None) == getattr(other, attr, None)
for attr in self.equality_attributes))
return eq
def __repr__(self):
return ("{class_}({system!r}, location={location!r},"
" model_data={model_data!r})".format(
class_=self.__class__.__name__,
system=self.system,
location=self.location,
model_data=self._model_data,
))
return (
"{class_}({system!r}, location={location!r},"
" model_data={model_data!r})".format(
class_=self.__class__.__name__,
system=self.system,
location=self.location,
model_data=self._model_data,
)
)
@property
def non_editable_metadata_fields(self):
@@ -785,15 +785,17 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
editor_type = "Float"
elif isinstance(field, List):
editor_type = "List"
metadata_fields[field.name] = {'field_name': field.name,
'type': editor_type,
'display_name': field.display_name,
'value': field.to_json(value),
'options': [] if values is None else values,
'default_value': field.to_json(default_value),
'inheritable': inheritable,
'explicitly_set': explicitly_set,
'help': field.help}
metadata_fields[field.name] = {
'field_name': field.name,
'type': editor_type,
'display_name': field.display_name,
'value': field.to_json(value),
'options': [] if values is None else values,
'default_value': field.to_json(default_value),
'inheritable': inheritable,
'explicitly_set': explicitly_set,
'help': field.help,
}
return metadata_fields
@@ -885,28 +887,14 @@ class ModuleSystem(Runtime):
Note that these functions can be closures over e.g. a django request
and user, or other environment-specific info.
'''
def __init__(self,
ajax_url,
track_function,
get_module,
render_template,
replace_urls,
xblock_model_data,
user=None,
filestore=None,
debug=False,
xqueue=None,
publish=None,
node_path="",
anonymous_student_id='',
course_id=None,
open_ended_grading_interface=None,
s3_interface=None,
cache=None,
can_execute_unsafe_code=None,
replace_course_urls=None,
replace_jump_to_id_urls=None
):
def __init__(
self, ajax_url, track_function, get_module, render_template,
replace_urls, xblock_model_data, user=None, filestore=None,
debug=False, xqueue=None, publish=None, node_path="",
anonymous_student_id='', course_id=None,
open_ended_grading_interface=None, s3_interface=None,
cache=None, can_execute_unsafe_code=None, replace_course_urls=None,
replace_jump_to_id_urls=None):
'''
Create a closure around the system environment.

View File

@@ -0,0 +1,379 @@
function callPeriodicallyUntil(block, delay, condition, i) { // i is optional
i = i || 0;
block(i);
waits(delay);
runs(function () {
if (!condition()) {
callPeriodicallyUntil(block, delay, condition, i + 1);
}
});
}
describe("Formula Equation Preview", function () {
beforeEach(function () {
// Simulate an environment conducive to a FormulaEquationInput
var $fixture = this.$fixture = $('\
<section class="problems-wrapper" data-url="THE_URL">\
<section class="formulaequationinput">\
<input type="text" id="input_THE_ID" data-input-id="THE_ID"\
value="prefilled_value"/>\
<div id="input_THE_ID_preview" class="equation">\
\[\]\
<img class="loading" style="visibility:hidden"/>\
</div>\
</section>\
</section>');
// Modify $ for the test to search the fixture.
var old$find = this.old$find = $.find;
$.find = function () {
// Given the default context, swap it out for the fixture.
if (arguments[1] == document) {
arguments[1] = $fixture[0];
}
// Call old function.
return old$find.apply(this, arguments);
}
$.find.matchesSelector = old$find.matchesSelector;
this.oldDGEBI = document.getElementById;
document.getElementById = function (id) {
return $("*#" + id)[0] || null;
};
// Catch the AJAX requests
var ajaxTimes = this.ajaxTimes = [];
this.oldProblem = window.Problem;
window.Problem = {};
Problem.inputAjax = jasmine.createSpy('Problem.inputAjax')
.andCallFake(function () {
ajaxTimes.push(Date.now());
});
// Spy on MathJax
this.jax = 'OUTPUT_JAX';
this.oldMathJax = window.MathJax;
window.MathJax = {Hub: {}};
MathJax.Hub.getAllJax = jasmine.createSpy('MathJax.Hub.getAllJax')
.andReturn([this.jax]);
MathJax.Hub.Queue = jasmine.createSpy('MathJax.Hub.Queue');
});
it('(the test) should be able to swap out the behavior of $', function () {
// This was a pain to write, make sure it doesn't get screwed up.
// Find the DOM element using DOM methods.
var legitInput = this.$fixture[0].getElementsByTagName("input")[0];
// Use the (modified) jQuery.
var jqueryInput = $('.formulaequationinput input');
var byIdInput = $("#input_THE_ID");
expect(jqueryInput[0]).toEqual(legitInput);
expect(byIdInput[0]).toEqual(legitInput);
});
describe('Ajax requests', function () {
it('has an initial request with the correct parameters', function () {
formulaEquationPreview.enable();
expect(MathJax.Hub.Queue).toHaveBeenCalled();
// Do what Queue would've done--call the function.
var args = MathJax.Hub.Queue.mostRecentCall.args;
args[1].call(args[0]);
// This part may be asynchronous, so wait.
waitsFor(function () {
return Problem.inputAjax.wasCalled;
}, "AJAX never called initially", 1000);
runs(function () {
expect(Problem.inputAjax.callCount).toEqual(1);
// Use `.toEqual` rather than `.toHaveBeenCalledWith`
// since it supports `jasmine.any`.
expect(Problem.inputAjax.mostRecentCall.args).toEqual([
"THE_URL",
"THE_ID",
"preview_formcalc",
{formula: "prefilled_value",
request_start: jasmine.any(Number)},
jasmine.any(Function)
]);
});
});
it('makes a request on user input', function () {
formulaEquationPreview.enable();
$('#input_THE_ID').val('user_input').trigger('input');
// This part is probably asynchronous
waitsFor(function () {
return Problem.inputAjax.wasCalled;
}, "AJAX never called on user input", 1000);
runs(function () {
expect(Problem.inputAjax.mostRecentCall.args[3].formula
).toEqual('user_input');
});
});
it("shouldn't be requested for empty input", function () {
formulaEquationPreview.enable();
MathJax.Hub.Queue.reset();
// When we make an input of '',
$('#input_THE_ID').val('').trigger('input');
// Either it makes a request or jumps straight into displaying ''.
waitsFor(function () {
// (Short circuit if `inputAjax` is indeed called)
return Problem.inputAjax.wasCalled ||
MathJax.Hub.Queue.wasCalled;
}, "AJAX never called on user input", 1000);
runs(function () {
// Expect the request not to have been called.
expect(Problem.inputAjax).not.toHaveBeenCalled();
});
});
it('should limit the number of requests per second', function () {
formulaEquationPreview.enable();
var minDelay = formulaEquationPreview.minDelay;
var end = Date.now() + minDelay * 1.1;
var step = 10; // ms
var $input = $('#input_THE_ID');
var value;
function inputAnother(iter) {
value = "math input " + iter;
$input.val(value).trigger('input');
}
callPeriodicallyUntil(inputAnother, step, function () {
return Date.now() > end; // Stop when we get to `end`.
});
waitsFor(function () {
return Problem.inputAjax.wasCalled &&
Problem.inputAjax.mostRecentCall.args[3].formula == value;
}, "AJAX never called with final value from input", 1000);
runs(function () {
// There should be 2 or 3 calls (depending on leading edge).
expect(Problem.inputAjax.callCount).not.toBeGreaterThan(3);
// The calls should happen approximately `minDelay` apart.
for (var i =1; i < this.ajaxTimes.length; i ++) {
var diff = this.ajaxTimes[i] - this.ajaxTimes[i - 1];
expect(diff).toBeGreaterThan(minDelay - 10);
}
});
});
});
describe("Visible results (icon and mathjax)", function () {
it('should display a loading icon when requests are open', function () {
formulaEquationPreview.enable();
var $img = $("img.loading");
expect($img.css('visibility')).toEqual('hidden');
$("#input_THE_ID").val("different").trigger('input');
expect($img.css('visibility')).toEqual('visible');
// Don't let it fail later.
waitsFor(function () {
return Problem.inputAjax.wasCalled;
});
});
it('should update MathJax and loading icon on callback', function () {
formulaEquationPreview.enable();
$('#input_THE_ID').val('user_input').trigger('input');
waitsFor(function () {
return Problem.inputAjax.wasCalled;
}, "AJAX never called initially", 1000);
runs(function () {
var args = Problem.inputAjax.mostRecentCall.args;
var callback = args[4];
callback({
preview: 'THE_FORMULA',
request_start: args[3].request_start
});
// The only request returned--it should hide the loading icon.
expect($("img.loading").css('visibility')).toEqual('hidden');
// We should look in the preview div for the MathJax.
var previewDiv = $("div")[0];
expect(MathJax.Hub.getAllJax).toHaveBeenCalledWith(previewDiv);
// Refresh the MathJax.
expect(MathJax.Hub.Queue).toHaveBeenCalledWith(
['Text', this.jax, 'THE_FORMULA'],
['Reprocess', this.jax]
);
});
});
it('should display errors from the server well', function () {
var $img = $("img.loading");
formulaEquationPreview.enable();
MathJax.Hub.Queue.reset();
$("#input_THE_ID").val("different").trigger('input');
waitsFor(function () {
return Problem.inputAjax.wasCalled;
}, "AJAX never called initially", 1000);
runs(function () {
var args = Problem.inputAjax.mostRecentCall.args;
var callback = args[4];
callback({
error: 'OOPSIE',
request_start: args[3].request_start
});
expect(MathJax.Hub.Queue).not.toHaveBeenCalled();
expect($img.css('visibility')).toEqual('visible');
});
var errorDelay = formulaEquationPreview.errorDelay * 1.1;
waitsFor(function () {
return MathJax.Hub.Queue.wasCalled;
}, "Error message never displayed", 2000);
runs(function () {
// Refresh the MathJax.
expect(MathJax.Hub.Queue).toHaveBeenCalledWith(
['Text', this.jax, '\\text{OOPSIE}'],
['Reprocess', this.jax]
);
expect($img.css('visibility')).toEqual('hidden');
});
});
});
describe('Multiple callbacks', function () {
beforeEach(function () {
formulaEquationPreview.enable();
MathJax.Hub.Queue.reset();
$('#input_THE_ID').val('different').trigger('input');
waitsFor(function () {
return Problem.inputAjax.wasCalled;
});
runs(function () {
$("#input_THE_ID").val("different2").trigger('input');
});
waitsFor(function () {
return Problem.inputAjax.callCount > 1;
});
runs(function () {
var args = Problem.inputAjax.argsForCall;
var response0 = {
preview: 'THE_FORMULA_0',
request_start: args[0][3].request_start
};
var response1 = {
preview: 'THE_FORMULA_1',
request_start: args[1][3].request_start
};
this.callbacks = [args[0][4], args[1][4]];
this.responses = [response0, response1];
});
});
it('should update requests sequentially', function () {
var $img = $("img.loading");
expect($img.css('visibility')).toEqual('visible');
this.callbacks[0](this.responses[0]);
expect(MathJax.Hub.Queue).toHaveBeenCalledWith(
['Text', this.jax, 'THE_FORMULA_0'],
['Reprocess', this.jax]
);
expect($img.css('visibility')).toEqual('visible');
this.callbacks[1](this.responses[1]);
expect(MathJax.Hub.Queue).toHaveBeenCalledWith(
['Text', this.jax, 'THE_FORMULA_1'],
['Reprocess', this.jax]
);
expect($img.css('visibility')).toEqual('hidden')
});
it("shouldn't display outdated information", function () {
var $img = $("img.loading");
expect($img.css('visibility')).toEqual('visible');
// Switch the order (1 returns before 0)
this.callbacks[1](this.responses[1]);
expect(MathJax.Hub.Queue).toHaveBeenCalledWith(
['Text', this.jax, 'THE_FORMULA_1'],
['Reprocess', this.jax]
);
expect($img.css('visibility')).toEqual('hidden')
MathJax.Hub.Queue.reset();
this.callbacks[0](this.responses[0]);
expect(MathJax.Hub.Queue).not.toHaveBeenCalled();
expect($img.css('visibility')).toEqual('hidden')
});
it("shouldn't show an error if the responses are close together",
function () {
this.callbacks[0]({
error: 'OOPSIE',
request_start: this.responses[0].request_start
});
expect(MathJax.Hub.Queue).not.toHaveBeenCalled();
// Error message waiting to be displayed
this.callbacks[1](this.responses[1]);
expect(MathJax.Hub.Queue).toHaveBeenCalledWith(
['Text', this.jax, 'THE_FORMULA_1'],
['Reprocess', this.jax]
);
// Make sure that it doesn't indeed show up later
MathJax.Hub.Queue.reset();
var errorDelay = formulaEquationPreview.errorDelay * 1.1;
waits(errorDelay);
runs(function () {
expect(MathJax.Hub.Queue).not.toHaveBeenCalled();
})
});
});
afterEach(function () {
// Return jQuery
$.find = this.old$find;
document.getElementById = this.oldDGEBI;
// Return Problem
Problem = this.oldProblem;
if (Problem === undefined) {
delete Problem;
}
// Return MathJax
MathJax = this.oldMathJax;
if (MathJax === undefined) {
delete MathJax;
}
});
});

View File

@@ -1,4 +1,4 @@
describe("A jsinput has:", function () {
xdescribe("A jsinput has:", function () {
beforeEach(function () {
$('#fixture').remove();

View File

@@ -0,0 +1,161 @@
var formulaEquationPreview = {
minDelay: 300, // Minimum time between requests sent out.
errorDelay: 1500 // Wait time before showing error (prevent frustration).
};
/** Setup the FormulaEquationInputs and associated javascript code. */
formulaEquationPreview.enable = function () {
/**
* Accumulate all the variables and attach event handlers.
* This includes rate-limiting `sendRequest` and creating a closure for
* its callback.
*/
function setupInput() {
var $this = $(this); // cache the jQuery object
var $preview = $("#" + this.id + "_preview");
var inputData = {
// These are the mutable values
lastSent: 0,
isWaitingForRequest: false,
requestVisible: 0,
errorDelayTimeout: null,
// The following don't change
// Find the URL from the closest parent problems-wrapper.
url: $this.closest('.problems-wrapper').data('url'),
// Grab the input id from the input.
inputId: $this.data('input-id'),
// Store the DOM/MathJax elements in which visible output occurs.
$preview: $preview,
// Note: sometimes MathJax hasn't finished loading yet.
jax: MathJax.Hub.getAllJax($preview[0])[0],
$img: $preview.find("img.loading"),
requestCallback: null // Fill it in in a bit.
};
// Give callback access to `inputData` (fill in first parameter).
inputData.requestCallback = _.partial(updatePage, inputData);
// Limit `sendRequest` and have it show the loading icon.
var throttledRequest = _.throttle(
sendRequest,
formulaEquationPreview.minDelay,
{leading: false}
);
// The following acts as a closure of `inputData`.
var initializeRequest = function () {
// Show the loading icon.
inputData.$img.css('visibility', 'visible');
inputData.isWaitingForRequest = true;
throttledRequest(inputData, this.value);
};
$this.on("input", initializeRequest);
// send an initial
MathJax.Hub.Queue(this, initializeRequest);
}
/**
* Fire off a request for a preview of the current value.
* Also send along the time it was sent, and store that locally.
*/
function sendRequest(inputData, formula) {
// Save the time.
var now = Date.now();
inputData.lastSent = now;
// We're sending it.
inputData.isWaitingForRequest = false;
if (formula) {
// Send the request.
Problem.inputAjax(
inputData.url,
inputData.inputId,
'preview_formcalc',
{"formula" : formula, "request_start" : now},
inputData.requestCallback
);
// ).fail(function () {
// // This is run when ajax call fails.
// // Have an error message and other stuff here?
// inputData.$img.css('visibility', 'hidden');
// }); */
}
else {
inputData.requestCallback({
preview: '',
request_start: now
});
}
}
/**
* Respond to the preview request if need be.
* Stop if it is outdated (i.e. a later request arrived back earlier)
* Otherwise:
* -Refresh the MathJax
* -Stop the loading icon if this is the most recent request
* -Save which request is visible
*/
function updatePage(inputData, response) {
var requestStart = response['request_start'];
if (requestStart == inputData.lastSent &&
!inputData.isWaitingForRequest) {
// Disable icon.
inputData.$img.css('visibility', 'hidden');
}
if (requestStart <= inputData.requestVisible) {
// This is an old request.
return;
}
// Save the value of the last response displayed.
inputData.requestVisible = requestStart;
// Prevent an old error message from showing.
if (inputData.errorWaitTimeout != null) {
window.clearTimeout(inputData.errorWaitTimeout);
}
function display(latex) {
// Load jax if it failed before.
if (!inputData.jax) {
results = MathJax.Hub.getAllJax(inputData.$preview[0]);
if (!results.length) {
console.log("Unable to find MathJax to display");
return;
}
inputData.jax = results[0];
}
// Set the text as the latex code, and then update the MathJax.
MathJax.Hub.Queue(
['Text', inputData.jax, latex],
['Reprocess', inputData.jax]
);
}
if (response.error) {
inputData.$img.css('visibility', 'visible');
inputData.errorWaitTimeout = window.setTimeout(function () {
display("\\text{" + response.error + "}");
inputData.$img.css('visibility', 'hidden');
}, formulaEquationPreview.errorDelay);
} else {
display(response.preview);
}
}
// Invoke the setup method.
$('.formulaequationinput input').each(setupInput);
};
formulaEquationPreview.enable();

View File

@@ -1,3 +1,3 @@
<chapter>
<video url_name="toyvideo" youtube_id_1_0="OEoXaMPEzfM" display_name="toyvideo"/>
<video url_name="toyvideo" youtube_id_1_0="OEoXaMPEzfMA" display_name="toyvideo"/>
</chapter>

View File

@@ -1 +1 @@
<video display_name="default" youtube_id_0_75="JMD_ifUUfsU" youtube_id_1_0="OEoXaMPEzfM" youtube_id_1_25="AKqURZnYqpk" youtube_id_1_5="DYpADpL7jAY" name="sample_video"/>
<video display_name="default" youtube_id_0_75="JMD_ifUUfsU" youtube_id_1_0="OEoXaMPEzfM" youtube_id_1_25="AKqURZnYqpk" youtube_id_1_5="DYpADpL7jAY" name="sample_video"/>

View File

@@ -328,6 +328,12 @@ expected answer.
The expected answer can be specified explicitly or precomputed by a Python
script.
Accepted input types include ``<formulaequationinput />`` and ``<textline />``.
However, the math display on ``<textline math="1" />`` uses a different parser
and has different capabilities than the response type--this may lead to student
confusion. For this reason, we strongly urge using ``<formulaequationinput />``
only, and the examples below show its use.
Sample Problem:
.. image:: ../Images/image292.png
@@ -343,14 +349,14 @@ Sample Problem:
<p>What base is the decimal numeral system in?
<numericalresponse answer="10">
<textline />
<formulaequationinput />
</numericalresponse>
</p>
<p>What is the value of the standard gravity constant <i>g</i>, measured in m/s<sup>2</sup>? Give your answer to at least two decimal places.
<numericalresponse answer="9.80665">
<responseparam type="tolerance" default="0.01" />
<textline />
<formulaequationinput />
</numericalresponse>
</p>
@@ -362,7 +368,7 @@ Sample Problem:
<p>What is the distance in the plane between the points (pi, 0) and (0, e)? You can type math.
<numericalresponse answer="$computed_response">
<responseparam type="tolerance" default="0.0001" />
<textline math="1" />
<formulaequationinput />
</numericalresponse>
</p>
<solution>
@@ -391,7 +397,7 @@ Exact values
<problem>
<numericalresponse answer="10">
<textline />
<formulaequationinput />
</numericalresponse>
<solution>
@@ -409,7 +415,7 @@ Answers with decimal precision
<numericalresponse answer="9.80665">
<responseparam type="tolerance" default="0.01" />
<textline />
<formulaequationinput />
</numericalresponse>
<solution>
@@ -427,25 +433,7 @@ Answers with percentage precision
<numericalresponse answer="100">
<responseparam type="tolerance" default="10%" />
<textline />
</numericalresponse>
<solution>
<div class="detailed-solution">
</div>
</solution>
</problem>
Answers with a live math interpretation popup display
.. code-block:: xml
<problem>
<numericalresponse answer="3.14159">
<responseparam type="tolerance" default="0.00001" />
<textline math="1" />
<formulaequationinput />
</numericalresponse>
<solution>
@@ -468,7 +456,7 @@ Answers with scripts
<numericalresponse answer="$computed_response">
<responseparam type="tolerance" default="0.0001" />
<textline math="1" />
<formulaequationinput />
</numericalresponse>
<solution>
@@ -479,7 +467,7 @@ Answers with scripts
</problem>
XML Attribute Information
**XML Attribute Information**
<script>
@@ -490,15 +478,103 @@ XML Attribute Information
.. image:: ../Images/numericalresponse2.png
Children may include ``<formulaequationinput/>``.
<responseparam>
.. image:: ../Images/numericalresponse4.png
<textline>
<formulaequationinput/>
========= ============================================= =====
Attribute Description Notes
========= ============================================= =====
size (optional) defines the size (i.e. the width)
of the input box displayed to students for
typing their math expression.
========= ============================================= =====
<textline> (While <textline /> is supported, its use is extremely discouraged. We urge usage of <formulaequationinput />. See the opening paragraphs of the Numerical Response section for more information.)
.. image:: ../Images/numericalresponse5.png
Math Expression Syntax
----------------------
In NumericalResponses, the student's input may be more complicated than a
simple number. Expressions like ``sqrt(3)`` and even ``1+e^(sin(pi/2)+2*i)``
are valid, and evaluate to 1.73 and -0.13 + 2.47i, respectively.
A summary of the syntax follows:
Numbers
~~~~~~~
Accepted number types:
- Integers: '2520'
- Normal floats: '3.14'
- With no integer part: '.98'
- Scientific notation: '1.2e-2' (=0.012)
- More s.n.: '-4.4e+5' = '-4.4e5' (=-440,000)
- Appending SI suffixes: '2.25k' (=2,250). The full list:
====== ========== ===============
Suffix Stands for One of these is
====== ========== ===============
% percent 0.01 = 1e-2
k kilo 1000 = 1e3
M mega 1e6
G giga 1e9
T tera 1e12
c centi 0.01 = 1e-2
m milli 0.001 = 1e-3
u micro 1e-6
n nano 1e-9
p pico 1e-12
====== ========== ===============
The largest possible number handled currently is exactly the largest float
possible (in the Python language). This number is 1.7977e+308. Any expression
containing larger values will not evaluate correctly, so it's best to avoid
this situation.
Default Constants
~~~~~~~~~~~~~~~~~
Simple and commonly used mathematical/scientific constants are included by
default. These include:
- ``i`` and ``j`` as ``sqrt(-1)``
- ``e`` as Euler's number (2.718...)
- ``pi``
- ``k``: the Boltzmann constant (~1.38e-23 in Joules/Kelvin)
- ``c``: the speed of light in m/s (2.998e8)
- ``T``: the positive difference between 0K and 0°C (285.15)
- ``q``: the fundamental charge (~1.602e-19 Coloumbs)
Operators and Functions
~~~~~~~~~~~~~~~~~~~~~~~
As expected, the normal operators apply (with normal order of operations):
``+ - * / ^``. Also provided is a special "parallel resistors" operator given
by ``||``. For example, an input of ``1 || 2`` would represent the resistance
of a pair of parallel resistors (of resistance 1 and 2 ohms), evaluating to 2/3
(ohms).
At the time of writing, factorials written in the form '3!' are invalid, but there is a workaround. Students can specify ``fact(3)`` or ``factorial(3)`` to
access the factorial function.
The default included functions are the following:
- Trig functions: sin, cos, tan, sec, csc, cot
- Their inverses: arcsin, arccos, arctan, arcsec, arccsc, arccot
- Other common functions: sqrt, log10, log2, ln, exp, abs
- Factorial: ``fact(3)`` or ``factorial(3)`` are valid. However, you must take
care to only input integers. For example, ``fact(1.5)`` would fail.
- Hyperbolic trig functions and their inverses: sinh, cosh, tanh, sech, csch, coth, arcsinh, arccosh, arctanh, arcsech, arccsch, arccoth
.. raw:: latex
\newpage %
@@ -513,6 +589,13 @@ mathematical expression from the student and evaluates the input for equivalence
to a mathematical expression provided by the grader. Correctness is based on
numerical sampling of the symbolic expressions.
The syntax of the answers is shared with that of the Numerical Response,
including default variables and functions. The difference between the two
response types is that the Formula Response grader may specify unknown
variables. The student's response is compared against the instructor's
response, with the unknown variable(s) sampled at random values, as specified
by the problem author.
The answer is correct if both the student-provided response and the grader's
mathematical expression are equivalent to specified numerical tolerance, over a
specified range of values for each variable.
@@ -522,6 +605,15 @@ an extra burden on the problem author to specify the allowed variables in the
expression and the numerical ranges over which the variables must be sampled in
order to test for correctness.
A further note about the variables: when the following Greek letters are typed
out, an appropriate character is substituted:
``alpha beta gamma delta epsilon varepsilon zeta eta theta vartheta iota
kappa lambda mu nu xi pi rho sigma tau upsilon phi varphi chi psi omega``
Note: ``epsilon`` is the lunate version, whereas ``varepsilon`` looks like a
backward 3.
Sample Problem:
.. image:: ../Images/image293.png
@@ -538,26 +630,32 @@ Sample Problem:
<p>Write an expression for the product of R_1, R_2, and the inverse of R_3.</p>
<formularesponse type="ci" samples="R_1,R_2,R_3@1,2,3:3,4,5#10" answer="$VoVi">
<responseparam type="tolerance" default="0.00001"/>
<textline size="40" math="1" />
<formulaequationinput size="40" />
</formularesponse>
<p>Let <i>c</i> denote the speed of light. What is the relativistic energy <i>E</i> of an object of mass <i>m</i>?</p>
<script type="loncapa/python">
VoVi = "(R_1*R_2)/R_3"
</script>
<formularesponse type="cs" samples="m,c@1,2:3,4#10" answer="m*c^2">
<formularesponse type="cs" samples="m,c@1,2:3,4#10" answer="m*c^2">
<responseparam type="tolerance" default="0.00001"/>
<text><i>E</i> =</text> <textline size="40" math="1" />
</formularesponse>
<text><i>E</i> =</text> <formulaequationinput size="40"/>
</formularesponse>
<p>Let <i>x</i> be a variable, and let <i>n</i> be an arbitrary constant. What is the derivative of <i>x<sup>n</sup></i>?</p>
<script type="loncapa/python">
derivative = "n*x^(n-1)"
</script>
<formularesponse type="ci" samples="x,n@1,2:3,4#10" answer="$derivative">
<responseparam type="tolerance" default="0.00001"/>
<textline size="40" math="1" />
</formularesponse>
<formularesponse type="ci" samples="x,n@1,2:3,4#10" answer="$derivative">
<responseparam type="tolerance" default="0.00001"/>
<formulaequationinput size="40" />
</formularesponse>
<!-- Example problem specifying only one variable -->
<formularesponse type="ci" samples="x@1,9#10" answer="x**2 - x + 4">
<responseparam type="tolerance" default="0.00001"/>
<formulaequationinput size="40" />
</formularesponse>
<solution>
<div class="detailed-solution">
@@ -568,24 +666,6 @@ Sample Problem:
</div>
</solution>
</problem>
Template
<problem>
<script type="loncapa/python">
answer_value = "n*x^(n-1)"
</script>
<formularesponse type="ci" samples="x,n@1,2:3,4#10" answer="$answer_value">
<responseparam type="tolerance" default="0.00001"/>
<textline size="40" math="1" />
</formularesponse>
<solution>
<div class="detailed-solution">
</div>
</solution>
</problem>
XML Attribute Information
@@ -600,11 +680,26 @@ XML Attribute Information
.. image:: ../Images/formularesponse3.png
Children may include ``<formulaequationinput/>``.
If you do not need to specify any samples, you should look into the use of the
Numerical Response input type, as it provides all the capabilities of Formula
Response without the need to specify any unknown variables.
<responseparam>
.. image:: ../Images/formularesponse6.png
<formulaequationinput/>
========= ============================================= =====
Attribute Description Notes
========= ============================================= =====
size (optional) defines the size (i.e. the width)
of the input box displayed to students for
typing their math expression.
========= ============================================= =====
.. raw:: latex
@@ -825,7 +920,6 @@ Sample Problem:
<endouttext/>
</problem>
h
.. raw:: latex
\newpage %

View File

@@ -0,0 +1,47 @@
Formula Equation Input
######################
Tag: ``<formulaequationinput />``
The formula equation input is a math input type used with Numerical and Formula
responses only. It is not to be used with Symoblic Response. It is comparable
to a ``<textline math="1"/>`` but with a different means to display the math.
It lets the platform validate the student's input as she types.
This is achieved by periodically sending the typed expression and requesting
its preview from the LMS. It parses the expression (using the same parser as
the parser it uses to eventually evaluate the response for grading), and sends
back an OK'd copy.
The basic appearance is that of a textbox with a preview box below it. The
student types in math (see note below for syntax), and a typeset preview
appears below it. Even complicated math expressions may be entered in.
For more information about the syntax, look in the course author's
documentation, under Appendix E, the section about Numerical Responses.
Format
******
The XML is rather simple, it is a ``<formulaequationinput />`` tag with an
optional ``size`` attribute, which defines the size (i.e. the width) of the
input box displayed to students for typing their math expression. Unlike
``<textline />``, there is no ``math`` attribute and adding such will have no
effect.
To see an example of the input type in context:
.. code-block:: xml
<problem>
<p>What base is the decimal numeral system in?</p>
<numericalresponse answer="10">
<formulaequationinput />
</numericalresponse>
<p>Write an expression for the product of R_1, R_2, and the inverse of R_3.</p>
<formularesponse type="ci" samples="R_1,R_2,R_3@1,2,3:3,4,5#10" answer="R_1*R_2/R_3">
<responseparam type="tolerance" default="0.00001"/>
<formulaequationinput size="40" />
</formularesponse>
</problem>

View File

@@ -30,6 +30,7 @@ Specific Problem Types
course_data_formats/custom_response.rst
course_data_formats/symbolic_response.rst
course_data_formats/jsinput.rst
course_data_formats/formula_equation_input.rst
Internal Data Formats

Some files were not shown because too many files have changed in this diff Show More