diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 8d13a39bb3..516659fadb 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -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' diff --git a/cms/djangoapps/contentstore/features/video-editor.feature b/cms/djangoapps/contentstore/features/video-editor.feature index f28ee568dc..a53183e37c 100644 --- a/cms/djangoapps/contentstore/features/video-editor.feature +++ b/cms/djangoapps/contentstore/features/video-editor.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py index ad3229ab53..f9d433fc02 100644 --- a/cms/djangoapps/contentstore/features/video-editor.py +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -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) + diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature index 634bb8a17f..50c06fde63 100644 --- a/cms/djangoapps/contentstore/features/video.feature +++ b/cms/djangoapps/contentstore/features/video.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index e27ca28eb7..2c3d2cdfa9 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -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'] + diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 09413be7b7..86369f73d9 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -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 = ''' ''' - 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 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 diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py index 827dd1b054..260444a8f7 100644 --- a/cms/djangoapps/contentstore/tests/test_item.py +++ b/cms/djangoapps/contentstore/tests/test_item.py @@ -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): diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 7cb503db1e..d7b41acb24 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -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' diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index efebded9b9..ff347a2878 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -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() diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index e801faa4f1..0591ff0dc4 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -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 diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 339425fee5..c2ba51a5f8 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -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", []) diff --git a/cms/envs/common.py b/cms/envs/common.py index 40084c20ae..030609e26f 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -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': '', diff --git a/cms/static/coffee/spec/views/metadata_edit_spec.coffee b/cms/static/coffee/spec/views/metadata_edit_spec.coffee index 926e5be315..b3e4567d82 100644 --- a/cms/static/coffee/spec/views/metadata_edit_spec.coffee +++ b/cms/static/coffee/spec/views/metadata_edit_spec.coffee @@ -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) diff --git a/cms/static/sass/elements/_xmodules.scss b/cms/static/sass/elements/_xmodules.scss index a8ec208b02..db1a50b40c 100644 --- a/cms/static/sass/elements/_xmodules.scss +++ b/cms/static/sass/elements/_xmodules.scss @@ -2,7 +2,7 @@ // ==================== // Video Alpha -.xmodule_VideoAlphaModule { +.xmodule_VideoModule { // display mode &.xmodule_display { diff --git a/cms/templates/widgets/videoalpha/codemirror-edit.html b/cms/templates/widgets/video/codemirror-edit.html similarity index 100% rename from cms/templates/widgets/videoalpha/codemirror-edit.html rename to cms/templates/widgets/video/codemirror-edit.html diff --git a/cms/templates/widgets/videoalpha/subtitles.html b/cms/templates/widgets/video/subtitles.html similarity index 100% rename from cms/templates/widgets/videoalpha/subtitles.html rename to cms/templates/widgets/video/subtitles.html diff --git a/common/lib/calc/calc.py b/common/lib/calc/calc.py index bbfd9545f6..f2a68988ae 100644 --- a/common/lib/calc/calc.py +++ b/common/lib/calc/calc.py @@ -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))) diff --git a/common/lib/calc/preview.py b/common/lib/calc/preview.py new file mode 100644 index 0000000000..b800b7604b --- /dev/null +++ b/common/lib/calc/preview.py @@ -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 diff --git a/common/lib/calc/tests/test_calc.py b/common/lib/calc/tests/test_calc.py index 13cd9e9471..48ac7b88c1 100644 --- a/common/lib/calc/tests/test_calc.py +++ b/common/lib/calc/tests/test_calc.py @@ -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) diff --git a/common/lib/calc/tests/test_preview.py b/common/lib/calc/tests/test_preview.py new file mode 100644 index 0000000000..0008cdda47 --- /dev/null +++ b/common/lib/calc/tests/test_preview.py @@ -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) diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 29800a211b..9defd2c5e6 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -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: - + 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: + + + + 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' : '' or '' + 'error' : 'the-error' or '' + 'request_start' :