Merge branch 'master' into jkarni/fix/descriptorsystemruntime
Conflicts: cms/djangoapps/contentstore/views/preview.py
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", [])
|
||||
|
||||
|
||||
@@ -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': '',
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// ====================
|
||||
|
||||
// Video Alpha
|
||||
.xmodule_VideoAlphaModule {
|
||||
.xmodule_VideoModule {
|
||||
|
||||
// display mode
|
||||
&.xmodule_display {
|
||||
|
||||
@@ -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
390
common/lib/calc/preview.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
251
common/lib/calc/tests/test_preview.py
Normal file
251
common/lib/calc/tests/test_preview.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
20
common/lib/capa/capa/templates/formulaequationinput.html
Normal file
20
common/lib/capa/capa/templates/formulaequationinput.html
Normal 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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div id="example">
|
||||
<div
|
||||
id="video_id"
|
||||
class="videoalpha"
|
||||
class="video"
|
||||
data-show-captions="true"
|
||||
data-start=""
|
||||
data-end=""
|
||||
@@ -3,7 +3,7 @@
|
||||
<div id="example">
|
||||
<div
|
||||
id="video_id"
|
||||
class="videoalpha"
|
||||
class="video"
|
||||
data-show-captions="true"
|
||||
data-start=""
|
||||
data-end=""
|
||||
@@ -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=""
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
@@ -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.
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
4
common/lib/xmodule/xmodule/js/src/.gitignore
vendored
4
common/lib/xmodule/xmodule/js/src/.gitignore
vendored
@@ -4,5 +4,5 @@
|
||||
*.js
|
||||
|
||||
|
||||
# Videoalpha are written in pure JavaScript.
|
||||
!videoalpha/*.js
|
||||
# Video are written in pure JavaScript.
|
||||
!video/*.js
|
||||
@@ -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';
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -14,7 +14,7 @@
|
||||
(function (requirejs, require, define) {
|
||||
|
||||
define(
|
||||
'videoalpha/02_html5_video.js',
|
||||
'video/02_html5_video.js',
|
||||
[],
|
||||
function () {
|
||||
var HTML5Video = {};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// VideoControl module.
|
||||
define(
|
||||
'videoalpha/04_video_control.js',
|
||||
'video/04_video_control.js',
|
||||
[],
|
||||
function () {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// VideoQualityControl module.
|
||||
define(
|
||||
'videoalpha/05_video_quality_control.js',
|
||||
'video/05_video_quality_control.js',
|
||||
[],
|
||||
function () {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// VideoVolumeControl module.
|
||||
define(
|
||||
'videoalpha/07_video_volume_control.js',
|
||||
'video/07_video_volume_control.js',
|
||||
[],
|
||||
function () {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// VideoSpeedControl module.
|
||||
define(
|
||||
'videoalpha/08_video_speed_control.js',
|
||||
'video/08_video_speed_control.js',
|
||||
[],
|
||||
function () {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// VideoCaption module.
|
||||
define(
|
||||
'videoalpha/09_video_caption.js',
|
||||
'video/09_video_caption.js',
|
||||
[],
|
||||
function () {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
});
|
||||
@@ -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
|
||||
@@ -1,14 +0,0 @@
|
||||
class @Subview
|
||||
constructor: (options) ->
|
||||
$.each options, (key, value) =>
|
||||
@[key] = value
|
||||
@initialize()
|
||||
@render()
|
||||
@bind()
|
||||
|
||||
$: (selector) ->
|
||||
$(selector, @el)
|
||||
|
||||
initialize: ->
|
||||
render: ->
|
||||
bind: ->
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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)}")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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">
|
||||
|
||||
@@ -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"> EJ/year</p>
|
||||
</p>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -33,7 +33,7 @@ class TabsEditingDescriptorTestCase(unittest.TestCase):
|
||||
},
|
||||
{
|
||||
'name': "Subtitles",
|
||||
'template': "videoalpha/subtitles.html",
|
||||
'template': "video/subtitles.html",
|
||||
},
|
||||
{
|
||||
'name': "Settings",
|
||||
|
||||
@@ -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)
|
||||
@@ -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, '')
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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]])
|
||||
|
||||
@@ -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]])
|
||||
@@ -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.
|
||||
|
||||
|
||||
379
common/static/js/capa/spec/formula_equation_preview_spec.js
Normal file
379
common/static/js/capa/spec/formula_equation_preview_spec.js
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
describe("A jsinput has:", function () {
|
||||
xdescribe("A jsinput has:", function () {
|
||||
|
||||
beforeEach(function () {
|
||||
$('#fixture').remove();
|
||||
161
common/static/js/capa/src/formula_equation_preview.js
Normal file
161
common/static/js/capa/src/formula_equation_preview.js
Normal 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();
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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 %
|
||||
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user