From 668bc81d7f2a1e22ac1bf644245f4d8e92557a5f Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Tue, 27 Nov 2012 15:08:41 -0500 Subject: [PATCH 01/59] New branch for clean lms acceptance tests --- .../features/course-section-content.feature | 10 + .../features/course-section-content.py | 197 ++++++++++++++++++ .../courseware/features/course_info.feature | 21 ++ .../courseware/features/course_info.py | 15 ++ lms/djangoapps/courseware/features/courses.py | 129 ++++++++++++ .../courseware/features/courseware.feature | 18 ++ .../courseware/features/courseware_common.py | 36 ++++ .../features/high-level-tabs.feature | 23 ++ .../features/smart-accordion.feature | 19 ++ .../courseware/features/smart-accordion.py | 149 +++++++++++++ lms/djangoapps/portal/README.md | 37 ++++ lms/djangoapps/portal/__init__.py | 0 .../portal/features/homepage.feature | 45 ++++ lms/djangoapps/portal/features/homepage.py | 5 + lms/djangoapps/portal/features/login.feature | 27 +++ lms/djangoapps/portal/features/login.py | 46 ++++ .../portal/features/registration.feature | 18 ++ .../portal/features/registration.py | 24 +++ lms/djangoapps/portal/features/signup.feature | 16 ++ lms/djangoapps/portal/features/signup.py | 22 ++ lms/djangoapps/terrain/__init__.py | 6 + lms/djangoapps/terrain/browser.py | 78 +++++++ lms/djangoapps/terrain/common.py | 105 ++++++++++ lms/djangoapps/terrain/factories.py | 34 +++ lms/envs/acceptance.py | 20 ++ test-requirements.txt | 4 + 26 files changed, 1104 insertions(+) create mode 100644 lms/djangoapps/courseware/features/course-section-content.feature create mode 100644 lms/djangoapps/courseware/features/course-section-content.py create mode 100644 lms/djangoapps/courseware/features/course_info.feature create mode 100644 lms/djangoapps/courseware/features/course_info.py create mode 100644 lms/djangoapps/courseware/features/courses.py create mode 100644 lms/djangoapps/courseware/features/courseware.feature create mode 100644 lms/djangoapps/courseware/features/courseware_common.py create mode 100644 lms/djangoapps/courseware/features/high-level-tabs.feature create mode 100644 lms/djangoapps/courseware/features/smart-accordion.feature create mode 100644 lms/djangoapps/courseware/features/smart-accordion.py create mode 100644 lms/djangoapps/portal/README.md create mode 100644 lms/djangoapps/portal/__init__.py create mode 100644 lms/djangoapps/portal/features/homepage.feature create mode 100644 lms/djangoapps/portal/features/homepage.py create mode 100644 lms/djangoapps/portal/features/login.feature create mode 100644 lms/djangoapps/portal/features/login.py create mode 100644 lms/djangoapps/portal/features/registration.feature create mode 100644 lms/djangoapps/portal/features/registration.py create mode 100644 lms/djangoapps/portal/features/signup.feature create mode 100644 lms/djangoapps/portal/features/signup.py create mode 100644 lms/djangoapps/terrain/__init__.py create mode 100644 lms/djangoapps/terrain/browser.py create mode 100644 lms/djangoapps/terrain/common.py create mode 100644 lms/djangoapps/terrain/factories.py create mode 100644 lms/envs/acceptance.py diff --git a/lms/djangoapps/courseware/features/course-section-content.feature b/lms/djangoapps/courseware/features/course-section-content.feature new file mode 100644 index 0000000000..bf6a9d8929 --- /dev/null +++ b/lms/djangoapps/courseware/features/course-section-content.feature @@ -0,0 +1,10 @@ +Feature: There are many different types of tabs + In order to validate tab types + As a staff member + I want to try out all the videos, buttons, and content + + Scenario: I visit a tabbed quiz + Given I am registered for course "MITx/6.002x-EE98/2012_Fall_SJSU" + And I log in + Given I visit and check 502 for "http://www.edx.org/courses/MITx/6.002x-EE98/2012_Fall_SJSU/courseware/Week_0/Administrivia_and_Circuit_Elements/" + I process diff --git a/lms/djangoapps/courseware/features/course-section-content.py b/lms/djangoapps/courseware/features/course-section-content.py new file mode 100644 index 0000000000..5513ab9d0c --- /dev/null +++ b/lms/djangoapps/courseware/features/course-section-content.py @@ -0,0 +1,197 @@ +from lettuce import * #before, world +from selenium import * +#import lettuce_webdriver.webdriver +import logging +import nose.tools +from selenium.webdriver import ActionChains +from selenium.webdriver.support.ui import WebDriverWait +import re + +## imported from lms/djangoapps/courseware/courses.py +from collections import defaultdict +from fs.errors import ResourceNotFoundError +from functools import wraps +import logging + +from path import path +from django.conf import settings +from django.http import Http404 + +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore import Location +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError +from static_replace import replace_urls, try_staticfiles_lookup +from courseware.access import has_access +## end import + +from django.core.urlresolvers import reverse +from courseware.courses import course_image_url, get_course_about_section, get_course_by_id +from courses import * +import os.path +import sys +path = os.path.abspath(os.path.join(os.path.dirname(__file__), 'static')) +if not path in sys.path: + sys.path.insert(1, path) +del path +#from helpers import * + + +@step(u'I visit and check 502 for "(.*)"') +def i_visit_and_check_502_for_url(step, url): + world.browser.get(url) + check_for_502(url) + +@step(u'I process') +def i_make_sure_everything_is_there(step): + e = world.browser.find_element_by_css_selector('section.course-content section') + process_section(e) + + +def process_section(element, num_tabs=0): + ''' + Process section reads through whatever is in 'course-content' and classifies it according to sequence module type. + + This function is recursive + + There are 5 types, with 5 actions. + + Sequence Module + -contains one child module + -to prevent from over-processing all its children (no easy way to specify only one level of depth), we only grab the first child + + Vertical Module + -contains other modules + -process it and get its children, then process them + + Capa Module + -problem type, contains only one problem + -for this, the most complex type, we created a separate method, process_problem + + Video Module + -video type, contains only one video + -we only check to ensure that a section with class of video exists + + Custom Tag Module + -a custom 'hack' module type + -there is a large variety of content that could go in a custom tag module, so we just pass if it is of this unusual type + ''' + tab_type = element.get_attribute('class') + print 'processing a %s' % (tab_type) + if tab_type == "xmodule_display xmodule_SequenceModule": + child_modules = element.find_elements_by_css_selector("section[class^='xmodule']") + + ## ugly bit of code to get around not being able to specify only the first level of descendants + if child_modules[0].get_attribute('class') == "xmodule_display xmodule_VerticalModule": + process_section(child_modules[0]) + else: + for mod in child_modules: + process_section(mod) + + elif tab_type == "xmodule_display xmodule_VerticalModule": + vert_list = element.find_elements_by_css_selector("li section[class^='xmodule']") + print "I found %s items" % (str(len(vert_list))) + for item in vert_list: + print 'processing a child %s' % (item.get_attribute('class')) + process_section(item) + + elif tab_type == "xmodule_display xmodule_CapaModule": + assert element.find_element_by_css_selector("section[id^='problem']") , "No problems found in %s" % (tab_type) + p = element.find_element_by_css_selector("section[id^='problem']") + p_id = p.get_attribute('id') + process_problem(p, p_id) + + elif tab_type == "xmodule_display xmodule_VideoModule": + assert element.find_element_by_css_selector("section[class^='video']") , 'No video found in %s' % (tab_type) + + elif tab_type == "xmodule_display xmodule_CustomTagModule": + pass + + else: + assert False, "%s not recognized!!" % (tab_type) + + + +def process_problem(element, problem_id): + ''' + Process problem attempts to + 1) scan all the input fields and reset them + 2) click the 'check' button and look for an incorrect response (p.status text should be 'incorrect') + 3) click the 'show answer' button IF it exists and IF the answer is not already displayed + 4) enter the correct answer in each input box + 5) click the 'check' button and verify that answers are correct + + Because of all the ajax calls happening, sometimes the test fails because objects disconnect from the DOM. + The basic functionality does exist, though, and I'm hoping that someone can take it over and make it super effective. + ''' + + prob_xmod = element.find_element_by_css_selector("section.problem") + input_fields = prob_xmod.find_elements_by_css_selector("section[id^='textinput']") + + ## clear out all input to ensure an incorrect result + for field in input_fields: + box = field.find_element_by_css_selector("input") + box.clear() + print "\n I cleared out the box %s \n" % (box.get_attribute('id')) + + ## because of cookies or the application, only click the 'check' button if the status is not already 'incorrect' + if prob_xmod.find_element_by_css_selector("p.status").text.lower() != 'incorrect': + prob_xmod.find_element_by_css_selector("section.action input.check").click() + world.browser.implicitly_wait(4) + + ## all elements become disconnected after the click + element = world.browser.find_element_by_css_selector("section[id='"+problem_id+"']") + prob_xmod = element.find_element_by_css_selector("section.problem") + input_fields = prob_xmod.find_elements_by_css_selector("section[id^='textinput']") + for field in input_fields: + assert field.find_element_by_css_selector("div.incorrect") , "The 'check' button did not work for %s" % (problem_id) + print "\n So far so good! \n" + + + ## wait for the ajax changes to render + world.browser.implicitly_wait(4) + + ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) + element = world.browser.find_element_by_css_selector("section[id='"+problem_id+"']") + prob_xmod = element.find_element_by_css_selector("section.problem") + + + show_button = element.find_element_by_css_selector("section.action input.show") + ## this logic is to ensure we do not accidentally hide the answers + if show_button.get_attribute('value').lower() == 'show answer': + show_button.click() + print "\n I clicked show for %s \n" % (problem_id) + else: + pass + + + ## wait for the ajax changes to render + world.browser.implicitly_wait(4) + + ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) + element = world.browser.find_element_by_css_selector("section[id='"+problem_id+"']") + prob_xmod = element.find_element_by_css_selector("section.problem") + + ## find all the input fields + input_fields = prob_xmod.find_elements_by_css_selector("section[id^='textinput']") + + ## in each field, find the answer, and send it to the field. + ## Note that this does not work if the answer type is a strange format, e.g. "either a or b" + for field in input_fields: + field.find_element_by_css_selector("input").send_keys(field.find_element_by_css_selector("p[id^='answer']").text) + print "\n \n Entered %s into %s \n \n" % (field.find_element_by_css_selector("p[id^='answer']").text,field.find_element_by_css_selector("input").get_attribute('id') ) + prob_xmod = element.find_element_by_css_selector("section.problem") + prob_xmod.find_element_by_css_selector("section.action input.check").click() + world.browser.implicitly_wait(4) + + ## assert that we entered the correct answers + ## we have to redefine input-fields because apparently they become detached from the dom after clicking 'check' + + input_fields = world.browser.find_elements_by_css_selector("section[id='"+problem_id+"'] section[id^='textinput']") + for field in input_fields: + ## if you don't use 'starts with ^=' the test will fail because the actual class is 'correct ' (with a space) + assert world.browser.find_element_by_css_selector("div[class^='correct']"), "The check answer values were not correct for %s" % (problem_id) + inputs = world.browser.find_elements_by_css_selector("section[id^='textinput'] input") + for el in inputs: + el.clear() + print "\n checked answers for %s \n" % (problem_id) diff --git a/lms/djangoapps/courseware/features/course_info.feature b/lms/djangoapps/courseware/features/course_info.feature new file mode 100644 index 0000000000..4a6a2daff6 --- /dev/null +++ b/lms/djangoapps/courseware/features/course_info.feature @@ -0,0 +1,21 @@ +Feature: View the Course Info tab + As a student in an edX course + In order to get background on the course + I want to view the info on the course info tab + + Scenario: I can get to the course info tab when logged in + Given I am logged in + And I am registered for a course + And I visit the dashboard + When I click on View Courseware + Then I am on an info page + And the Course Info tab is active + And I do not see "! Info section missing !" anywhere on the page + + # This test is currently failing + # see: https://www.pivotaltracker.com/projects/614553?classic=true#!/stories/38801223 + Scenario: I cannot get to the course info tab when not logged in + Given I am not logged in + And I visit the homepage + When I visit the course info URL + Then the login dialog is visible \ No newline at end of file diff --git a/lms/djangoapps/courseware/features/course_info.py b/lms/djangoapps/courseware/features/course_info.py new file mode 100644 index 0000000000..964a7af35f --- /dev/null +++ b/lms/djangoapps/courseware/features/course_info.py @@ -0,0 +1,15 @@ +from lettuce import world, step +from lettuce.django import django_url +#from portal.common import * + +@step('I am on an info page') +def i_am_on_an_info_page(step): + title = world.browser.title + url = world.browser.url + assert ('Course Info' in title) + assert (r'/info' in url) + +@step('I visit the course info URL$') +def i_visit_the_course_info_url(step): + url = django_url('/courses/MITx/6.002x/2012_Fall/info') + world.browser.visit(url) diff --git a/lms/djangoapps/courseware/features/courses.py b/lms/djangoapps/courseware/features/courses.py new file mode 100644 index 0000000000..7b22c54231 --- /dev/null +++ b/lms/djangoapps/courseware/features/courses.py @@ -0,0 +1,129 @@ +from lettuce import * #before, world +from selenium import * +#import lettuce_webdriver.webdriver +import logging +import nose.tools +from selenium.webdriver import ActionChains +from selenium.webdriver.support.ui import WebDriverWait + +## imported from lms/djangoapps/courseware/courses.py +from collections import defaultdict +from fs.errors import ResourceNotFoundError +from functools import wraps +import logging + +from path import path +from django.conf import settings +from django.http import Http404 + +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore import Location +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError +from static_replace import replace_urls, try_staticfiles_lookup +from courseware.access import has_access +## end import + +from django.core.urlresolvers import reverse +from courseware.courses import course_image_url, get_course_about_section, get_course_by_id +import xmodule + +## support functions +def get_courses(): + ''' + Returns dict of lists of courses available, keyed by course.org (ie university). + Courses are sorted by course.number. + ''' + courses = [c for c in modulestore().get_courses() + if isinstance(c, CourseDescriptor)] + courses = sorted(courses, key=lambda course: course.number) + return courses + +def get_courseware(course_id): + """ + Given a course_id (string), return a courseware array of dictionaries for the + top two levels of navigation. Example: + + [ + {'chapter_name': 'Overview', + 'sections': ['Welcome', 'System Usage Sequence', 'Lab0: Using the tools', 'Circuit Sandbox'] + }, + {'chapter_name': 'Week 1', + 'sections': ['Administrivia and Circuit Elements', 'Basic Circuit Analysis', 'Resistor Divider', 'Week 1 Tutorials'] + }, + {'chapter_name': 'Midterm Exam', + 'sections': ['Midterm Exam'] + } + ] + """ + + course = get_course_by_id(course_id) + chapters = course.get_children() + courseware = [ {'chapter_name':c.display_name, 'sections':[s.display_name for s in c.get_children()]} for c in chapters] + return courseware + +def get_courseware_with_tabs(course_id): + """ + Given a course_id (string), return a courseware array of dictionaries for the + top three levels of navigation. Same as get_courseware() except include + the tabs on the right hand main navigation page. + + This hides the appropriate courseware as defined by the XML flag test: + chapter.metadata.get('hide_from_toc','false').lower() == 'true' + + Example: + + [{ + 'chapter_name': 'Overview', + 'sections': [{ + 'clickable_tab_count': 0, + 'section_name': 'Welcome', + 'tab_classes': [] + }, { + 'clickable_tab_count': 1, + 'section_name': 'System Usage Sequence', + 'tab_classes': ['VerticalDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Lab0: Using the tools', + 'tab_classes': ['HtmlDescriptor', 'HtmlDescriptor', 'CapaDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Circuit Sandbox', + 'tab_classes': [] + }] + }, { + 'chapter_name': 'Week 1', + 'sections': [{ + 'clickable_tab_count': 4, + 'section_name': 'Administrivia and Circuit Elements', + 'tab_classes': ['VerticalDescriptor', 'VerticalDescriptor', 'VerticalDescriptor', 'VerticalDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Basic Circuit Analysis', + 'tab_classes': ['CapaDescriptor', 'CapaDescriptor', 'CapaDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Resistor Divider', + 'tab_classes': [] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Week 1 Tutorials', + 'tab_classes': [] + }] + }, { + 'chapter_name': 'Midterm Exam', + 'sections': [{ + 'clickable_tab_count': 2, + 'section_name': 'Midterm Exam', + 'tab_classes': ['VerticalDescriptor', 'VerticalDescriptor'] + }] + }] + """ + + course = get_course_by_id(course_id) + chapters = [ chapter for chapter in course.get_children() if chapter.metadata.get('hide_from_toc','false').lower() != 'true' ] + courseware = [{'chapter_name':c.display_name, 'sections':[{'section_name':s.display_name, 'clickable_tab_count': len(s.get_children()) if (type(s)==xmodule.seq_module.SequenceDescriptor) else 0, 'tab_classes':[t.__class__.__name__ for t in s.get_children() ]} for s in c.get_children() if s.metadata.get('hide_from_toc', 'false').lower() != 'true']} for c in chapters ] + + return courseware + diff --git a/lms/djangoapps/courseware/features/courseware.feature b/lms/djangoapps/courseware/features/courseware.feature new file mode 100644 index 0000000000..cddf7527ae --- /dev/null +++ b/lms/djangoapps/courseware/features/courseware.feature @@ -0,0 +1,18 @@ +Feature: View the Courseware Tab + As a student in an edX course + In order to work on the course + I want to view the info on the courseware tab + + Scenario: I can get to the courseware tab when logged in + Given I am registered for a course + And I log in + And I click on View Courseware + When I click on the "Courseware" tab + Then the "Courseware" tab is active + + # TODO: fix this one? Not sure whether you should get a 404. + # Scenario: I cannot get to the courseware tab when not logged in + # Given I am not logged in + # And I visit the homepage + # When I visit the courseware URL + # Then the login dialog is visible \ No newline at end of file diff --git a/lms/djangoapps/courseware/features/courseware_common.py b/lms/djangoapps/courseware/features/courseware_common.py new file mode 100644 index 0000000000..f90c7c3ba1 --- /dev/null +++ b/lms/djangoapps/courseware/features/courseware_common.py @@ -0,0 +1,36 @@ +from lettuce import world, step +from lettuce.django import django_url + +@step('I click on View Courseware') +def i_click_on_view_courseware(step): + css = 'p.enter-course' + world.browser.find_by_css(css).first.click() + +@step('I click on the "([^"]*)" tab$') +def i_click_on_the_tab(step, tab): + world.browser.find_link_by_text(tab).first.click() + +@step('I visit the courseware URL$') +def i_visit_the_course_info_url(step): + url = django_url('/courses/MITx/6.002x/2012_Fall/courseware') + world.browser.visit(url) + +@step(u'I do not see "([^"]*)" anywhere on the page') +def i_do_not_see_text_anywhere_on_the_page(step, text): + assert world.browser.is_text_not_present(text) + +@step(u'I am on the dashboard page$') +def i_am_on_the_dashboard_page(step): + assert world.browser.is_element_present_by_css('section.courses') + assert world.browser.url == django_url('/dashboard') + +@step('the "([^"]*)" tab is active$') +def the_tab_is_active(step, tab): + css = '.course-tabs a.active' + active_tab = world.browser.find_by_css(css) + assert (active_tab.text == tab) + +@step('the login dialog is visible$') +def login_dialog_visible(step): + css = 'form#login_form.login_form' + assert world.browser.find_by_css(css).visible diff --git a/lms/djangoapps/courseware/features/high-level-tabs.feature b/lms/djangoapps/courseware/features/high-level-tabs.feature new file mode 100644 index 0000000000..2e9c4f1886 --- /dev/null +++ b/lms/djangoapps/courseware/features/high-level-tabs.feature @@ -0,0 +1,23 @@ +Feature: All the high level tabs should work + In order to preview the courseware + As a student + I want to navigate through the high level tabs + +# Note this didn't work as a scenario outline because +# before each scenario was not flushing the database +# TODO: break this apart so that if one fails the others +# will still run + Scenario: A student can see all tabs of the course + Given I am registered for a course + And I log in + And I click on View Courseware + When I click on the "Courseware" tab + Then the page title should be "6.002x Courseware" + When I click on the "Course Info" tab + Then the page title should be "6.002x Course Info" + When I click on the "Textbook" tab + Then the page title should be "6.002x Textbook" + When I click on the "Wiki" tab + Then the page title should be "6.002x | edX Wiki" + When I click on the "Progress" tab + Then the page title should be "6.002x Progress" diff --git a/lms/djangoapps/courseware/features/smart-accordion.feature b/lms/djangoapps/courseware/features/smart-accordion.feature new file mode 100644 index 0000000000..98dad7131f --- /dev/null +++ b/lms/djangoapps/courseware/features/smart-accordion.feature @@ -0,0 +1,19 @@ +Feature: There are courses on the homepage + In order to compared rendered content to the database + As an acceptance test + I want to count all the chapters, sections, and tabs for each course + + Scenario: Navigate through course MITx/6.002x/2012_Fall + Given I am registered for course "MITx/6.002x/2012_Fall" + And I log in + Then I verify all the content of each course + + Scenario: Navigate through course edX/edx101/edX_Studio_Reference + Given I am registered for course "edX/edx101/edX_Studio_Reference" + And I log in + Then I verify all the content of each course + + Scenario: Navigate through course BerkeleyX/CS169.1x/2012_Fall + Given I am registered for course "BerkeleyX/CS169.1x/2012_Fall" + And I log in + Then I verify all the content of each course \ No newline at end of file diff --git a/lms/djangoapps/courseware/features/smart-accordion.py b/lms/djangoapps/courseware/features/smart-accordion.py new file mode 100644 index 0000000000..ebc5f19db6 --- /dev/null +++ b/lms/djangoapps/courseware/features/smart-accordion.py @@ -0,0 +1,149 @@ +from lettuce import world, step +import re +from nose.tools import assert_equals + +## imported from lms/djangoapps/courseware/courses.py +from collections import defaultdict +from fs.errors import ResourceNotFoundError +from functools import wraps + +from path import path +from django.conf import settings +from django.http import Http404 + +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore import Location +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError +from static_replace import replace_urls, try_staticfiles_lookup +from courseware.access import has_access +## end import + +from django.core.urlresolvers import reverse +from courseware.courses import course_image_url, get_course_about_section, get_course_by_id +from courses import * +import os.path +import sys +path = os.path.abspath(os.path.join(os.path.dirname(__file__), 'static')) +if not path in sys.path: + sys.path.insert(1, path) +del path +#from helpers import * + +from logging import getLogger +logger = getLogger(__name__) + +def check_for_errors(): + e = world.browser.find_by_css('.outside-app') + if len(e) > 0: + assert False, 'there was a server error at %s' % (world.browser.url) + else: + assert True + +@step(u'I verify all the content of each course') +def i_verify_all_the_content_of_each_course(step): + all_possible_courses = get_courses() + ids = [c.id for c in all_possible_courses] + + # Get a list of all the registered courses + registered_courses = world.browser.find_by_css('article.my-course') + if len(all_possible_courses) < len(registered_courses): + assert False, "user is registered for more courses than are uniquely posssible" + else: + pass + + for test_course in registered_courses: + test_course.find_by_css('a').click() + check_for_errors() + + # Get the course. E.g. 'MITx/6.002x/2012_Fall' + current_course = re.sub('/info','',re.sub('.*/courses/','',world.browser.url)) + validate_course(current_course,ids) + + world.browser.find_link_by_text('Courseware').click() + assert world.browser.is_element_present_by_id('accordion',wait_time=2) + check_for_errors() + browse_course(current_course) + + # clicking the user link gets you back to the user's home page + world.browser.find_by_css('.user-link').click() + check_for_errors() + +def browse_course(course_id): + + ## count chapters from xml and page and compare + chapters = get_courseware_with_tabs(course_id) + num_chapters = len(chapters) + rendered_chapters = world.browser.find_by_css('#accordion > nav > div') + num_rendered_chapters = len(rendered_chapters) + assert num_chapters == num_rendered_chapters, '%d chapters expected, %d chapters found on page for %s' % (num_chapters, num_rendered_chapters, course_id) + + chapter_it = 0 + + ## Iterate the chapters + while chapter_it < num_chapters: + + ## click into a chapter + world.browser.find_by_css('#accordion > nav > div')[chapter_it].find_by_tag('h3').click() + + ## look for the "there was a server error" div + check_for_errors() + + ## count sections from xml and page and compare + sections = chapters[chapter_it]['sections'] + num_sections = len(sections) + + rendered_sections = world.browser.find_by_css('#accordion > nav > div')[chapter_it].find_by_tag('li') + num_rendered_sections = len(rendered_sections) + assert num_sections == num_rendered_sections, '%d sections expected, %d sections found on page, iteration number %d on %s' % (num_sections, num_rendered_sections, chapter_it, course_id) + + section_it = 0 + + ## Iterate the sections + while section_it < num_sections: + + ## click on a section + world.browser.find_by_css('#accordion > nav > div')[chapter_it].find_by_tag('li')[section_it].find_by_tag('a').click() + + ## sometimes the course-content takes a long time to load + assert world.browser.is_element_present_by_css('.course-content',wait_time=5) + + ## look for server error div + check_for_errors() + + ## count tabs from xml and page and compare + + ## count the number of tabs. If number of tabs is 0, there won't be anything rendered + ## so we explicitly set rendered_tabs because otherwise find_elements returns a None object with no length + num_tabs = sections[section_it]['clickable_tab_count'] + if num_tabs != 0: + rendered_tabs = world.browser.find_by_css('ol#sequence-list > li') + num_rendered_tabs = len(rendered_tabs) + else: + rendered_tabs = 0 + num_rendered_tabs = 0 + + assert num_tabs == num_rendered_tabs ,'%d tabs expected, %d tabs found, iteration number %d, on %s' % (num_tabs,num_rendered_tabs,section_it, course_id) + + tab_it = 0 + + ## Iterate the tabs + while tab_it < num_tabs: + + rendered_tabs[tab_it].find_by_tag('a').click() + + ## do something with the tab sections[section_it] + check_for_errors() + + tab_it += 1 + + section_it += 1 + + chapter_it += 1 + + +def validate_course(current_course, ids): + try: + ids.index(current_course) + except: + assert False, "invalid course id" diff --git a/lms/djangoapps/portal/README.md b/lms/djangoapps/portal/README.md new file mode 100644 index 0000000000..b4da273124 --- /dev/null +++ b/lms/djangoapps/portal/README.md @@ -0,0 +1,37 @@ +## acceptance_testing + +This fake django app is here to support acceptance testing using lettuce + +salad +(which uses splinter wrapping selenium). + +Some documentation for our efforts are located in basecamp at this link. + +First you need to make sure that you've installed the requirements. +This includes lettuce, salad, selenium, splinter, etc. +Do this with: +```pip install -r test-requirements.txt``` + +First set up the database that you need: +WARNING!!! THIS WILL OVERWRITE THE DATA IN YOUR DEV DATABASE +IF YOU WANT TO KEEP THAT DATA, SAVE A COPY OF YOUR ../db/mitx.db ELSEWHERE FIRST! + +
  • If necessary, delete it first from mit_all/db
  • +
  • ```rake django-admin[syncdb,lms,acceptance]```
  • +
  • ```rake django-admin[migrate,lms,acceptance]```
  • + +To use, start up the server separately: +```rake lms[acceptance]``` + +In between scenarios, flush the database with this command. +You will not need to do this if it's set up in the terrain.py file +which is at the mitx root level. +```rake django-admin[flush,lms,acceptance,--noinput]``` + +Running the all the user acceptance scenarios: +```django-admin.py harvest --no-server --settings=lms.envs.acceptance --pythonpath=.``` + +Running a single user acceptance scenario: +```django-admin.py harvest --no-server --settings=lms.envs.acceptance --pythonpath=. lms/djangoapps/portal/features/signup.feature``` + +Or you can use the rake task named lettuce like this: +rake lettuce[lms/djangoapps/portal/features/homepage.feature] diff --git a/lms/djangoapps/portal/__init__.py b/lms/djangoapps/portal/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/portal/features/homepage.feature b/lms/djangoapps/portal/features/homepage.feature new file mode 100644 index 0000000000..fa416a598f --- /dev/null +++ b/lms/djangoapps/portal/features/homepage.feature @@ -0,0 +1,45 @@ +Feature: Homepage for web users + In order to get an idea what edX is about + As a an anonymous web user + I want to check the information on the home page + + Scenario: User can see the "Login" button + Given I visit the homepage + Then I should see a link called "Log In" + + Scenario: User can see the "Sign up" button + Given I visit the homepage + Then I should see a link called "Sign Up" + + Scenario Outline: User can see main parts of the page + Given I visit the homepage + Then I should see a link called "" + When I click the link with the text "" + Then I should see that the path is "" + + Examples: + | Link | Path | + | Find Courses | /courses | + | About | /about | + | Jobs | /jobs | + | Contact | /contact | + + Scenario: User can visit the blog + Given I visit the homepage + When I click the link with the text "Blog" + Then I should see that the url is "http://blog.edx.org/" + + # TODO: test according to domain or policy + Scenario: User can see the partner institutions + Given I visit the homepage + Then I should see "" in the Partners section + + Examples: + | Partner | + | MITx | + | HarvardX | + | BerkeleyX | + | UTx | + + # # TODO: Add scenario that tests the courses available + # # using a policy or a configuration file diff --git a/lms/djangoapps/portal/features/homepage.py b/lms/djangoapps/portal/features/homepage.py new file mode 100644 index 0000000000..9170f9c844 --- /dev/null +++ b/lms/djangoapps/portal/features/homepage.py @@ -0,0 +1,5 @@ +from lettuce import world, step + +@step('I should see "([^"]*)" in the Partners section$') +def i_should_see_partner(step, partner): + assert (partner in world.browser.find_by_css(".partners").text) diff --git a/lms/djangoapps/portal/features/login.feature b/lms/djangoapps/portal/features/login.feature new file mode 100644 index 0000000000..3d8757bd32 --- /dev/null +++ b/lms/djangoapps/portal/features/login.feature @@ -0,0 +1,27 @@ +Feature: Login in as a registered user + As a registered user + In order to access my content + I want to be able to login in to edX + + Scenario: Login to an unactivated account + Given I am an edX user + And I am an unactivated user + And I visit the homepage + When I click on the link with the text "Log In" + And I submit my credentials on the login form + Then I should see the login error message "This account has not been activated" + + Scenario: Login to an activated account + Given I am an edX user + And I am an activated user + And I visit the homepage + When I click on the link with the text "Log In" + And I submit my credentials on the login form + Then I should be on the dashboard page + + Scenario: Logout of a signed in account + Given I am logged in + When I click the dropdown arrow + And I click on the link with the text "Log Out" + Then I should see a link with the text "Log In" + And I should see that the path is "/" \ No newline at end of file diff --git a/lms/djangoapps/portal/features/login.py b/lms/djangoapps/portal/features/login.py new file mode 100644 index 0000000000..f538aaa5eb --- /dev/null +++ b/lms/djangoapps/portal/features/login.py @@ -0,0 +1,46 @@ +from lettuce import step, world +from salad.steps.everything import * +from django.contrib.auth.models import User + +@step('I am an unactivated user$') +def i_am_an_unactivated_user(step): + user_is_an_unactivated_user('robot') + +@step('I am an activated user$') +def i_am_an_activated_user(step): + user_is_an_activated_user('robot') + +@step('I submit my credentials on the login form') +def i_submit_my_credentials_on_the_login_form(step): + fill_in_the_login_form('email', 'robot@edx.org') + fill_in_the_login_form('password', 'test') + login_form = world.browser.find_by_css('form#login_form') + login_form.find_by_value('Access My Courses').click() + +@step(u'I should see the login error message "([^"]*)"$') +def i_should_see_the_login_error_message(step, msg): + login_error_div = world.browser.find_by_css('form#login_form #login_error') + assert (msg in login_error_div.text) + +@step(u'click the dropdown arrow$') +def click_the_dropdown(step): + css = ".dropdown" + e = world.browser.find_by_css(css) + e.click() + +#### helper functions + +def user_is_an_unactivated_user(uname): + u = User.objects.get(username=uname) + u.is_active = False + u.save() + +def user_is_an_activated_user(uname): + u = User.objects.get(username=uname) + u.is_active = True + u.save() + +def fill_in_the_login_form(field, value): + login_form = world.browser.find_by_css('form#login_form') + form_field = login_form.find_by_name(field) + form_field.fill(value) diff --git a/lms/djangoapps/portal/features/registration.feature b/lms/djangoapps/portal/features/registration.feature new file mode 100644 index 0000000000..ad226c479b --- /dev/null +++ b/lms/djangoapps/portal/features/registration.feature @@ -0,0 +1,18 @@ +Feature: Register for a course + As a registered user + In order to access my class content + I want to register for a class on the edX website + + Scenario: I can register for a course + Given I am logged in + And I visit the courses page + When I register for the course numbered "6.002x" + Then I should see the course numbered "6.002x" in my dashboard + + Scenario: I can unregister for a course + Given I am logged in + And I am registered for a course + And I visit the dashboard + When I click the link with the text "Unregister" + And I press the "Unregister" button in the Unenroll dialog + Then I should see "Looks like you haven't registered for any courses yet." somewhere in the page \ No newline at end of file diff --git a/lms/djangoapps/portal/features/registration.py b/lms/djangoapps/portal/features/registration.py new file mode 100644 index 0000000000..124bed4923 --- /dev/null +++ b/lms/djangoapps/portal/features/registration.py @@ -0,0 +1,24 @@ +from lettuce import world, step + +@step('I register for the course numbered "([^"]*)"$') +def i_register_for_the_course(step, course): + courses_section = world.browser.find_by_css('section.courses') + course_link_css = 'article[id*="%s"] a' % course + course_link = courses_section.find_by_css(course_link_css).first + course_link.click() + + intro_section = world.browser.find_by_css('section.intro') + register_link = intro_section.find_by_css('a.register') + register_link.click() + + assert world.browser.is_element_present_by_css('section.container.dashboard') + +@step(u'I should see the course numbered "([^"]*)" in my dashboard$') +def i_should_see_that_course_in_my_dashboard(step, course): + course_link_css = 'section.my-courses a[href*="%s"]' % course + assert world.browser.is_element_present_by_css(course_link_css) + +@step(u'I press the "([^"]*)" button in the Unenroll dialog') +def i_press_the_button_in_the_unenroll_dialog(step, value): + button_css = 'section#unenroll-modal input[value="%s"]' % value + world.browser.find_by_css(button_css).click() diff --git a/lms/djangoapps/portal/features/signup.feature b/lms/djangoapps/portal/features/signup.feature new file mode 100644 index 0000000000..5529225670 --- /dev/null +++ b/lms/djangoapps/portal/features/signup.feature @@ -0,0 +1,16 @@ +Feature: Sign in + In order to use the edX content + As a new user + I want to signup for a student account + + Scenario: Sign up from the homepage + Given I visit the homepage + When I click the link with the text "Sign Up" + And I fill in "email" on the registration form with "robot2@edx.org" + And I fill in "password" on the registration form with "test" + And I fill in "username" on the registration form with "robot2" + And I fill in "name" on the registration form with "Robot Two" + And I check the checkbox named "terms_of_service" + And I check the checkbox named "honor_code" + And I press the "Create My Account" button on the registration form + Then I should see "THANKS FOR REGISTERING!" in the dashboard banner \ No newline at end of file diff --git a/lms/djangoapps/portal/features/signup.py b/lms/djangoapps/portal/features/signup.py new file mode 100644 index 0000000000..afde72b589 --- /dev/null +++ b/lms/djangoapps/portal/features/signup.py @@ -0,0 +1,22 @@ +from lettuce import world, step + +@step('I fill in "([^"]*)" on the registration form with "([^"]*)"$') +def when_i_fill_in_field_on_the_registration_form_with_value(step, field, value): + register_form = world.browser.find_by_css('form#register_form') + form_field = register_form.find_by_name(field) + form_field.fill(value) + +@step('I press the "([^"]*)" button on the registration form$') +def i_press_the_button_on_the_registration_form(step, button): + register_form = world.browser.find_by_css('form#register_form') + register_form.find_by_value(button).click() + +@step('I check the checkbox named "([^"]*)"$') +def i_check_checkbox(step, checkbox): + world.browser.find_by_name(checkbox).check() + +@step('I should see "([^"]*)" in the dashboard banner$') +def i_should_see_text_in_the_dashboard_banner_section(step, text): + css_selector = "section.dashboard-banner h2" + assert (text in world.browser.find_by_css(css_selector).text) + \ No newline at end of file diff --git a/lms/djangoapps/terrain/__init__.py b/lms/djangoapps/terrain/__init__.py new file mode 100644 index 0000000000..dd7ed50184 --- /dev/null +++ b/lms/djangoapps/terrain/__init__.py @@ -0,0 +1,6 @@ +# Use this as your terrain file so that the common steps +# across all lms apps can be put in terrain/steps +# See https://groups.google.com/forum/?fromgroups=#!msg/lettuce-users/5VyU9B4HcX8/USgbGIJdS5QJ +from terrain.browser import * +from terrain.common import * +from terrain.factories import * \ No newline at end of file diff --git a/lms/djangoapps/terrain/browser.py b/lms/djangoapps/terrain/browser.py new file mode 100644 index 0000000000..85f842279e --- /dev/null +++ b/lms/djangoapps/terrain/browser.py @@ -0,0 +1,78 @@ +from lettuce import before, after, world +from splinter.browser import Browser +from splinter.driver.webdriver.firefox import FirefoxProfile +from logging import getLogger +import time + +logger = getLogger(__name__) +logger.info("Loading the terrain file...") + +try: + from django.core.management import call_command + from django.conf import settings + from django.test.simple import DjangoTestSuiteRunner + from django.core import mail + + try: + from south.management.commands import patch_for_test_db_setup + USE_SOUTH = getattr(settings, "SOUTH_TESTS_MIGRATE", False) + except: + USE_SOUTH = False + + @before.runserver + def setup_database(actual_server): + logger.info("Setting up a test database...") + + if USE_SOUTH: + patch_for_test_db_setup() + + world.test_runner = DjangoTestSuiteRunner(interactive=False) + DjangoTestSuiteRunner.setup_test_environment(world.test_runner) + world.created_db = DjangoTestSuiteRunner.setup_databases(world.test_runner) + + # call_command('syncdb', interactive=False, verbosity=0) + # call_command('migrate', interactive=False, verbosity=0) + + # because the TestSuiteRunner setup_test_environment hard codes it to False + settings.DEBUG = True + + @after.runserver + def teardown_database(actual_server): + if hasattr(world, "test_runner"): + logger.info("Destroying the test database ...") + DjangoTestSuiteRunner.teardown_databases(world.test_runner, world.created_db) + DjangoTestSuiteRunner.teardown_test_environment(world.test_runner) + + @before.harvest + def initial_setup(server): + # call_command('syncdb', interactive=False, verbosity=2) + # call_command('migrate', interactive=False, verbosity=2) + + world.browser = Browser('firefox') + # pass + + # logger.info('Sleeping 7 seconds to give the server time to compile the js...') + # time.sleep(float(7)) + # logger.info('...done sleeping.') + + @before.each_scenario + def reset_data(scenario): + # Clean up django. + logger.info("Flushing the test database...") + call_command('flush', interactive=False) + #call_command('loaddata', 'all', verbosity=0) + + @after.all + def teardown_browser(total): + # world.browser.driver.save_screenshot('/tmp/selenium_screenshot.png') + # world.browser.quit() + pass + + +except: + try: + # Only complain if it seems likely that using django was intended. + import django + logger.warn("Django terrains not imported.") + except: + pass \ No newline at end of file diff --git a/lms/djangoapps/terrain/common.py b/lms/djangoapps/terrain/common.py new file mode 100644 index 0000000000..6d5ac83d24 --- /dev/null +++ b/lms/djangoapps/terrain/common.py @@ -0,0 +1,105 @@ +from lettuce import world, step +from factories import * +from django.core.management import call_command +from salad.steps.everything import * +from lettuce.django import django_url +from django.conf import settings +from django.contrib.auth.models import User +from student.models import CourseEnrollment +import time +from nose.tools import assert_equals + +from logging import getLogger +logger = getLogger(__name__) + +@step(u'I wait (?:for )?"(\d+)" seconds?$') +def wait(step, seconds): + time.sleep(float(seconds)) + +@step('I (?:visit|access|open) the homepage$') +def i_visit_the_homepage(step): + world.browser.visit(django_url('/')) + assert world.browser.is_element_present_by_css('header.global', 10) + +@step(u'I (?:visit|access|open) the dashboard$') +def i_visit_the_dashboard(step): + world.browser.visit(django_url('/dashboard')) + assert world.browser.is_element_present_by_css('section.container.dashboard', 5) + +@step('I should be on the dashboard page$') +def i_should_be_on_the_dashboard(step): + assert world.browser.is_element_present_by_css('section.container.dashboard', 5) + assert world.browser.title == 'Dashboard' + +@step(u'I (?:visit|access|open) the courses page$') +def i_am_on_the_courses_page(step): + world.browser.visit(django_url('/courses')) + assert world.browser.is_element_present_by_css('section.courses') + +@step('I should see that the path is "([^"]*)"$') +def i_should_see_that_the_path_is(step, path): + assert world.browser.url == django_url(path) + +@step(u'the page title should be "([^"]*)"$') +def the_page_title_should_be(step, title): + assert_equals(world.browser.title, title) + +@step('I am a logged in user$') +def i_am_logged_in_user(step): + create_user('robot') + log_in('robot@edx.org','test') + +@step('I am not logged in$') +def i_am_not_logged_in(step): + world.browser.cookies.delete() + +@step('I am registered for a course$') +def i_am_registered_for_a_course(step): + create_user('robot') + u = User.objects.get(username='robot') + CourseEnrollment.objects.get_or_create(user=u, course_id='MITx/6.002x/2012_Fall') + +@step('I am registered for course "([^"]*)"$') +def i_am_registered_for_course_by_id(step, course_id): + create_user('robot') + u = User.objects.get(username='robot') + CourseEnrollment.objects.get_or_create(user=u, course_id=course_id) + +@step('I log in$') +def i_log_in(step): + log_in('robot@edx.org','test') + +@step(u'I am an edX user$') +def i_am_an_edx_user(step): + create_user('robot') + +#### helper functions +def create_user(uname): + # This user factory stuff should work after we kill askbot + portal_user = UserFactory.build(username=uname, email=uname + '@edx.org') + portal_user.set_password('test') + portal_user.save() + + registration = RegistrationFactory(user=portal_user) + registration.register(portal_user) + registration.activate() + + user_profile = UserProfileFactory(user=portal_user) + +def log_in(email, password): + world.browser.cookies.delete() + world.browser.visit(django_url('/')) + world.browser.is_element_present_by_css('header.global', 10) + world.browser.click_link_by_href('#login-modal') + login_form = world.browser.find_by_css('form#login_form') + login_form.find_by_name('email').fill(email) + login_form.find_by_name('password').fill(password) + login_form.find_by_name('submit').click() + + # wait for the page to redraw + assert world.browser.is_element_present_by_css('.content-wrapper', 10) + +########### DEBUGGING ############## +@step(u'I save a screenshot to "(.*)"') +def save_screenshot_to(step, filename): + world.browser.driver.save_screenshot(filename) diff --git a/lms/djangoapps/terrain/factories.py b/lms/djangoapps/terrain/factories.py new file mode 100644 index 0000000000..ddab9e2b06 --- /dev/null +++ b/lms/djangoapps/terrain/factories.py @@ -0,0 +1,34 @@ +import factory +from student.models import User, UserProfile, Registration +from datetime import datetime +import uuid + +class UserProfileFactory(factory.Factory): + FACTORY_FOR = UserProfile + + user = None + name = 'Robot Test' + level_of_education = None + gender = 'm' + mailing_address = None + goals = 'World domination' + +class RegistrationFactory(factory.Factory): + FACTORY_FOR = Registration + + user = None + activation_key = uuid.uuid4().hex + +class UserFactory(factory.Factory): + FACTORY_FOR = User + + username = 'robot' + email = 'robot+test@edx.org' + password = 'test' + first_name = 'Robot' + last_name = 'Test' + is_staff = False + is_active = True + is_superuser = False + last_login = datetime(2012, 1, 1) + date_joined = datetime(2011, 1, 1) diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py new file mode 100644 index 0000000000..474f22be28 --- /dev/null +++ b/lms/envs/acceptance.py @@ -0,0 +1,20 @@ +""" +This config file is a copy of dev environment without the Debug +Toolbar. I it suitable to run against acceptance tests. + +""" +from .dev import * + +# REMOVE DEBUG TOOLBAR + +INSTALLED_APPS = tuple(e for e in INSTALLED_APPS if e != 'debug_toolbar') +MIDDLEWARE_CLASSES = tuple(e for e in MIDDLEWARE_CLASSES \ + if e != 'debug_toolbar.middleware.DebugToolbarMiddleware') + + +########################### LETTUCE TESTING ########################## +MITX_FEATURES['DISPLAY_TOY_COURSES'] = True + +INSTALLED_APPS += ('lettuce.django',) + +LETTUCE_APPS = ('portal',) # dummy app covers the home page, login, registration, and course enrollment \ No newline at end of file diff --git a/test-requirements.txt b/test-requirements.txt index c9c15b340d..0238249df8 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -3,3 +3,7 @@ coverage nosexcover pylint pep8 +lettuce +salad +selenium +factory_boy From 0a77dd4431d672dc172a10ed896b5a21b4764031 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Wed, 28 Nov 2012 09:17:57 -0500 Subject: [PATCH 02/59] Smart accordion walkthrough working for cms-master branch Conflicts: lms/envs/cms/acceptance.py --- .../features/smart-accordion.feature | 16 ++++++------- lms/envs/cms/acceptance.py | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 lms/envs/cms/acceptance.py diff --git a/lms/djangoapps/courseware/features/smart-accordion.feature b/lms/djangoapps/courseware/features/smart-accordion.feature index 98dad7131f..ebfe588c6c 100644 --- a/lms/djangoapps/courseware/features/smart-accordion.feature +++ b/lms/djangoapps/courseware/features/smart-accordion.feature @@ -8,12 +8,12 @@ Feature: There are courses on the homepage And I log in Then I verify all the content of each course - Scenario: Navigate through course edX/edx101/edX_Studio_Reference - Given I am registered for course "edX/edx101/edX_Studio_Reference" - And I log in - Then I verify all the content of each course + # Scenario: Navigate through course edX/edx101/edX_Studio_Reference + # Given I am registered for course "edX/edx101/edX_Studio_Reference" + # And I log in + # Then I verify all the content of each course - Scenario: Navigate through course BerkeleyX/CS169.1x/2012_Fall - Given I am registered for course "BerkeleyX/CS169.1x/2012_Fall" - And I log in - Then I verify all the content of each course \ No newline at end of file + # Scenario: Navigate through course BerkeleyX/CS169.1x/2012_Fall + # Given I am registered for course "BerkeleyX/CS169.1x/2012_Fall" + # And I log in + # Then I verify all the content of each course \ No newline at end of file diff --git a/lms/envs/cms/acceptance.py b/lms/envs/cms/acceptance.py new file mode 100644 index 0000000000..e5ee2937f4 --- /dev/null +++ b/lms/envs/cms/acceptance.py @@ -0,0 +1,23 @@ +""" +This config file is a copy of dev environment without the Debug +Toolbar. I it suitable to run against acceptance tests. + +""" +from .dev import * + +# REMOVE DEBUG TOOLBAR + +INSTALLED_APPS = tuple(e for e in INSTALLED_APPS if e != 'debug_toolbar') +INSTALLED_APPS = tuple(e for e in INSTALLED_APPS if e != 'debug_toolbar_mongo') + +MIDDLEWARE_CLASSES = tuple(e for e in MIDDLEWARE_CLASSES \ + if e != 'debug_toolbar.middleware.DebugToolbarMiddleware') + + +########################### LETTUCE TESTING ########################## +MITX_FEATURES['DISPLAY_TOY_COURSES'] = True + +INSTALLED_APPS += ('lettuce.django',) +# INSTALLED_APPS += ('portal',) + +LETTUCE_APPS = ('portal',) # dummy app covers the home page, login, registration, and course enrollment From a40ea202d22963773dbd4dbeb4d70b1f56cbceab Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Wed, 28 Nov 2012 11:12:31 -0500 Subject: [PATCH 03/59] Cleanup of imports in courseware tests --- lms/djangoapps/courseware/features/courses.py | 32 +++---------- .../courseware/features/smart-accordion.py | 45 ++++++------------- 2 files changed, 19 insertions(+), 58 deletions(-) diff --git a/lms/djangoapps/courseware/features/courses.py b/lms/djangoapps/courseware/features/courses.py index 7b22c54231..b4bd00c055 100644 --- a/lms/djangoapps/courseware/features/courses.py +++ b/lms/djangoapps/courseware/features/courses.py @@ -1,32 +1,10 @@ -from lettuce import * #before, world -from selenium import * -#import lettuce_webdriver.webdriver -import logging -import nose.tools -from selenium.webdriver import ActionChains -from selenium.webdriver.support.ui import WebDriverWait - -## imported from lms/djangoapps/courseware/courses.py -from collections import defaultdict -from fs.errors import ResourceNotFoundError -from functools import wraps -import logging - -from path import path -from django.conf import settings -from django.http import Http404 - from xmodule.course_module import CourseDescriptor -from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore -from xmodule.modulestore.exceptions import ItemNotFoundError -from static_replace import replace_urls, try_staticfiles_lookup -from courseware.access import has_access -## end import +from courseware.courses import get_course_by_id +from xmodule import seq_module -from django.core.urlresolvers import reverse -from courseware.courses import course_image_url, get_course_about_section, get_course_by_id -import xmodule +from logging import getLogger +logger = getLogger(__name__) ## support functions def get_courses(): @@ -123,7 +101,7 @@ def get_courseware_with_tabs(course_id): course = get_course_by_id(course_id) chapters = [ chapter for chapter in course.get_children() if chapter.metadata.get('hide_from_toc','false').lower() != 'true' ] - courseware = [{'chapter_name':c.display_name, 'sections':[{'section_name':s.display_name, 'clickable_tab_count': len(s.get_children()) if (type(s)==xmodule.seq_module.SequenceDescriptor) else 0, 'tab_classes':[t.__class__.__name__ for t in s.get_children() ]} for s in c.get_children() if s.metadata.get('hide_from_toc', 'false').lower() != 'true']} for c in chapters ] + courseware = [{'chapter_name':c.display_name, 'sections':[{'section_name':s.display_name, 'clickable_tab_count': len(s.get_children()) if (type(s)==seq_module.SequenceDescriptor) else 0, 'tab_classes':[t.__class__.__name__ for t in s.get_children() ]} for s in c.get_children() if s.metadata.get('hide_from_toc', 'false').lower() != 'true']} for c in chapters ] return courseware diff --git a/lms/djangoapps/courseware/features/smart-accordion.py b/lms/djangoapps/courseware/features/smart-accordion.py index ebc5f19db6..caaddec432 100644 --- a/lms/djangoapps/courseware/features/smart-accordion.py +++ b/lms/djangoapps/courseware/features/smart-accordion.py @@ -1,34 +1,8 @@ from lettuce import world, step -import re +from re import sub from nose.tools import assert_equals - -## imported from lms/djangoapps/courseware/courses.py -from collections import defaultdict -from fs.errors import ResourceNotFoundError -from functools import wraps - -from path import path -from django.conf import settings -from django.http import Http404 - -from xmodule.course_module import CourseDescriptor -from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore -from xmodule.modulestore.exceptions import ItemNotFoundError -from static_replace import replace_urls, try_staticfiles_lookup -from courseware.access import has_access -## end import - -from django.core.urlresolvers import reverse -from courseware.courses import course_image_url, get_course_about_section, get_course_by_id from courses import * -import os.path -import sys -path = os.path.abspath(os.path.join(os.path.dirname(__file__), 'static')) -if not path in sys.path: - sys.path.insert(1, path) -del path -#from helpers import * from logging import getLogger logger = getLogger(__name__) @@ -57,7 +31,7 @@ def i_verify_all_the_content_of_each_course(step): check_for_errors() # Get the course. E.g. 'MITx/6.002x/2012_Fall' - current_course = re.sub('/info','',re.sub('.*/courses/','',world.browser.url)) + current_course = sub('/info','', sub('.*/courses/', '', world.browser.url)) validate_course(current_course,ids) world.browser.find_link_by_text('Courseware').click() @@ -74,9 +48,13 @@ def browse_course(course_id): ## count chapters from xml and page and compare chapters = get_courseware_with_tabs(course_id) num_chapters = len(chapters) + rendered_chapters = world.browser.find_by_css('#accordion > nav > div') num_rendered_chapters = len(rendered_chapters) - assert num_chapters == num_rendered_chapters, '%d chapters expected, %d chapters found on page for %s' % (num_chapters, num_rendered_chapters, course_id) + + msg = '%d chapters expected, %d chapters found on page for %s' % (num_chapters, num_rendered_chapters, course_id) + logger.debug(msg) + assert num_chapters == num_rendered_chapters, msg chapter_it = 0 @@ -95,7 +73,10 @@ def browse_course(course_id): rendered_sections = world.browser.find_by_css('#accordion > nav > div')[chapter_it].find_by_tag('li') num_rendered_sections = len(rendered_sections) - assert num_sections == num_rendered_sections, '%d sections expected, %d sections found on page, iteration number %d on %s' % (num_sections, num_rendered_sections, chapter_it, course_id) + + msg = '%d sections expected, %d sections found on page, %s - %d - %s' % (num_sections, num_rendered_sections, course_id, chapter_it, chapters[chapter_it]['chapter_name']) + logger.debug(msg) + assert num_sections == num_rendered_sections, msg section_it = 0 @@ -123,7 +104,9 @@ def browse_course(course_id): rendered_tabs = 0 num_rendered_tabs = 0 - assert num_tabs == num_rendered_tabs ,'%d tabs expected, %d tabs found, iteration number %d, on %s' % (num_tabs,num_rendered_tabs,section_it, course_id) + msg = '%d tabs expected, %d tabs found, %s - %d - %s' % (num_tabs, num_rendered_tabs, course_id, section_it, sections[section_it]['section_name']) + logger.debug(msg) + assert num_tabs == num_rendered_tabs, msg tab_it = 0 From 10275e97096cfdc7feceef0e1c74324af41fc6d2 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Thu, 29 Nov 2012 13:17:45 -0500 Subject: [PATCH 04/59] Add item checking within courseware tabs --- .../features/course-section-content.feature | 10 - .../features/course-section-content.py | 197 ------------------ lms/djangoapps/courseware/features/courses.py | 191 +++++++++++++++-- .../courseware/features/smart-accordion.py | 19 +- 4 files changed, 185 insertions(+), 232 deletions(-) delete mode 100644 lms/djangoapps/courseware/features/course-section-content.feature delete mode 100644 lms/djangoapps/courseware/features/course-section-content.py diff --git a/lms/djangoapps/courseware/features/course-section-content.feature b/lms/djangoapps/courseware/features/course-section-content.feature deleted file mode 100644 index bf6a9d8929..0000000000 --- a/lms/djangoapps/courseware/features/course-section-content.feature +++ /dev/null @@ -1,10 +0,0 @@ -Feature: There are many different types of tabs - In order to validate tab types - As a staff member - I want to try out all the videos, buttons, and content - - Scenario: I visit a tabbed quiz - Given I am registered for course "MITx/6.002x-EE98/2012_Fall_SJSU" - And I log in - Given I visit and check 502 for "http://www.edx.org/courses/MITx/6.002x-EE98/2012_Fall_SJSU/courseware/Week_0/Administrivia_and_Circuit_Elements/" - I process diff --git a/lms/djangoapps/courseware/features/course-section-content.py b/lms/djangoapps/courseware/features/course-section-content.py deleted file mode 100644 index 5513ab9d0c..0000000000 --- a/lms/djangoapps/courseware/features/course-section-content.py +++ /dev/null @@ -1,197 +0,0 @@ -from lettuce import * #before, world -from selenium import * -#import lettuce_webdriver.webdriver -import logging -import nose.tools -from selenium.webdriver import ActionChains -from selenium.webdriver.support.ui import WebDriverWait -import re - -## imported from lms/djangoapps/courseware/courses.py -from collections import defaultdict -from fs.errors import ResourceNotFoundError -from functools import wraps -import logging - -from path import path -from django.conf import settings -from django.http import Http404 - -from xmodule.course_module import CourseDescriptor -from xmodule.modulestore import Location -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.exceptions import ItemNotFoundError -from static_replace import replace_urls, try_staticfiles_lookup -from courseware.access import has_access -## end import - -from django.core.urlresolvers import reverse -from courseware.courses import course_image_url, get_course_about_section, get_course_by_id -from courses import * -import os.path -import sys -path = os.path.abspath(os.path.join(os.path.dirname(__file__), 'static')) -if not path in sys.path: - sys.path.insert(1, path) -del path -#from helpers import * - - -@step(u'I visit and check 502 for "(.*)"') -def i_visit_and_check_502_for_url(step, url): - world.browser.get(url) - check_for_502(url) - -@step(u'I process') -def i_make_sure_everything_is_there(step): - e = world.browser.find_element_by_css_selector('section.course-content section') - process_section(e) - - -def process_section(element, num_tabs=0): - ''' - Process section reads through whatever is in 'course-content' and classifies it according to sequence module type. - - This function is recursive - - There are 5 types, with 5 actions. - - Sequence Module - -contains one child module - -to prevent from over-processing all its children (no easy way to specify only one level of depth), we only grab the first child - - Vertical Module - -contains other modules - -process it and get its children, then process them - - Capa Module - -problem type, contains only one problem - -for this, the most complex type, we created a separate method, process_problem - - Video Module - -video type, contains only one video - -we only check to ensure that a section with class of video exists - - Custom Tag Module - -a custom 'hack' module type - -there is a large variety of content that could go in a custom tag module, so we just pass if it is of this unusual type - ''' - tab_type = element.get_attribute('class') - print 'processing a %s' % (tab_type) - if tab_type == "xmodule_display xmodule_SequenceModule": - child_modules = element.find_elements_by_css_selector("section[class^='xmodule']") - - ## ugly bit of code to get around not being able to specify only the first level of descendants - if child_modules[0].get_attribute('class') == "xmodule_display xmodule_VerticalModule": - process_section(child_modules[0]) - else: - for mod in child_modules: - process_section(mod) - - elif tab_type == "xmodule_display xmodule_VerticalModule": - vert_list = element.find_elements_by_css_selector("li section[class^='xmodule']") - print "I found %s items" % (str(len(vert_list))) - for item in vert_list: - print 'processing a child %s' % (item.get_attribute('class')) - process_section(item) - - elif tab_type == "xmodule_display xmodule_CapaModule": - assert element.find_element_by_css_selector("section[id^='problem']") , "No problems found in %s" % (tab_type) - p = element.find_element_by_css_selector("section[id^='problem']") - p_id = p.get_attribute('id') - process_problem(p, p_id) - - elif tab_type == "xmodule_display xmodule_VideoModule": - assert element.find_element_by_css_selector("section[class^='video']") , 'No video found in %s' % (tab_type) - - elif tab_type == "xmodule_display xmodule_CustomTagModule": - pass - - else: - assert False, "%s not recognized!!" % (tab_type) - - - -def process_problem(element, problem_id): - ''' - Process problem attempts to - 1) scan all the input fields and reset them - 2) click the 'check' button and look for an incorrect response (p.status text should be 'incorrect') - 3) click the 'show answer' button IF it exists and IF the answer is not already displayed - 4) enter the correct answer in each input box - 5) click the 'check' button and verify that answers are correct - - Because of all the ajax calls happening, sometimes the test fails because objects disconnect from the DOM. - The basic functionality does exist, though, and I'm hoping that someone can take it over and make it super effective. - ''' - - prob_xmod = element.find_element_by_css_selector("section.problem") - input_fields = prob_xmod.find_elements_by_css_selector("section[id^='textinput']") - - ## clear out all input to ensure an incorrect result - for field in input_fields: - box = field.find_element_by_css_selector("input") - box.clear() - print "\n I cleared out the box %s \n" % (box.get_attribute('id')) - - ## because of cookies or the application, only click the 'check' button if the status is not already 'incorrect' - if prob_xmod.find_element_by_css_selector("p.status").text.lower() != 'incorrect': - prob_xmod.find_element_by_css_selector("section.action input.check").click() - world.browser.implicitly_wait(4) - - ## all elements become disconnected after the click - element = world.browser.find_element_by_css_selector("section[id='"+problem_id+"']") - prob_xmod = element.find_element_by_css_selector("section.problem") - input_fields = prob_xmod.find_elements_by_css_selector("section[id^='textinput']") - for field in input_fields: - assert field.find_element_by_css_selector("div.incorrect") , "The 'check' button did not work for %s" % (problem_id) - print "\n So far so good! \n" - - - ## wait for the ajax changes to render - world.browser.implicitly_wait(4) - - ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) - element = world.browser.find_element_by_css_selector("section[id='"+problem_id+"']") - prob_xmod = element.find_element_by_css_selector("section.problem") - - - show_button = element.find_element_by_css_selector("section.action input.show") - ## this logic is to ensure we do not accidentally hide the answers - if show_button.get_attribute('value').lower() == 'show answer': - show_button.click() - print "\n I clicked show for %s \n" % (problem_id) - else: - pass - - - ## wait for the ajax changes to render - world.browser.implicitly_wait(4) - - ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) - element = world.browser.find_element_by_css_selector("section[id='"+problem_id+"']") - prob_xmod = element.find_element_by_css_selector("section.problem") - - ## find all the input fields - input_fields = prob_xmod.find_elements_by_css_selector("section[id^='textinput']") - - ## in each field, find the answer, and send it to the field. - ## Note that this does not work if the answer type is a strange format, e.g. "either a or b" - for field in input_fields: - field.find_element_by_css_selector("input").send_keys(field.find_element_by_css_selector("p[id^='answer']").text) - print "\n \n Entered %s into %s \n \n" % (field.find_element_by_css_selector("p[id^='answer']").text,field.find_element_by_css_selector("input").get_attribute('id') ) - prob_xmod = element.find_element_by_css_selector("section.problem") - prob_xmod.find_element_by_css_selector("section.action input.check").click() - world.browser.implicitly_wait(4) - - ## assert that we entered the correct answers - ## we have to redefine input-fields because apparently they become detached from the dom after clicking 'check' - - input_fields = world.browser.find_elements_by_css_selector("section[id='"+problem_id+"'] section[id^='textinput']") - for field in input_fields: - ## if you don't use 'starts with ^=' the test will fail because the actual class is 'correct ' (with a space) - assert world.browser.find_element_by_css_selector("div[class^='correct']"), "The check answer values were not correct for %s" % (problem_id) - inputs = world.browser.find_elements_by_css_selector("section[id^='textinput'] input") - for el in inputs: - el.clear() - print "\n checked answers for %s \n" % (problem_id) diff --git a/lms/djangoapps/courseware/features/courses.py b/lms/djangoapps/courseware/features/courses.py index b4bd00c055..aecaa139ff 100644 --- a/lms/djangoapps/courseware/features/courses.py +++ b/lms/djangoapps/courseware/features/courses.py @@ -1,7 +1,8 @@ +from lettuce import world from xmodule.course_module import CourseDescriptor from xmodule.modulestore.django import modulestore from courseware.courses import get_course_by_id -from xmodule import seq_module +from xmodule import seq_module, vertical_module from logging import getLogger logger = getLogger(__name__) @@ -17,28 +18,28 @@ def get_courses(): courses = sorted(courses, key=lambda course: course.number) return courses -def get_courseware(course_id): - """ - Given a course_id (string), return a courseware array of dictionaries for the - top two levels of navigation. Example: +# def get_courseware(course_id): +# """ +# Given a course_id (string), return a courseware array of dictionaries for the +# top two levels of navigation. Example: - [ - {'chapter_name': 'Overview', - 'sections': ['Welcome', 'System Usage Sequence', 'Lab0: Using the tools', 'Circuit Sandbox'] - }, - {'chapter_name': 'Week 1', - 'sections': ['Administrivia and Circuit Elements', 'Basic Circuit Analysis', 'Resistor Divider', 'Week 1 Tutorials'] - }, - {'chapter_name': 'Midterm Exam', - 'sections': ['Midterm Exam'] - } - ] - """ +# [ +# {'chapter_name': 'Overview', +# 'sections': ['Welcome', 'System Usage Sequence', 'Lab0: Using the tools', 'Circuit Sandbox'] +# }, +# {'chapter_name': 'Week 1', +# 'sections': ['Administrivia and Circuit Elements', 'Basic Circuit Analysis', 'Resistor Divider', 'Week 1 Tutorials'] +# }, +# {'chapter_name': 'Midterm Exam', +# 'sections': ['Midterm Exam'] +# } +# ] +# """ - course = get_course_by_id(course_id) - chapters = course.get_children() - courseware = [ {'chapter_name':c.display_name, 'sections':[s.display_name for s in c.get_children()]} for c in chapters] - return courseware +# course = get_course_by_id(course_id) +# chapters = course.get_children() +# courseware = [ {'chapter_name':c.display_name, 'sections':[s.display_name for s in c.get_children()]} for c in chapters] +# return courseware def get_courseware_with_tabs(course_id): """ @@ -101,7 +102,153 @@ def get_courseware_with_tabs(course_id): course = get_course_by_id(course_id) chapters = [ chapter for chapter in course.get_children() if chapter.metadata.get('hide_from_toc','false').lower() != 'true' ] - courseware = [{'chapter_name':c.display_name, 'sections':[{'section_name':s.display_name, 'clickable_tab_count': len(s.get_children()) if (type(s)==seq_module.SequenceDescriptor) else 0, 'tab_classes':[t.__class__.__name__ for t in s.get_children() ]} for s in c.get_children() if s.metadata.get('hide_from_toc', 'false').lower() != 'true']} for c in chapters ] + courseware = [{'chapter_name':c.display_name, + 'sections':[{'section_name':s.display_name, + 'clickable_tab_count':len(s.get_children()) if (type(s)==seq_module.SequenceDescriptor) else 0, + 'tabs':[{'children_count':len(t.get_children()) if (type(t)==vertical_module.VerticalDescriptor) else 0, + 'class':t.__class__.__name__ } + for t in s.get_children() ]} + for s in c.get_children() if s.metadata.get('hide_from_toc', 'false').lower() != 'true']} + for c in chapters ] return courseware +def process_section(element, num_tabs=0): + ''' + Process section reads through whatever is in 'course-content' and classifies it according to sequence module type. + + This function is recursive + + There are 6 types, with 6 actions. + + Sequence Module + -contains one child module + + Vertical Module + -contains other modules + -process it and get its children, then process them + + Capa Module + -problem type, contains only one problem + -for this, the most complex type, we created a separate method, process_problem + + Video Module + -video type, contains only one video + -we only check to ensure that a section with class of video exists + + HTML Module + -html text + -we do not check anything about it + + Custom Tag Module + -a custom 'hack' module type + -there is a large variety of content that could go in a custom tag module, so we just pass if it is of this unusual type + + can be used like this: + e = world.browser.find_by_css('section.course-content section') + process_section(e) + + ''' + if element.has_class('xmodule_display xmodule_SequenceModule'): + logger.debug('####### Processing xmodule_SequenceModule') + child_modules = element.find_by_css("div>div>section[class^='xmodule']") + for mod in child_modules: + process_section(mod) + + elif element.has_class('xmodule_display xmodule_VerticalModule'): + logger.debug('####### Processing xmodule_VerticalModule') + vert_list = element.find_by_css("li section[class^='xmodule']") + for item in vert_list: + process_section(item) + + elif element.has_class('xmodule_display xmodule_CapaModule'): + logger.debug('####### Processing xmodule_CapaModule') + assert element.find_by_css("section[id^='problem']"), "No problems found in Capa Module" + p = element.find_by_css("section[id^='problem']").first + p_id = p['id'] + logger.debug('####################') + logger.debug('id is "%s"' % p_id) + logger.debug('####################') + process_problem(p, p_id) + + elif element.has_class('xmodule_display xmodule_VideoModule'): + logger.debug('####### Processing xmodule_VideoModule') + assert element.find_by_css("section[class^='video']"), "No video found in Video Module" + + elif element.has_class('xmodule_display xmodule_HtmlModule'): + logger.debug('####### Processing xmodule_HtmlModule') + pass + + elif element.has_class('xmodule_display xmodule_CustomTagModule'): + logger.debug('####### Processing xmodule_CustomTagModule') + pass + + else: + assert False, "Class for element not recognized!!" + + + +def process_problem(element, problem_id): + ''' + Process problem attempts to + 1) scan all the input fields and reset them + 2) click the 'check' button and look for an incorrect response (p.status text should be 'incorrect') + 3) click the 'show answer' button IF it exists and IF the answer is not already displayed + 4) enter the correct answer in each input box + 5) click the 'check' button and verify that answers are correct + + Because of all the ajax calls happening, sometimes the test fails because objects disconnect from the DOM. + The basic functionality does exist, though, and I'm hoping that someone can take it over and make it super effective. + ''' + + prob_xmod = element.find_by_css("section.problem").first + input_fields = prob_xmod.find_by_css("section[id^='input']") + + ## clear out all input to ensure an incorrect result + for field in input_fields: + field.find_by_css("input").first.fill('') + + ## because of cookies or the application, only click the 'check' button if the status is not already 'incorrect' + # This would need to be reworked because multiple choice problems don't have this status + # if prob_xmod.find_by_css("p.status").first.text.strip().lower() != 'incorrect': + prob_xmod.find_by_css("section.action input.check").first.click() + + ## all elements become disconnected after the click + ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) + # Wait for the ajax reload + assert world.browser.is_element_present_by_css("section[id='%s']" % problem_id, wait_time=5) + element = world.browser.find_by_css("section[id='%s']" % problem_id).first + prob_xmod = element.find_by_css("section.problem").first + input_fields = prob_xmod.find_by_css("section[id^='input']") + for field in input_fields: + assert field.find_by_css("div.incorrect"), "The 'check' button did not work for %s" % (problem_id) + + show_button = element.find_by_css("section.action input.show").first + ## this logic is to ensure we do not accidentally hide the answers + if show_button.value.lower() == 'show answer': + show_button.click() + else: + pass + + ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) + assert world.browser.is_element_present_by_css("section[id='%s']" % problem_id, wait_time=5) + element = world.browser.find_by_css("section[id='%s']" % problem_id).first + prob_xmod = element.find_by_css("section.problem").first + input_fields = prob_xmod.find_by_css("section[id^='input']") + + ## in each field, find the answer, and send it to the field. + ## Note that this does not work if the answer type is a strange format, e.g. "either a or b" + for field in input_fields: + field.find_by_css("input").first.fill(field.find_by_css("p[id^='answer']").first.text) + + prob_xmod.find_by_css("section.action input.check").first.click() + + ## assert that we entered the correct answers + ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) + assert world.browser.is_element_present_by_css("section[id='%s']" % problem_id, wait_time=5) + element = world.browser.find_by_css("section[id='%s']" % problem_id).first + prob_xmod = element.find_by_css("section.problem").first + input_fields = prob_xmod.find_by_css("section[id^='input']") + for field in input_fields: + ## if you don't use 'starts with ^=' the test will fail because the actual class is 'correct ' (with a space) + assert field.find_by_css("div[class^='correct']"), "The check answer values were not correct for %s" % problem_id diff --git a/lms/djangoapps/courseware/features/smart-accordion.py b/lms/djangoapps/courseware/features/smart-accordion.py index caaddec432..f6618a344c 100644 --- a/lms/djangoapps/courseware/features/smart-accordion.py +++ b/lms/djangoapps/courseware/features/smart-accordion.py @@ -74,7 +74,8 @@ def browse_course(course_id): rendered_sections = world.browser.find_by_css('#accordion > nav > div')[chapter_it].find_by_tag('li') num_rendered_sections = len(rendered_sections) - msg = '%d sections expected, %d sections found on page, %s - %d - %s' % (num_sections, num_rendered_sections, course_id, chapter_it, chapters[chapter_it]['chapter_name']) + msg = ('%d sections expected, %d sections found on page, %s - %d - %s' % + (num_sections, num_rendered_sections, course_id, chapter_it, chapters[chapter_it]['chapter_name'])) logger.debug(msg) assert num_sections == num_rendered_sections, msg @@ -104,10 +105,12 @@ def browse_course(course_id): rendered_tabs = 0 num_rendered_tabs = 0 - msg = '%d tabs expected, %d tabs found, %s - %d - %s' % (num_tabs, num_rendered_tabs, course_id, section_it, sections[section_it]['section_name']) + msg = ('%d tabs expected, %d tabs found, %s - %d - %s' % + (num_tabs, num_rendered_tabs, course_id, section_it, sections[section_it]['section_name'])) logger.debug(msg) assert num_tabs == num_rendered_tabs, msg + tabs = sections[section_it]['tabs'] tab_it = 0 ## Iterate the tabs @@ -116,7 +119,17 @@ def browse_course(course_id): rendered_tabs[tab_it].find_by_tag('a').click() ## do something with the tab sections[section_it] - check_for_errors() + # e = world.browser.find_by_css('section.course-content section') + # process_section(e) + tab_children = tabs[tab_it]['children_count'] + tab_class = tabs[tab_it]['class'] + if tab_children != 0: + rendered_items = world.browser.find_by_css('div#seq_content > section > ol > li > section') + num_rendered_items = len(rendered_items) + msg = ('%d items expected, %d items found, %s - %d - %s - tab %d' % + (tab_children, num_rendered_items, course_id, section_it, sections[section_it]['section_name'], tab_it)) + logger.debug(msg) + assert tab_children == num_rendered_items, msg tab_it += 1 From af4f1668d70ce35edfe2cc6df1e7079d3016927d Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Fri, 30 Nov 2012 12:20:35 -0500 Subject: [PATCH 05/59] Stub out the video and capture the html files of the high level tabs for diff comparison Conflicts: lms/templates/video.html --- .../courseware/features/courseware_common.py | 1 + lms/djangoapps/terrain/common.py | 10 ++++ lms/djangoapps/terrain/diff_html_files.py | 57 +++++++++++++++++++ lms/templates/video.html | 26 +++++---- 4 files changed, 82 insertions(+), 12 deletions(-) create mode 100755 lms/djangoapps/terrain/diff_html_files.py diff --git a/lms/djangoapps/courseware/features/courseware_common.py b/lms/djangoapps/courseware/features/courseware_common.py index f90c7c3ba1..8850c88fef 100644 --- a/lms/djangoapps/courseware/features/courseware_common.py +++ b/lms/djangoapps/courseware/features/courseware_common.py @@ -9,6 +9,7 @@ def i_click_on_view_courseware(step): @step('I click on the "([^"]*)" tab$') def i_click_on_the_tab(step, tab): world.browser.find_link_by_text(tab).first.click() + world.save_the_html() @step('I visit the courseware URL$') def i_visit_the_course_info_url(step): diff --git a/lms/djangoapps/terrain/common.py b/lms/djangoapps/terrain/common.py index 6d5ac83d24..7294ad2a6e 100644 --- a/lms/djangoapps/terrain/common.py +++ b/lms/djangoapps/terrain/common.py @@ -7,6 +7,7 @@ from django.conf import settings from django.contrib.auth.models import User from student.models import CourseEnrollment import time +from urllib import quote_plus from nose.tools import assert_equals from logging import getLogger @@ -99,6 +100,15 @@ def log_in(email, password): # wait for the page to redraw assert world.browser.is_element_present_by_css('.content-wrapper', 10) +@world.absorb +def save_the_html(path='/tmp'): + u = world.browser.url + html = world.browser.html.encode('ascii', 'ignore') + filename = '%s.html' % quote_plus(u) + f = open('%s/%s' % (path, filename), 'w') + f.write(html) + f.close + ########### DEBUGGING ############## @step(u'I save a screenshot to "(.*)"') def save_screenshot_to(step, filename): diff --git a/lms/djangoapps/terrain/diff_html_files.py b/lms/djangoapps/terrain/diff_html_files.py new file mode 100755 index 0000000000..ce57daca57 --- /dev/null +++ b/lms/djangoapps/terrain/diff_html_files.py @@ -0,0 +1,57 @@ +""" Command line interface to difflib.py to compare html files +""" + +import sys, os, time, difflib, optparse, os.path, re +from urllib import unquote_plus + +def main(): + # Configure the option parser + usage = "usage: %prog fromdir todir" + parser = optparse.OptionParser(usage) + (options, args) = parser.parse_args() + + if len(args) == 0: + parser.print_help() + sys.exit(1) + if len(args) != 2: + parser.error("need to specify both a fromdir and todir") + + fromdir, todir = args # as specified in the usage string + + if not os.path.isdir(fromdir): + print "'%s' is not a directory" % fromdir + + if not os.path.isdir(todir): + print "'%s' is not a directory" % todir + + from_files = os.listdir(fromdir) + to_files = os.listdir(todir) + + for filename in from_files: + if filename in to_files: + + fromfile = os.path.join(fromdir, filename) + tofile = os.path.join(todir, filename) + + # we're passing these as arguments to the diff function + # fromdate = time.ctime(os.stat(fromfile).st_mtime) + # todate = time.ctime(os.stat(tofile).st_mtime) + fromlines = cleanup(open(fromfile, 'U').readlines()) + tolines = cleanup(open(tofile, 'U').readlines()) + + diff = difflib.unified_diff(fromlines, tolines, fromdir, todir, n=0) + # fromdate, todate, n=0) + + print 'FILE: %s' % unquote_plus(filename) + # we're using writelines because diff is a generator + sys.stdout.writelines(diff) + print '' + +def cleanup(lines): + + lines = [s.replace('/c4x/MITx/6.002x/asset', '/static/content-mit-6002x') for s in lines] + lines = [s.replace('handouts_', 'handouts/') for s in lines] + return lines + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/lms/templates/video.html b/lms/templates/video.html index 6e45a91c31..0270ba102e 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -2,19 +2,21 @@

    ${display_name}

    % endif -
    -
    -
    -
    -
    -
    - -
    -
    +%if settings.MITX_FEATURES['DISPLAY_TOY_COURSES']: +
    +%else: +
    +
    +
    +
    +
    +
    +
    +
    +
    -
    +%endif + % if source:

    Download video here.

    From 20cc930312d0667838f226cdb30e53b74ed774db Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Fri, 30 Nov 2012 16:22:44 -0500 Subject: [PATCH 06/59] Bring over acceptance test changes from cms master branch. --- .../courseware/features/course_info.feature | 4 +- .../courseware/features/course_info.py | 1 - .../courseware/features/courseware.feature | 2 +- .../courseware/features/courseware.py | 7 ++ lms/djangoapps/portal/features/common.py | 99 +++++++++++++++++++ lms/djangoapps/portal/features/factories.py | 34 +++++++ lms/djangoapps/portal/features/login.feature | 2 +- lms/djangoapps/portal/features/signup.feature | 2 +- lms/envs/acceptance.py | 2 +- 9 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 lms/djangoapps/courseware/features/courseware.py create mode 100644 lms/djangoapps/portal/features/common.py create mode 100644 lms/djangoapps/portal/features/factories.py diff --git a/lms/djangoapps/courseware/features/course_info.feature b/lms/djangoapps/courseware/features/course_info.feature index 4a6a2daff6..f58dc9c5cc 100644 --- a/lms/djangoapps/courseware/features/course_info.feature +++ b/lms/djangoapps/courseware/features/course_info.feature @@ -12,10 +12,8 @@ Feature: View the Course Info tab And the Course Info tab is active And I do not see "! Info section missing !" anywhere on the page - # This test is currently failing - # see: https://www.pivotaltracker.com/projects/614553?classic=true#!/stories/38801223 Scenario: I cannot get to the course info tab when not logged in Given I am not logged in And I visit the homepage When I visit the course info URL - Then the login dialog is visible \ No newline at end of file + Then the login dialog is visible diff --git a/lms/djangoapps/courseware/features/course_info.py b/lms/djangoapps/courseware/features/course_info.py index 964a7af35f..60cd91cd88 100644 --- a/lms/djangoapps/courseware/features/course_info.py +++ b/lms/djangoapps/courseware/features/course_info.py @@ -1,6 +1,5 @@ from lettuce import world, step from lettuce.django import django_url -#from portal.common import * @step('I am on an info page') def i_am_on_an_info_page(step): diff --git a/lms/djangoapps/courseware/features/courseware.feature b/lms/djangoapps/courseware/features/courseware.feature index cddf7527ae..21c7e84541 100644 --- a/lms/djangoapps/courseware/features/courseware.feature +++ b/lms/djangoapps/courseware/features/courseware.feature @@ -15,4 +15,4 @@ Feature: View the Courseware Tab # Given I am not logged in # And I visit the homepage # When I visit the courseware URL - # Then the login dialog is visible \ No newline at end of file + # Then the login dialog is visible diff --git a/lms/djangoapps/courseware/features/courseware.py b/lms/djangoapps/courseware/features/courseware.py new file mode 100644 index 0000000000..05ecd63f4b --- /dev/null +++ b/lms/djangoapps/courseware/features/courseware.py @@ -0,0 +1,7 @@ +from lettuce import world, step +from lettuce.django import django_url + +@step('I visit the courseware URL$') +def i_visit_the_course_info_url(step): + url = django_url('/courses/MITx/6.002x/2012_Fall/courseware') + world.browser.visit(url) \ No newline at end of file diff --git a/lms/djangoapps/portal/features/common.py b/lms/djangoapps/portal/features/common.py new file mode 100644 index 0000000000..553f0da0a1 --- /dev/null +++ b/lms/djangoapps/portal/features/common.py @@ -0,0 +1,99 @@ +from lettuce import world, step#, before, after +from factories import * +from django.core.management import call_command +from salad.steps.everything import * +from lettuce.django import django_url +from django.conf import settings +from django.contrib.auth.models import User +from student.models import CourseEnrollment +import time + +from logging import getLogger +logger = getLogger(__name__) + +@step(u'I wait (?:for )?"(\d+)" seconds?$') +def wait(step, seconds): + time.sleep(float(seconds)) + +@step('I (?:visit|access|open) the homepage$') +def i_visit_the_homepage(step): + world.browser.visit(django_url('/')) + assert world.browser.is_element_present_by_css('header.global', 10) + +@step(u'I (?:visit|access|open) the dashboard$') +def i_visit_the_dashboard(step): + world.browser.visit(django_url('/dashboard')) + assert world.browser.is_element_present_by_css('section.container.dashboard', 5) + +@step('I should be on the dashboard page$') +def i_should_be_on_the_dashboard(step): + assert world.browser.is_element_present_by_css('section.container.dashboard', 5) + assert world.browser.title == 'Dashboard' + +@step(u'I (?:visit|access|open) the courses page$') +def i_am_on_the_courses_page(step): + world.browser.visit(django_url('/courses')) + assert world.browser.is_element_present_by_css('section.courses') + +@step('I should see that the path is "([^"]*)"$') +def i_should_see_that_the_path_is(step, path): + assert world.browser.url == django_url(path) + +@step(u'the page title should be "([^"]*)"$') +def the_page_title_should_be(step, title): + assert world.browser.title == title + +@step('I am logged in$') +def i_am_logged_in(step): + + # This is a workaround for now, until askbot is removed + # because askbot is messing with the user and auth tables. + call_command('loaddata', '/tmp/fixtures/robot_user_active.json') + world.browser.cookies.delete() + world.browser.visit(django_url('/')) + world.browser.is_element_present_by_css('header.global', 10) + world.browser.click_link_by_href('#login-modal') + login_form = world.browser.find_by_css('form#login_form') + login_form.find_by_name('email').fill('robot@edx.org') + login_form.find_by_name('password').fill('test') + login_form.find_by_name('submit').click() + + # wait for the page to redraw + assert world.browser.is_element_present_by_css('.content-wrapper', 5) + +@step('I am not logged in$') +def i_am_not_logged_in(step): + world.browser.cookies.delete() + +@step(u'I am registered for a course$') +def i_am_registered_for_a_course(step): + u = User.objects.get(username='robot2') + CourseEnrollment.objects.create(user=u, course_id='MITx/6.002x/2012_Fall') + +@step(u'I am an edX user$') +def i_am_an_edx_user(step): + call_command('loaddata', '/tmp/fixtures/robot_user_active.json') + +########### USER CREATION ############## +@step(u'User "([^"]*)" is an edX user$') +def registered_edx_user(step, uname): + # This user factory stuff should work after we kill askbot + # portal_user = UserFactory.build(username=uname, email=uname + '@edx.org') + # portal_user.set_password('test') + # portal_user.save() + + # registration = RegistrationFactory(user=portal_user) + # registration.register(portal_user) + # registration.activate() + + # user_profile = UserProfileFactory(user=portal_user) + + # This is a workaround for now, until askbot is removed + # because askbot is messing with the user and auth tables. + call_command('loaddata', '/tmp/fixtures/robot_user_active.json') + +########### DEBUGGING ############## +@step(u'I save a screenshot to "(.*)"') +def save_screenshot_to(step, filename): + world.browser.driver.save_screenshot(filename) + diff --git a/lms/djangoapps/portal/features/factories.py b/lms/djangoapps/portal/features/factories.py new file mode 100644 index 0000000000..07b615f468 --- /dev/null +++ b/lms/djangoapps/portal/features/factories.py @@ -0,0 +1,34 @@ +import factory +from student.models import User, UserProfile, Registration +from datetime import datetime +import uuid + +class UserProfileFactory(factory.Factory): + FACTORY_FOR = UserProfile + + user = None + name = 'Jack Foo' + level_of_education = None + gender = 'm' + mailing_address = None + goals = 'World domination' + +class RegistrationFactory(factory.Factory): + FACTORY_FOR = Registration + + user = None + activation_key = uuid.uuid4().hex + +class UserFactory(factory.Factory): + FACTORY_FOR = User + + username = 'robot' + email = 'robot+test@edx.org' + password = 'test' + first_name = 'Robot' + last_name = 'Test' + is_staff = False + is_active = True + is_superuser = False + last_login = datetime(2012, 1, 1) + date_joined = datetime(2011, 1, 1) diff --git a/lms/djangoapps/portal/features/login.feature b/lms/djangoapps/portal/features/login.feature index 3d8757bd32..2261dc2016 100644 --- a/lms/djangoapps/portal/features/login.feature +++ b/lms/djangoapps/portal/features/login.feature @@ -24,4 +24,4 @@ Feature: Login in as a registered user When I click the dropdown arrow And I click on the link with the text "Log Out" Then I should see a link with the text "Log In" - And I should see that the path is "/" \ No newline at end of file + And I should see that the path is "/" diff --git a/lms/djangoapps/portal/features/signup.feature b/lms/djangoapps/portal/features/signup.feature index 5529225670..b28a6819a1 100644 --- a/lms/djangoapps/portal/features/signup.feature +++ b/lms/djangoapps/portal/features/signup.feature @@ -13,4 +13,4 @@ Feature: Sign in And I check the checkbox named "terms_of_service" And I check the checkbox named "honor_code" And I press the "Create My Account" button on the registration form - Then I should see "THANKS FOR REGISTERING!" in the dashboard banner \ No newline at end of file + Then I should see "THANKS FOR REGISTERING!" in the dashboard banner diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index 474f22be28..bfcc1faaa3 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -17,4 +17,4 @@ MITX_FEATURES['DISPLAY_TOY_COURSES'] = True INSTALLED_APPS += ('lettuce.django',) -LETTUCE_APPS = ('portal',) # dummy app covers the home page, login, registration, and course enrollment \ No newline at end of file +LETTUCE_APPS = ('portal',) # dummy app covers the home page, login, registration, and course enrollment From 5cad5d5fbae4b6129eb01b236fe276b8b7e931a6 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Mon, 3 Dec 2012 12:46:40 -0500 Subject: [PATCH 07/59] Add scenarios for all Fall 2012 courses to smart accordion feature. --- .../features/smart-accordion.feature | 62 ++++++++++++++++--- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/lms/djangoapps/courseware/features/smart-accordion.feature b/lms/djangoapps/courseware/features/smart-accordion.feature index ebfe588c6c..447649175e 100644 --- a/lms/djangoapps/courseware/features/smart-accordion.feature +++ b/lms/djangoapps/courseware/features/smart-accordion.feature @@ -1,19 +1,65 @@ +# Here are all the courses for Fall 2012 +# MITx/3.091x/2012_Fall +# MITx/6.002x/2012_Fall +# MITx/6.00x/2012_Fall +# HarvardX/CS50x/2012 +# HarvardX/PH207x/2012_Fall +# BerkeleyX/CS169.1x/2012_Fall +# BerkeleyX/CS169.2x/2012_Fall +# BerkeleyX/CS184.1x/2012_Fall + +#You can load the courses into your data directory with these cmds: +# git clone https://github.com/MITx/3.091x.git +# git clone https://github.com/MITx/6.00x.git +# git clone https://github.com/MITx/content-mit-6002x.git +# git clone https://github.com/MITx/content-mit-6002x.git +# git clone https://github.com/MITx/cs50.git +# git clone https://github.com/MITx/content-harvard-id270x.git +# git clone https://github.com/MITx/content-berkeley-cs169x.git +# git clone https://github.com/MITx/content-berkeley-cs169.2x.git +# git clone https://github.com/MITx/content-berkeley-cs184x.git + Feature: There are courses on the homepage In order to compared rendered content to the database As an acceptance test I want to count all the chapters, sections, and tabs for each course + Scenario: Navigate through course MITx/3.091x/2012_Fall + Given I am registered for course "MITx/3.091x/2012_Fall" + And I log in + Then I verify all the content of each course + Scenario: Navigate through course MITx/6.002x/2012_Fall Given I am registered for course "MITx/6.002x/2012_Fall" And I log in Then I verify all the content of each course - # Scenario: Navigate through course edX/edx101/edX_Studio_Reference - # Given I am registered for course "edX/edx101/edX_Studio_Reference" - # And I log in - # Then I verify all the content of each course + Scenario: Navigate through course MITx/6.00x/2012_Fall + Given I am registered for course "MITx/6.00x/2012_Fall" + And I log in + Then I verify all the content of each course - # Scenario: Navigate through course BerkeleyX/CS169.1x/2012_Fall - # Given I am registered for course "BerkeleyX/CS169.1x/2012_Fall" - # And I log in - # Then I verify all the content of each course \ No newline at end of file + Scenario: Navigate through course HarvardX/CS50x/2012 + Given I am registered for course "HarvardX/CS50x/2012" + And I log in + Then I verify all the content of each course + + Scenario: Navigate through course HarvardX/PH207x/2012_Fall + Given I am registered for course "HarvardX/PH207x/2012_Fall" + And I log in + Then I verify all the content of each course + + Scenario: Navigate through course BerkeleyX/CS169.1x/2012_Fall + Given I am registered for course "BerkeleyX/CS169.1x/2012_Fall" + And I log in + Then I verify all the content of each course + + Scenario: Navigate through course BerkeleyX/CS169.2x/2012_Fall + Given I am registered for course "BerkeleyX/CS169.2x/2012_Fall" + And I log in + Then I verify all the content of each course + + Scenario: Navigate through course BerkeleyX/CS184.1x/2012_Fall + Given I am registered for course "BerkeleyX/CS184.1x/2012_Fall" + And I log in + Then I verify all the content of each course \ No newline at end of file From 8934f54d6ffbeeb1608184108d89285c3ac63a8c Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Mon, 3 Dec 2012 13:17:54 -0500 Subject: [PATCH 08/59] Save the HTML to a file for each section and comment out debug msgs --- .../courseware/features/smart-accordion.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/courseware/features/smart-accordion.py b/lms/djangoapps/courseware/features/smart-accordion.py index f6618a344c..89a852453f 100644 --- a/lms/djangoapps/courseware/features/smart-accordion.py +++ b/lms/djangoapps/courseware/features/smart-accordion.py @@ -53,7 +53,7 @@ def browse_course(course_id): num_rendered_chapters = len(rendered_chapters) msg = '%d chapters expected, %d chapters found on page for %s' % (num_chapters, num_rendered_chapters, course_id) - logger.debug(msg) + #logger.debug(msg) assert num_chapters == num_rendered_chapters, msg chapter_it = 0 @@ -76,7 +76,7 @@ def browse_course(course_id): msg = ('%d sections expected, %d sections found on page, %s - %d - %s' % (num_sections, num_rendered_sections, course_id, chapter_it, chapters[chapter_it]['chapter_name'])) - logger.debug(msg) + #logger.debug(msg) assert num_sections == num_rendered_sections, msg section_it = 0 @@ -107,7 +107,11 @@ def browse_course(course_id): msg = ('%d tabs expected, %d tabs found, %s - %d - %s' % (num_tabs, num_rendered_tabs, course_id, section_it, sections[section_it]['section_name'])) - logger.debug(msg) + #logger.debug(msg) + + # Save the HTML to a file for later comparison + world.save_the_html() + assert num_tabs == num_rendered_tabs, msg tabs = sections[section_it]['tabs'] @@ -128,7 +132,7 @@ def browse_course(course_id): num_rendered_items = len(rendered_items) msg = ('%d items expected, %d items found, %s - %d - %s - tab %d' % (tab_children, num_rendered_items, course_id, section_it, sections[section_it]['section_name'], tab_it)) - logger.debug(msg) + #logger.debug(msg) assert tab_children == num_rendered_items, msg tab_it += 1 From d65985b44512ef39bedc0f0d14928fc5dca78b9a Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Tue, 4 Dec 2012 12:09:50 -0500 Subject: [PATCH 09/59] Use beautiful soup to massage the HTML for easier comparison. Strip off the data-id tag for HTML diff of courseware Strip data-id attributes so they are not compared in the HTML --- .../courseware/features/smart-accordion.py | 7 ++- lms/djangoapps/terrain/common.py | 47 ++++++++++++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/courseware/features/smart-accordion.py b/lms/djangoapps/courseware/features/smart-accordion.py index 89a852453f..95d3396f57 100644 --- a/lms/djangoapps/courseware/features/smart-accordion.py +++ b/lms/djangoapps/courseware/features/smart-accordion.py @@ -17,6 +17,9 @@ def check_for_errors(): @step(u'I verify all the content of each course') def i_verify_all_the_content_of_each_course(step): all_possible_courses = get_courses() + logger.debug('Courses found:') + for c in all_possible_courses: + logger.debug(c.id) ids = [c.id for c in all_possible_courses] # Get a list of all the registered courses @@ -110,7 +113,7 @@ def browse_course(course_id): #logger.debug(msg) # Save the HTML to a file for later comparison - world.save_the_html() + world.save_the_course_content('/tmp/%s' % course_id) assert num_tabs == num_rendered_tabs, msg @@ -146,4 +149,4 @@ def validate_course(current_course, ids): try: ids.index(current_course) except: - assert False, "invalid course id" + assert False, "invalid course id %s" % current_course diff --git a/lms/djangoapps/terrain/common.py b/lms/djangoapps/terrain/common.py index 7294ad2a6e..2224bb1b9e 100644 --- a/lms/djangoapps/terrain/common.py +++ b/lms/djangoapps/terrain/common.py @@ -6,9 +6,12 @@ from lettuce.django import django_url from django.conf import settings from django.contrib.auth.models import User from student.models import CourseEnrollment -import time from urllib import quote_plus from nose.tools import assert_equals +from bs4 import BeautifulSoup +import time +import re +import os.path from logging import getLogger logger = getLogger(__name__) @@ -109,6 +112,48 @@ def save_the_html(path='/tmp'): f.write(html) f.close +@world.absorb +def save_the_course_content(path='/tmp'): + html = world.browser.html.encode('ascii', 'ignore') + soup = BeautifulSoup(html) + + # get rid of the header, we only want to compare the body + # soup.head.decompose() + + # for now, remove the data-id attributes, because they are + # causing mismatches between cms-master and master + for item in soup.find_all(attrs={'data-id': re.compile('.*')}): + del item['data-id'] + + # we also need to remove them from unrendered problems, + # where they are contained in the text of divs instead of + # in attributes of tags + # Be careful of whether or not it was the last attribute + # and needs a trailing space + for item in soup.find_all(text=re.compile(' data-id=".*?" ')): + s = unicode(item.string) + item.string.replace_with(re.sub(' data-id=".*?" ', ' ', s)) + + for item in soup.find_all(text=re.compile(' data-id=".*?"')): + s = unicode(item.string) + item.string.replace_with(re.sub(' data-id=".*?"', ' ', s)) + + # prettify the html so it will compare better, with + # each HTML tag on its own line + output = soup.prettify() + + # use string slicing to grab everything after 'courseware/' in the URL + u = world.browser.url + section_url = u[u.find('courseware/')+11:] + + if not os.path.exists(path): + os.makedirs(path) + + filename = '%s.html' % (quote_plus(section_url)) + f = open('%s/%s' % (path, filename), 'w') + f.write(output) + f.close + ########### DEBUGGING ############## @step(u'I save a screenshot to "(.*)"') def save_screenshot_to(step, filename): From 6df565e468970c9422a61c28651869299cbefc3f Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Mon, 10 Dec 2012 16:48:26 -0500 Subject: [PATCH 10/59] Interim work on open-ended grading acceptance testing POC --- .../courseware/features/openended.feature | 21 +++++++++ .../courseware/features/openended.py | 43 +++++++++++++++++++ lms/djangoapps/terrain/common.py | 13 ++++++ lms/envs/acceptance.py | 10 +++++ 4 files changed, 87 insertions(+) create mode 100644 lms/djangoapps/courseware/features/openended.feature create mode 100644 lms/djangoapps/courseware/features/openended.py diff --git a/lms/djangoapps/courseware/features/openended.feature b/lms/djangoapps/courseware/features/openended.feature new file mode 100644 index 0000000000..4b6b684349 --- /dev/null +++ b/lms/djangoapps/courseware/features/openended.feature @@ -0,0 +1,21 @@ +Feature: Open ended grading + As a student in an edX course + In order to complete the courseware questions + I want the machine learning grading to be functional + + Scenario: I can submit an answer for instructor grading + Given I am registered for course "MITx/3.091x/2012_Fall" + And I log in + And I navigate to an openended question + When I enter the answer "I have no idea." + And I press the "Check" button + Then I see the grader status "Submitted for grading" + And I see the grader message "Feedback not yet available." + + Scenario: I can submit an answer for instructor grading + Given I am staff for course "MITx/3.091x/2012_Fall" + And I log in + And I navigate to an openended question + When I submit the answer "I love Chemistry." + And I visit the staff grading page + Then my answer is queued for instructor grading \ No newline at end of file diff --git a/lms/djangoapps/courseware/features/openended.py b/lms/djangoapps/courseware/features/openended.py new file mode 100644 index 0000000000..629318fc40 --- /dev/null +++ b/lms/djangoapps/courseware/features/openended.py @@ -0,0 +1,43 @@ +from lettuce import world, step +from lettuce.django import django_url +from nose.tools import assert_equals, assert_in + +@step('I navigate to an openended question$') +def navigate_to_an_openended_question(step): + problem = '/courses/MITx/3.091x/2012_Fall/courseware/Week_10/Polymer_Synthesis/' + world.browser.visit(django_url(problem)) + tab_css = 'ol#sequence-list > li > a[data-element="5"]' + world.browser.find_by_css(tab_css).click() + +@step(u'I enter the answer "([^"]*)"') +def enter_the_answer_text(step, text): + textarea_css = 'textarea' + world.browser.find_by_css(textarea_css).first.fill(text) + +@step(u'I see the grader message "([^"]*)"$') +def see_grader_message(step, msg): + message_css = 'div.external-grader-message' + grader_msg = world.browser.find_by_css(message_css).text + assert_in(msg, grader_msg) + +@step(u'I see the grader status "([^"]*)"') +def see_the_grader_status(step, status): + status_css = 'div.grader-status' + grader_status = world.browser.find_by_css(status_css).text + assert_equals(status, grader_status) + +@step(u'I submit the answer "([^"]*)"$') +def i_submit_the_answer_text(step, text): + textarea_css = 'textarea' + world.browser.find_by_css(textarea_css).first.fill(text) + check_css = 'input.check' + world.browser.find_by_css(check_css).click() + +@step(u'I visit the staff grading page$') +def i_visit_the_staff_grading_page(step): + course_u = '/courses/MITx/3.091x/2012_Fall' + world.browser.visit(django_url('%s/staff_grading' % course_u)) + +@step(u'my answer is queued for instructor grading') +def answer_is_queued_for_instructor_grading(step): + assert False, 'This step must be implemented' \ No newline at end of file diff --git a/lms/djangoapps/terrain/common.py b/lms/djangoapps/terrain/common.py index 2224bb1b9e..f7393da471 100644 --- a/lms/djangoapps/terrain/common.py +++ b/lms/djangoapps/terrain/common.py @@ -40,6 +40,11 @@ def i_am_on_the_courses_page(step): world.browser.visit(django_url('/courses')) assert world.browser.is_element_present_by_css('section.courses') +@step(u'I press the "([^"]*)" button$') +def and_i_press_the_button(step, value): + button_css = 'input[value="%s"]' % value + world.browser.find_by_css(button_css).first.click() + @step('I should see that the path is "([^"]*)"$') def i_should_see_that_the_path_is(step, path): assert world.browser.url == django_url(path) @@ -69,6 +74,14 @@ def i_am_registered_for_course_by_id(step, course_id): u = User.objects.get(username='robot') CourseEnrollment.objects.get_or_create(user=u, course_id=course_id) +@step('I am staff for course "([^"]*)"$') +def i_am_staff_for_course_by_id(step, course_id): + create_user('robot') + u = User.objects.get(username='robot') + u.is_staff=True + u.save() + CourseEnrollment.objects.get_or_create(user=u, course_id=course_id) + @step('I log in$') def i_log_in(step): log_in('robot@edx.org','test') diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index bfcc1faaa3..73d380528f 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -11,6 +11,16 @@ INSTALLED_APPS = tuple(e for e in INSTALLED_APPS if e != 'debug_toolbar') MIDDLEWARE_CLASSES = tuple(e for e in MIDDLEWARE_CLASSES \ if e != 'debug_toolbar.middleware.DebugToolbarMiddleware') +########################### OPEN GRADING TESTING ########################## +XQUEUE_INTERFACE = { + "url": 'http://127.0.0.1:3032', + "django_auth": { + "username": "lms", + "password": "abcd" + }, + "basic_auth": ('anant', 'agarwal'), +} + ########################### LETTUCE TESTING ########################## MITX_FEATURES['DISPLAY_TOY_COURSES'] = True From 7f6d55ad86722fb1d96dd63586d6dadb5d5b48a7 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Tue, 11 Dec 2012 17:16:15 -0500 Subject: [PATCH 11/59] Create acceptance tests for open ended grading. --- .../courseware/features/openended.feature | 36 ++++++--- .../courseware/features/openended.py | 80 +++++++++++++++---- lms/djangoapps/terrain/common.py | 20 +++-- 3 files changed, 99 insertions(+), 37 deletions(-) diff --git a/lms/djangoapps/courseware/features/openended.feature b/lms/djangoapps/courseware/features/openended.feature index 4b6b684349..3c7043ba54 100644 --- a/lms/djangoapps/courseware/features/openended.feature +++ b/lms/djangoapps/courseware/features/openended.feature @@ -3,19 +3,31 @@ Feature: Open ended grading In order to complete the courseware questions I want the machine learning grading to be functional - Scenario: I can submit an answer for instructor grading - Given I am registered for course "MITx/3.091x/2012_Fall" - And I log in - And I navigate to an openended question - When I enter the answer "I have no idea." - And I press the "Check" button - Then I see the grader status "Submitted for grading" - And I see the grader message "Feedback not yet available." + Scenario: An answer that is too short is rejected + Given I navigate to an openended question + And I enter the answer "z" + When I press the "Check" button + And I wait for "8" seconds + And I see the grader status "Submitted for grading" + And I press the "Recheck for Feedback" button + Then I see the red X + And I see the grader score "0" - Scenario: I can submit an answer for instructor grading - Given I am staff for course "MITx/3.091x/2012_Fall" - And I log in - And I navigate to an openended question + Scenario: An answer with too many spelling errors is rejected + Given I navigate to an openended question + And I enter the answer "az" + When I press the "Check" button + And I wait for "8" seconds + And I see the grader status "Submitted for grading" + And I press the "Recheck for Feedback" button + Then I see the red X + And I see the grader score "0" + When I click the link for full output + Then I see the spelling grading message "More spelling errors than average." + + Scenario: An answer makes its way to the instructor dashboard + Given I navigate to an openended question as staff When I submit the answer "I love Chemistry." + And I wait for "8" seconds And I visit the staff grading page Then my answer is queued for instructor grading \ No newline at end of file diff --git a/lms/djangoapps/courseware/features/openended.py b/lms/djangoapps/courseware/features/openended.py index 629318fc40..d37f9a0fae 100644 --- a/lms/djangoapps/courseware/features/openended.py +++ b/lms/djangoapps/courseware/features/openended.py @@ -1,31 +1,32 @@ from lettuce import world, step from lettuce.django import django_url from nose.tools import assert_equals, assert_in +from logging import getLogger +logger = getLogger(__name__) @step('I navigate to an openended question$') def navigate_to_an_openended_question(step): + world.register_by_course_id('MITx/3.091x/2012_Fall') + world.log_in('robot@edx.org','test') problem = '/courses/MITx/3.091x/2012_Fall/courseware/Week_10/Polymer_Synthesis/' world.browser.visit(django_url(problem)) tab_css = 'ol#sequence-list > li > a[data-element="5"]' world.browser.find_by_css(tab_css).click() -@step(u'I enter the answer "([^"]*)"') +@step('I navigate to an openended question as staff$') +def navigate_to_an_openended_question_as_staff(step): + world.register_by_course_id('MITx/3.091x/2012_Fall', True) + world.log_in('robot@edx.org','test') + problem = '/courses/MITx/3.091x/2012_Fall/courseware/Week_10/Polymer_Synthesis/' + world.browser.visit(django_url(problem)) + tab_css = 'ol#sequence-list > li > a[data-element="5"]' + world.browser.find_by_css(tab_css).click() + +@step(u'I enter the answer "([^"]*)"$') def enter_the_answer_text(step, text): textarea_css = 'textarea' world.browser.find_by_css(textarea_css).first.fill(text) -@step(u'I see the grader message "([^"]*)"$') -def see_grader_message(step, msg): - message_css = 'div.external-grader-message' - grader_msg = world.browser.find_by_css(message_css).text - assert_in(msg, grader_msg) - -@step(u'I see the grader status "([^"]*)"') -def see_the_grader_status(step, status): - status_css = 'div.grader-status' - grader_status = world.browser.find_by_css(status_css).text - assert_equals(status, grader_status) - @step(u'I submit the answer "([^"]*)"$') def i_submit_the_answer_text(step, text): textarea_css = 'textarea' @@ -33,11 +34,56 @@ def i_submit_the_answer_text(step, text): check_css = 'input.check' world.browser.find_by_css(check_css).click() +@step('I click the link for full output$') +def click_full_output_link(step): + link_css = 'a.full' + world.browser.find_by_css(link_css).first.click() + @step(u'I visit the staff grading page$') def i_visit_the_staff_grading_page(step): - course_u = '/courses/MITx/3.091x/2012_Fall' - world.browser.visit(django_url('%s/staff_grading' % course_u)) + # course_u = '/courses/MITx/3.091x/2012_Fall' + # sg_url = '%s/staff_grading' % course_u + world.browser.click_link_by_text('Instructor') + world.browser.click_link_by_text('Staff grading') + # world.browser.visit(django_url(sg_url)) -@step(u'my answer is queued for instructor grading') +@step(u'I see the grader message "([^"]*)"$') +def see_grader_message(step, msg): + message_css = 'div.external-grader-message' + grader_msg = world.browser.find_by_css(message_css).text + assert_in(msg, grader_msg) + +@step(u'I see the grader status "([^"]*)"$') +def see_the_grader_status(step, status): + status_css = 'div.grader-status' + grader_status = world.browser.find_by_css(status_css).text + assert_equals(status, grader_status) + +@step('I see the red X$') +def see_the_red_x(step): + x_css = 'div.grader-status > span.incorrect' + assert world.browser.find_by_css(x_css) + +@step(u'I see the grader score "([^"]*)"$') +def see_the_grader_score(step, score): + score_css = 'div.result-output > p' + score_text = world.browser.find_by_css(score_css).text + assert_equals(score_text, 'Score: %s' % score) + +@step('I see the link for full output$') +def see_full_output_link(step): + link_css = 'a.full' + assert world.browser.find_by_css(link_css) + +@step('I see the spelling grading message "([^"]*)"$') +def see_spelling_msg(step, msg): + spelling_css = 'div.spelling' + spelling_msg = world.browser.find_by_css(spelling_css).text + assert_equals('Spelling: %s' % msg, spelling_msg) + +@step(u'my answer is queued for instructor grading$') def answer_is_queued_for_instructor_grading(step): - assert False, 'This step must be implemented' \ No newline at end of file + list_css = 'ul.problem-list > li > a' + actual_msg = world.browser.find_by_css(list_css).text + expected_msg = "(0 graded, 1 pending)" + assert_in(expected_msg, actual_msg) diff --git a/lms/djangoapps/terrain/common.py b/lms/djangoapps/terrain/common.py index f7393da471..36de93debc 100644 --- a/lms/djangoapps/terrain/common.py +++ b/lms/djangoapps/terrain/common.py @@ -70,17 +70,11 @@ def i_am_registered_for_a_course(step): @step('I am registered for course "([^"]*)"$') def i_am_registered_for_course_by_id(step, course_id): - create_user('robot') - u = User.objects.get(username='robot') - CourseEnrollment.objects.get_or_create(user=u, course_id=course_id) + register_by_course_id(course_id) @step('I am staff for course "([^"]*)"$') def i_am_staff_for_course_by_id(step, course_id): - create_user('robot') - u = User.objects.get(username='robot') - u.is_staff=True - u.save() - CourseEnrollment.objects.get_or_create(user=u, course_id=course_id) + register_by_course_id(course_id, True) @step('I log in$') def i_log_in(step): @@ -103,6 +97,7 @@ def create_user(uname): user_profile = UserProfileFactory(user=portal_user) +@world.absorb def log_in(email, password): world.browser.cookies.delete() world.browser.visit(django_url('/')) @@ -116,6 +111,15 @@ def log_in(email, password): # wait for the page to redraw assert world.browser.is_element_present_by_css('.content-wrapper', 10) +@world.absorb +def register_by_course_id(course_id, is_staff=False): + create_user('robot') + u = User.objects.get(username='robot') + if is_staff: + u.is_staff=True + u.save() + CourseEnrollment.objects.get_or_create(user=u, course_id=course_id) + @world.absorb def save_the_html(path='/tmp'): u = world.browser.url From 7e5a67badf3e25fc2130ee020b292a406a20a71b Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Fri, 14 Dec 2012 16:41:44 -0500 Subject: [PATCH 12/59] Add new partner universities and remove cs50 from smart accordion testing --- .../courseware/features/smart-accordion.feature | 8 +------- lms/djangoapps/portal/features/homepage.feature | 12 +++++++----- lms/djangoapps/portal/features/homepage.py | 5 ++++- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/lms/djangoapps/courseware/features/smart-accordion.feature b/lms/djangoapps/courseware/features/smart-accordion.feature index 447649175e..90d097144a 100644 --- a/lms/djangoapps/courseware/features/smart-accordion.feature +++ b/lms/djangoapps/courseware/features/smart-accordion.feature @@ -2,7 +2,7 @@ # MITx/3.091x/2012_Fall # MITx/6.002x/2012_Fall # MITx/6.00x/2012_Fall -# HarvardX/CS50x/2012 +# HarvardX/CS50x/2012 (we will not be testing this, as it is anomolistic) # HarvardX/PH207x/2012_Fall # BerkeleyX/CS169.1x/2012_Fall # BerkeleyX/CS169.2x/2012_Fall @@ -13,7 +13,6 @@ # git clone https://github.com/MITx/6.00x.git # git clone https://github.com/MITx/content-mit-6002x.git # git clone https://github.com/MITx/content-mit-6002x.git -# git clone https://github.com/MITx/cs50.git # git clone https://github.com/MITx/content-harvard-id270x.git # git clone https://github.com/MITx/content-berkeley-cs169x.git # git clone https://github.com/MITx/content-berkeley-cs169.2x.git @@ -39,11 +38,6 @@ Feature: There are courses on the homepage And I log in Then I verify all the content of each course - Scenario: Navigate through course HarvardX/CS50x/2012 - Given I am registered for course "HarvardX/CS50x/2012" - And I log in - Then I verify all the content of each course - Scenario: Navigate through course HarvardX/PH207x/2012_Fall Given I am registered for course "HarvardX/PH207x/2012_Fall" And I log in diff --git a/lms/djangoapps/portal/features/homepage.feature b/lms/djangoapps/portal/features/homepage.feature index fa416a598f..06a45c4bfa 100644 --- a/lms/djangoapps/portal/features/homepage.feature +++ b/lms/djangoapps/portal/features/homepage.feature @@ -35,11 +35,13 @@ Feature: Homepage for web users Then I should see "" in the Partners section Examples: - | Partner | - | MITx | - | HarvardX | - | BerkeleyX | - | UTx | + | Partner | + | MITx | + | HarvardX | + | BerkeleyX | + | UTx | + | WellesleyX | + | GeorgetownX | # # TODO: Add scenario that tests the courses available # # using a policy or a configuration file diff --git a/lms/djangoapps/portal/features/homepage.py b/lms/djangoapps/portal/features/homepage.py index 9170f9c844..638d65077c 100644 --- a/lms/djangoapps/portal/features/homepage.py +++ b/lms/djangoapps/portal/features/homepage.py @@ -1,5 +1,8 @@ from lettuce import world, step +from nose.tools import assert_in @step('I should see "([^"]*)" in the Partners section$') def i_should_see_partner(step, partner): - assert (partner in world.browser.find_by_css(".partners").text) + partners = world.browser.find_by_css(".partner .name span") + names = set(span.text for span in partners) + assert_in(partner, names) From ccc8b599f11a48c544f01c641164ae73a7b0f04e Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Fri, 14 Dec 2012 17:16:29 -0500 Subject: [PATCH 13/59] Fixed tests to work OK so far. --- .../courseware/features/course_info.feature | 19 -------- .../courseware/features/course_info.py | 14 ------ lms/djangoapps/portal/features/common.py | 47 ++++--------------- .../portal/features/registration.feature | 3 +- lms/djangoapps/terrain/common.py | 5 +- 5 files changed, 10 insertions(+), 78 deletions(-) delete mode 100644 lms/djangoapps/courseware/features/course_info.feature delete mode 100644 lms/djangoapps/courseware/features/course_info.py diff --git a/lms/djangoapps/courseware/features/course_info.feature b/lms/djangoapps/courseware/features/course_info.feature deleted file mode 100644 index f58dc9c5cc..0000000000 --- a/lms/djangoapps/courseware/features/course_info.feature +++ /dev/null @@ -1,19 +0,0 @@ -Feature: View the Course Info tab - As a student in an edX course - In order to get background on the course - I want to view the info on the course info tab - - Scenario: I can get to the course info tab when logged in - Given I am logged in - And I am registered for a course - And I visit the dashboard - When I click on View Courseware - Then I am on an info page - And the Course Info tab is active - And I do not see "! Info section missing !" anywhere on the page - - Scenario: I cannot get to the course info tab when not logged in - Given I am not logged in - And I visit the homepage - When I visit the course info URL - Then the login dialog is visible diff --git a/lms/djangoapps/courseware/features/course_info.py b/lms/djangoapps/courseware/features/course_info.py deleted file mode 100644 index 60cd91cd88..0000000000 --- a/lms/djangoapps/courseware/features/course_info.py +++ /dev/null @@ -1,14 +0,0 @@ -from lettuce import world, step -from lettuce.django import django_url - -@step('I am on an info page') -def i_am_on_an_info_page(step): - title = world.browser.title - url = world.browser.url - assert ('Course Info' in title) - assert (r'/info' in url) - -@step('I visit the course info URL$') -def i_visit_the_course_info_url(step): - url = django_url('/courses/MITx/6.002x/2012_Fall/info') - world.browser.visit(url) diff --git a/lms/djangoapps/portal/features/common.py b/lms/djangoapps/portal/features/common.py index 553f0da0a1..2f379acaa4 100644 --- a/lms/djangoapps/portal/features/common.py +++ b/lms/djangoapps/portal/features/common.py @@ -45,21 +45,8 @@ def the_page_title_should_be(step, title): @step('I am logged in$') def i_am_logged_in(step): - - # This is a workaround for now, until askbot is removed - # because askbot is messing with the user and auth tables. - call_command('loaddata', '/tmp/fixtures/robot_user_active.json') - world.browser.cookies.delete() - world.browser.visit(django_url('/')) - world.browser.is_element_present_by_css('header.global', 10) - world.browser.click_link_by_href('#login-modal') - login_form = world.browser.find_by_css('form#login_form') - login_form.find_by_name('email').fill('robot@edx.org') - login_form.find_by_name('password').fill('test') - login_form.find_by_name('submit').click() - - # wait for the page to redraw - assert world.browser.is_element_present_by_css('.content-wrapper', 5) + world.create_user('robot') + world.log_in('robot@edx.org', 'test') @step('I am not logged in$') def i_am_not_logged_in(step): @@ -67,33 +54,15 @@ def i_am_not_logged_in(step): @step(u'I am registered for a course$') def i_am_registered_for_a_course(step): - u = User.objects.get(username='robot2') + world.create_user('robot') + u = User.objects.get(username='robot') CourseEnrollment.objects.create(user=u, course_id='MITx/6.002x/2012_Fall') - + world.log_in('robot@edx.org', 'test') + @step(u'I am an edX user$') def i_am_an_edx_user(step): - call_command('loaddata', '/tmp/fixtures/robot_user_active.json') + world.create_user('robot') -########### USER CREATION ############## @step(u'User "([^"]*)" is an edX user$') def registered_edx_user(step, uname): - # This user factory stuff should work after we kill askbot - # portal_user = UserFactory.build(username=uname, email=uname + '@edx.org') - # portal_user.set_password('test') - # portal_user.save() - - # registration = RegistrationFactory(user=portal_user) - # registration.register(portal_user) - # registration.activate() - - # user_profile = UserProfileFactory(user=portal_user) - - # This is a workaround for now, until askbot is removed - # because askbot is messing with the user and auth tables. - call_command('loaddata', '/tmp/fixtures/robot_user_active.json') - -########### DEBUGGING ############## -@step(u'I save a screenshot to "(.*)"') -def save_screenshot_to(step, filename): - world.browser.driver.save_screenshot(filename) - + world.create_user(uname) diff --git a/lms/djangoapps/portal/features/registration.feature b/lms/djangoapps/portal/features/registration.feature index ad226c479b..d8a6796ee3 100644 --- a/lms/djangoapps/portal/features/registration.feature +++ b/lms/djangoapps/portal/features/registration.feature @@ -10,8 +10,7 @@ Feature: Register for a course Then I should see the course numbered "6.002x" in my dashboard Scenario: I can unregister for a course - Given I am logged in - And I am registered for a course + Given I am registered for a course And I visit the dashboard When I click the link with the text "Unregister" And I press the "Unregister" button in the Unenroll dialog diff --git a/lms/djangoapps/terrain/common.py b/lms/djangoapps/terrain/common.py index 36de93debc..bd95edb65d 100644 --- a/lms/djangoapps/terrain/common.py +++ b/lms/djangoapps/terrain/common.py @@ -85,6 +85,7 @@ def i_am_an_edx_user(step): create_user('robot') #### helper functions +@world.absorb def create_user(uname): # This user factory stuff should work after we kill askbot portal_user = UserFactory.build(username=uname, email=uname + '@edx.org') @@ -171,7 +172,3 @@ def save_the_course_content(path='/tmp'): f.write(output) f.close -########### DEBUGGING ############## -@step(u'I save a screenshot to "(.*)"') -def save_screenshot_to(step, filename): - world.browser.driver.save_screenshot(filename) From d5111b9ed9056418b330a2afc58128b1b6615a9b Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Tue, 18 Dec 2012 16:20:23 -0500 Subject: [PATCH 14/59] Get lettuce tests running against test database --- lms/djangoapps/portal/README.md | 5 +- lms/djangoapps/portal/features/common.py | 18 ++++- lms/djangoapps/portal/features/login.feature | 6 +- lms/djangoapps/portal/features/login.py | 1 - lms/djangoapps/terrain/browser.py | 83 +++++--------------- lms/djangoapps/terrain/common.py | 7 +- lms/djangoapps/terrain/diff_html_files.py | 57 -------------- lms/envs/acceptance.py | 39 +++++---- test-requirements.txt | 1 - 9 files changed, 61 insertions(+), 156 deletions(-) delete mode 100755 lms/djangoapps/terrain/diff_html_files.py diff --git a/lms/djangoapps/portal/README.md b/lms/djangoapps/portal/README.md index b4da273124..de08758d88 100644 --- a/lms/djangoapps/portal/README.md +++ b/lms/djangoapps/portal/README.md @@ -1,13 +1,12 @@ ## acceptance_testing This fake django app is here to support acceptance testing using lettuce + -salad -(which uses splinter wrapping selenium). +splinter (which wraps selenium). Some documentation for our efforts are located in basecamp at this link. First you need to make sure that you've installed the requirements. -This includes lettuce, salad, selenium, splinter, etc. +This includes lettuce, selenium, splinter, etc. Do this with: ```pip install -r test-requirements.txt``` diff --git a/lms/djangoapps/portal/features/common.py b/lms/djangoapps/portal/features/common.py index 2f379acaa4..20c2ab56b8 100644 --- a/lms/djangoapps/portal/features/common.py +++ b/lms/djangoapps/portal/features/common.py @@ -1,7 +1,7 @@ from lettuce import world, step#, before, after from factories import * from django.core.management import call_command -from salad.steps.everything import * +from nose.tools import assert_equals, assert_in from lettuce.django import django_url from django.conf import settings from django.contrib.auth.models import User @@ -25,6 +25,10 @@ def i_visit_the_dashboard(step): world.browser.visit(django_url('/dashboard')) assert world.browser.is_element_present_by_css('section.container.dashboard', 5) +@step(r'click (?:the|a) link (?:called|with the text) "([^"]*)"$') +def click_the_link_called(step, text): + world.browser.find_link_by_text(text).click() + @step('I should be on the dashboard page$') def i_should_be_on_the_dashboard(step): assert world.browser.is_element_present_by_css('section.container.dashboard', 5) @@ -43,6 +47,18 @@ def i_should_see_that_the_path_is(step, path): def the_page_title_should_be(step, title): assert world.browser.title == title +@step(r'should see that the url is "([^"]*)"$') +def should_have_the_url(step, url): + assert_equals(world.browser.url, url) + +@step(r'should see (?:the|a) link (?:called|with the text) "([^"]*)"$') +def should_see_a_link_called(step, text): + assert len(world.browser.find_link_by_text(text)) > 0 + +@step(r'should see "(.*)" (?:somewhere|anywhere) in (?:the|this) page') +def should_see_in_the_page(step, text): + assert_in(text, world.browser.html) + @step('I am logged in$') def i_am_logged_in(step): world.create_user('robot') diff --git a/lms/djangoapps/portal/features/login.feature b/lms/djangoapps/portal/features/login.feature index 2261dc2016..23317b4876 100644 --- a/lms/djangoapps/portal/features/login.feature +++ b/lms/djangoapps/portal/features/login.feature @@ -7,7 +7,7 @@ Feature: Login in as a registered user Given I am an edX user And I am an unactivated user And I visit the homepage - When I click on the link with the text "Log In" + When I click the link with the text "Log In" And I submit my credentials on the login form Then I should see the login error message "This account has not been activated" @@ -15,13 +15,13 @@ Feature: Login in as a registered user Given I am an edX user And I am an activated user And I visit the homepage - When I click on the link with the text "Log In" + When I click the link with the text "Log In" And I submit my credentials on the login form Then I should be on the dashboard page Scenario: Logout of a signed in account Given I am logged in When I click the dropdown arrow - And I click on the link with the text "Log Out" + And I click the link with the text "Log Out" Then I should see a link with the text "Log In" And I should see that the path is "/" diff --git a/lms/djangoapps/portal/features/login.py b/lms/djangoapps/portal/features/login.py index f538aaa5eb..5f200eb259 100644 --- a/lms/djangoapps/portal/features/login.py +++ b/lms/djangoapps/portal/features/login.py @@ -1,5 +1,4 @@ from lettuce import step, world -from salad.steps.everything import * from django.contrib.auth.models import User @step('I am an unactivated user$') diff --git a/lms/djangoapps/terrain/browser.py b/lms/djangoapps/terrain/browser.py index 85f842279e..ff08ca615b 100644 --- a/lms/djangoapps/terrain/browser.py +++ b/lms/djangoapps/terrain/browser.py @@ -1,78 +1,31 @@ from lettuce import before, after, world from splinter.browser import Browser -from splinter.driver.webdriver.firefox import FirefoxProfile from logging import getLogger import time logger = getLogger(__name__) logger.info("Loading the terrain file...") -try: - from django.core.management import call_command - from django.conf import settings - from django.test.simple import DjangoTestSuiteRunner - from django.core import mail +from django.core.management import call_command - try: - from south.management.commands import patch_for_test_db_setup - USE_SOUTH = getattr(settings, "SOUTH_TESTS_MIGRATE", False) - except: - USE_SOUTH = False +@before.harvest +def initial_setup(server): - @before.runserver - def setup_database(actual_server): - logger.info("Setting up a test database...") + # Sync the test database defined in the settings.py file + # then apply the SOUTH migrations + call_command('syncdb', interactive=False) + call_command('migrate', interactive=False) - if USE_SOUTH: - patch_for_test_db_setup() + # Launch firefox + world.browser = Browser('firefox') - world.test_runner = DjangoTestSuiteRunner(interactive=False) - DjangoTestSuiteRunner.setup_test_environment(world.test_runner) - world.created_db = DjangoTestSuiteRunner.setup_databases(world.test_runner) +@before.each_scenario +def reset_data(scenario): + # Clean up the django database + logger.info("Flushing the test database...") + call_command('flush', interactive=False) - # call_command('syncdb', interactive=False, verbosity=0) - # call_command('migrate', interactive=False, verbosity=0) - - # because the TestSuiteRunner setup_test_environment hard codes it to False - settings.DEBUG = True - - @after.runserver - def teardown_database(actual_server): - if hasattr(world, "test_runner"): - logger.info("Destroying the test database ...") - DjangoTestSuiteRunner.teardown_databases(world.test_runner, world.created_db) - DjangoTestSuiteRunner.teardown_test_environment(world.test_runner) - - @before.harvest - def initial_setup(server): - # call_command('syncdb', interactive=False, verbosity=2) - # call_command('migrate', interactive=False, verbosity=2) - - world.browser = Browser('firefox') - # pass - - # logger.info('Sleeping 7 seconds to give the server time to compile the js...') - # time.sleep(float(7)) - # logger.info('...done sleeping.') - - @before.each_scenario - def reset_data(scenario): - # Clean up django. - logger.info("Flushing the test database...") - call_command('flush', interactive=False) - #call_command('loaddata', 'all', verbosity=0) - - @after.all - def teardown_browser(total): - # world.browser.driver.save_screenshot('/tmp/selenium_screenshot.png') - # world.browser.quit() - pass - - -except: - try: - # Only complain if it seems likely that using django was intended. - import django - logger.warn("Django terrains not imported.") - except: - pass \ No newline at end of file +@after.all +def teardown_browser(total): + # Quit firefox + world.browser.quit() diff --git a/lms/djangoapps/terrain/common.py b/lms/djangoapps/terrain/common.py index bd95edb65d..ce82a0a044 100644 --- a/lms/djangoapps/terrain/common.py +++ b/lms/djangoapps/terrain/common.py @@ -1,7 +1,6 @@ from lettuce import world, step from factories import * from django.core.management import call_command -from salad.steps.everything import * from lettuce.django import django_url from django.conf import settings from django.contrib.auth.models import User @@ -87,7 +86,6 @@ def i_am_an_edx_user(step): #### helper functions @world.absorb def create_user(uname): - # This user factory stuff should work after we kill askbot portal_user = UserFactory.build(username=uname, email=uname + '@edx.org') portal_user.set_password('test') portal_user.save() @@ -136,7 +134,7 @@ def save_the_course_content(path='/tmp'): soup = BeautifulSoup(html) # get rid of the header, we only want to compare the body - # soup.head.decompose() + soup.head.decompose() # for now, remove the data-id attributes, because they are # causing mismatches between cms-master and master @@ -166,9 +164,8 @@ def save_the_course_content(path='/tmp'): if not os.path.exists(path): os.makedirs(path) - + filename = '%s.html' % (quote_plus(section_url)) f = open('%s/%s' % (path, filename), 'w') f.write(output) f.close - diff --git a/lms/djangoapps/terrain/diff_html_files.py b/lms/djangoapps/terrain/diff_html_files.py deleted file mode 100755 index ce57daca57..0000000000 --- a/lms/djangoapps/terrain/diff_html_files.py +++ /dev/null @@ -1,57 +0,0 @@ -""" Command line interface to difflib.py to compare html files -""" - -import sys, os, time, difflib, optparse, os.path, re -from urllib import unquote_plus - -def main(): - # Configure the option parser - usage = "usage: %prog fromdir todir" - parser = optparse.OptionParser(usage) - (options, args) = parser.parse_args() - - if len(args) == 0: - parser.print_help() - sys.exit(1) - if len(args) != 2: - parser.error("need to specify both a fromdir and todir") - - fromdir, todir = args # as specified in the usage string - - if not os.path.isdir(fromdir): - print "'%s' is not a directory" % fromdir - - if not os.path.isdir(todir): - print "'%s' is not a directory" % todir - - from_files = os.listdir(fromdir) - to_files = os.listdir(todir) - - for filename in from_files: - if filename in to_files: - - fromfile = os.path.join(fromdir, filename) - tofile = os.path.join(todir, filename) - - # we're passing these as arguments to the diff function - # fromdate = time.ctime(os.stat(fromfile).st_mtime) - # todate = time.ctime(os.stat(tofile).st_mtime) - fromlines = cleanup(open(fromfile, 'U').readlines()) - tolines = cleanup(open(tofile, 'U').readlines()) - - diff = difflib.unified_diff(fromlines, tolines, fromdir, todir, n=0) - # fromdate, todate, n=0) - - print 'FILE: %s' % unquote_plus(filename) - # we're using writelines because diff is a generator - sys.stdout.writelines(diff) - print '' - -def cleanup(lines): - - lines = [s.replace('/c4x/MITx/6.002x/asset', '/static/content-mit-6002x') for s in lines] - lines = [s.replace('handouts_', 'handouts/') for s in lines] - return lines - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index 73d380528f..0c2f164905 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -1,30 +1,29 @@ """ -This config file is a copy of dev environment without the Debug -Toolbar. I it suitable to run against acceptance tests. - +This config file extends the test environment configuration +so that we can run the lettuce acceptance tests. """ -from .dev import * +from .test import * -# REMOVE DEBUG TOOLBAR +# You need to start the server in debug mode, +# otherwise the browser will not render the pages correctly +DEBUG = True -INSTALLED_APPS = tuple(e for e in INSTALLED_APPS if e != 'debug_toolbar') -MIDDLEWARE_CLASSES = tuple(e for e in MIDDLEWARE_CLASSES \ - if e != 'debug_toolbar.middleware.DebugToolbarMiddleware') +# We need to apply the SOUTH migrations to set up the +# auth tables correctly. Otherwise you'll get an error like this: +# DatabaseError: no such table: auth_registration +SOUTH_TESTS_MIGRATE = True -########################### OPEN GRADING TESTING ########################## -XQUEUE_INTERFACE = { - "url": 'http://127.0.0.1:3032', - "django_auth": { - "username": "lms", - "password": "abcd" - }, - "basic_auth": ('anant', 'agarwal'), +# Set this up so that rake lms[acceptance] and running the +# harvest command both use the same (test) database +# which they can flush without messing up your dev db +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ENV_ROOT / "db" / "test_mitx.db", + 'TEST_NAME': ENV_ROOT / "db" / "test_mitx.db", + } } - -########################### LETTUCE TESTING ########################## MITX_FEATURES['DISPLAY_TOY_COURSES'] = True - INSTALLED_APPS += ('lettuce.django',) - LETTUCE_APPS = ('portal',) # dummy app covers the home page, login, registration, and course enrollment diff --git a/test-requirements.txt b/test-requirements.txt index 0238249df8..e61bd1398f 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -4,6 +4,5 @@ nosexcover pylint pep8 lettuce -salad selenium factory_boy From 56d17d8a8af7844616dce9d0b80cf6b8e3a9b566 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Thu, 20 Dec 2012 09:19:06 -0500 Subject: [PATCH 15/59] Improve some comments and names in the acceptance test framework --- lms/djangoapps/portal/README.md | 29 +++---------------- lms/djangoapps/terrain/__init__.py | 6 ++-- lms/djangoapps/terrain/browser.py | 13 +++------ .../terrain/{common.py => steps.py} | 0 lms/envs/acceptance.py | 22 ++++++++++---- lms/envs/common.py | 2 ++ lms/templates/video.html | 2 +- 7 files changed, 31 insertions(+), 43 deletions(-) rename lms/djangoapps/terrain/{common.py => steps.py} (100%) diff --git a/lms/djangoapps/portal/README.md b/lms/djangoapps/portal/README.md index de08758d88..09930ec8fb 100644 --- a/lms/djangoapps/portal/README.md +++ b/lms/djangoapps/portal/README.md @@ -3,34 +3,13 @@ This fake django app is here to support acceptance testing using lettuce + splinter (which wraps selenium). -Some documentation for our efforts are located in basecamp at this link. - First you need to make sure that you've installed the requirements. This includes lettuce, selenium, splinter, etc. Do this with: ```pip install -r test-requirements.txt``` -First set up the database that you need: -WARNING!!! THIS WILL OVERWRITE THE DATA IN YOUR DEV DATABASE -IF YOU WANT TO KEEP THAT DATA, SAVE A COPY OF YOUR ../db/mitx.db ELSEWHERE FIRST! +The settings.py environment file used is named acceptance.py. +It uses a test SQLite database defined as ../db/test-mitx.db. +You need to first start up the server separately, then run the lettuce scenarios. -
  • If necessary, delete it first from mit_all/db
  • -
  • ```rake django-admin[syncdb,lms,acceptance]```
  • -
  • ```rake django-admin[migrate,lms,acceptance]```
  • - -To use, start up the server separately: -```rake lms[acceptance]``` - -In between scenarios, flush the database with this command. -You will not need to do this if it's set up in the terrain.py file -which is at the mitx root level. -```rake django-admin[flush,lms,acceptance,--noinput]``` - -Running the all the user acceptance scenarios: -```django-admin.py harvest --no-server --settings=lms.envs.acceptance --pythonpath=.``` - -Running a single user acceptance scenario: -```django-admin.py harvest --no-server --settings=lms.envs.acceptance --pythonpath=. lms/djangoapps/portal/features/signup.feature``` - -Or you can use the rake task named lettuce like this: -rake lettuce[lms/djangoapps/portal/features/homepage.feature] +Full documentation can be found on the wiki at this link. diff --git a/lms/djangoapps/terrain/__init__.py b/lms/djangoapps/terrain/__init__.py index dd7ed50184..dd6869e7fd 100644 --- a/lms/djangoapps/terrain/__init__.py +++ b/lms/djangoapps/terrain/__init__.py @@ -1,6 +1,6 @@ -# Use this as your terrain file so that the common steps -# across all lms apps can be put in terrain/steps +# Use this as your lettuce terrain file so that the common steps +# across all lms apps can be put in terrain/common # See https://groups.google.com/forum/?fromgroups=#!msg/lettuce-users/5VyU9B4HcX8/USgbGIJdS5QJ from terrain.browser import * -from terrain.common import * +from terrain.steps import * from terrain.factories import * \ No newline at end of file diff --git a/lms/djangoapps/terrain/browser.py b/lms/djangoapps/terrain/browser.py index ff08ca615b..7fe684e153 100644 --- a/lms/djangoapps/terrain/browser.py +++ b/lms/djangoapps/terrain/browser.py @@ -4,25 +4,20 @@ from logging import getLogger import time logger = getLogger(__name__) -logger.info("Loading the terrain file...") +logger.info("Loading the lettuce acceptance testing terrain file...") from django.core.management import call_command @before.harvest def initial_setup(server): - - # Sync the test database defined in the settings.py file - # then apply the SOUTH migrations - call_command('syncdb', interactive=False) - call_command('migrate', interactive=False) - # Launch firefox world.browser = Browser('firefox') @before.each_scenario def reset_data(scenario): - # Clean up the django database - logger.info("Flushing the test database...") + # Clean out the django test database defined in the + # envs/acceptance.py file: mitx_all/db/test_mitx.db + logger.debug("Flushing the test database...") call_command('flush', interactive=False) @after.all diff --git a/lms/djangoapps/terrain/common.py b/lms/djangoapps/terrain/steps.py similarity index 100% rename from lms/djangoapps/terrain/common.py rename to lms/djangoapps/terrain/steps.py diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index 0c2f164905..e0857a4392 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -8,10 +8,18 @@ from .test import * # otherwise the browser will not render the pages correctly DEBUG = True -# We need to apply the SOUTH migrations to set up the -# auth tables correctly. Otherwise you'll get an error like this: -# DatabaseError: no such table: auth_registration -SOUTH_TESTS_MIGRATE = True +# Show the courses that are in the data directory +COURSES_ROOT = ENV_ROOT / "data" +DATA_DIR = COURSES_ROOT +MODULESTORE = { + 'default': { + 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', + 'OPTIONS': { + 'data_dir': DATA_DIR, + 'default_class': 'xmodule.hidden_module.HiddenDescriptor', + } + } +} # Set this up so that rake lms[acceptance] and running the # harvest command both use the same (test) database @@ -24,6 +32,10 @@ DATABASES = { } } -MITX_FEATURES['DISPLAY_TOY_COURSES'] = True +# Do not display the YouTube videos in the browser while running the +# acceptance tests. This makes them faster and more reliable +MITX_FEATURES['STUB_VIDEO_FOR_TESTING'] = True + +# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command INSTALLED_APPS += ('lettuce.django',) LETTUCE_APPS = ('portal',) # dummy app covers the home page, login, registration, and course enrollment diff --git a/lms/envs/common.py b/lms/envs/common.py index 26941f7e01..ec3af7144a 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -76,6 +76,8 @@ MITX_FEATURES = { 'DISABLE_LOGIN_BUTTON': False, # used in systems where login is automatic, eg MIT SSL + 'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests + # extrernal access methods 'ACCESS_REQUIRE_STAFF_FOR_COURSE': False, 'AUTH_USE_OPENID': False, diff --git a/lms/templates/video.html b/lms/templates/video.html index 0270ba102e..4d4df8c3c7 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -2,7 +2,7 @@

    ${display_name}

    % endif -%if settings.MITX_FEATURES['DISPLAY_TOY_COURSES']: +%if settings.MITX_FEATURES['STUB_VIDEO_FOR_TESTING']:
    %else:
    From 62271fe5c690e7229c4aac4fc082041ec0a61288 Mon Sep 17 00:00:00 2001 From: valera-rozuvan Date: Tue, 4 Dec 2012 18:09:52 +0200 Subject: [PATCH 16/59] Added namespaced RequireJS. --- common/static/js/vendor/RequireJS.js | 57 ++++++++++++++++++++++++++++ lms/envs/common.py | 1 + 2 files changed, 58 insertions(+) create mode 100644 common/static/js/vendor/RequireJS.js diff --git a/common/static/js/vendor/RequireJS.js b/common/static/js/vendor/RequireJS.js new file mode 100644 index 0000000000..a0526930ef --- /dev/null +++ b/common/static/js/vendor/RequireJS.js @@ -0,0 +1,57 @@ +/* + * This file is a wrapper for the Require JS file and module loader. Please see + * the discussion at: + * + * https://edx-wiki.atlassian.net/wiki/display/LMS/Integration+of+Require+JS+into+the+system + */ + +var RequireJS = function() { + +// Below is the unmodified minified version of Require JS. The latest can be +// found at: +// +// http://requirejs.org/docs/download.html + +/* + RequireJS 2.1.2 Copyright (c) 2010-2012, The Dojo Foundation All Rights Reserved. + Available via the MIT or new BSD license. + see: http://github.com/jrburke/requirejs for details +*/ +var requirejs,require,define; +(function(Y){function H(b){return"[object Function]"===L.call(b)}function I(b){return"[object Array]"===L.call(b)}function x(b,c){if(b){var d;for(d=0;dthis.depCount&&!this.defined){if(H(n)){if(this.events.error)try{e=j.execCb(c,n,b,e)}catch(d){a=d}else e=j.execCb(c,n,b,e);this.map.isDefine&&((b=this.module)&&void 0!==b.exports&&b.exports!==this.exports?e=b.exports:void 0===e&&this.usingExports&&(e=this.exports));if(a)return a.requireMap=this.map,a.requireModules=[this.map.id],a.requireType="define",C(this.error=a)}else e=n;this.exports=e;if(this.map.isDefine&& +!this.ignore&&(p[c]=e,l.onResourceLoad))l.onResourceLoad(j,this.map,this.depMaps);delete k[c];this.defined=!0}this.defining=!1;this.defined&&!this.defineEmitted&&(this.defineEmitted=!0,this.emit("defined",this.exports),this.defineEmitComplete=!0)}}else this.fetch()}},callPlugin:function(){var a=this.map,b=a.id,d=h(a.prefix);this.depMaps.push(d);s(d,"defined",t(this,function(e){var n,d;d=this.map.name;var v=this.map.parentMap?this.map.parentMap.name:null,f=j.makeRequire(a.parentMap,{enableBuildCallback:!0, +skipMap:!0});if(this.map.unnormalized){if(e.normalize&&(d=e.normalize(d,function(a){return c(a,v,!0)})||""),e=h(a.prefix+"!"+d,this.map.parentMap),s(e,"defined",t(this,function(a){this.init([],function(){return a},null,{enabled:!0,ignore:!0})})),d=i(k,e.id)){this.depMaps.push(e);if(this.events.error)d.on("error",t(this,function(a){this.emit("error",a)}));d.enable()}}else n=t(this,function(a){this.init([],function(){return a},null,{enabled:!0})}),n.error=t(this,function(a){this.inited=!0;this.error= +a;a.requireModules=[b];E(k,function(a){0===a.map.id.indexOf(b+"_unnormalized")&&delete k[a.map.id]});C(a)}),n.fromText=t(this,function(e,c){var d=a.name,u=h(d),v=O;c&&(e=c);v&&(O=!1);q(u);r(m.config,b)&&(m.config[d]=m.config[b]);try{l.exec(e)}catch(k){throw Error("fromText eval for "+d+" failed: "+k);}v&&(O=!0);this.depMaps.push(u);j.completeLoad(d);f([d],n)}),e.load(a.name,f,n,m)}));j.enable(d,this);this.pluginMaps[d.id]=d},enable:function(){this.enabling=this.enabled=!0;x(this.depMaps,t(this,function(a, +b){var c,e;if("string"===typeof a){a=h(a,this.map.isDefine?this.map:this.map.parentMap,!1,!this.skipMap);this.depMaps[b]=a;if(c=i(N,a.id)){this.depExports[b]=c(this);return}this.depCount+=1;s(a,"defined",t(this,function(a){this.defineDep(b,a);this.check()}));this.errback&&s(a,"error",this.errback)}c=a.id;e=k[c];!r(N,c)&&(e&&!e.enabled)&&j.enable(a,this)}));E(this.pluginMaps,t(this,function(a){var b=i(k,a.id);b&&!b.enabled&&j.enable(a,this)}));this.enabling=!1;this.check()},on:function(a,b){var c= +this.events[a];c||(c=this.events[a]=[]);c.push(b)},emit:function(a,b){x(this.events[a],function(a){a(b)});"error"===a&&delete this.events[a]}};j={config:m,contextName:b,registry:k,defined:p,urlFetched:S,defQueue:F,Module:W,makeModuleMap:h,nextTick:l.nextTick,configure:function(a){a.baseUrl&&"/"!==a.baseUrl.charAt(a.baseUrl.length-1)&&(a.baseUrl+="/");var b=m.pkgs,c=m.shim,e={paths:!0,config:!0,map:!0};E(a,function(a,b){e[b]?"map"===b?Q(m[b],a,!0,!0):Q(m[b],a,!0):m[b]=a});a.shim&&(E(a.shim,function(a, +b){I(a)&&(a={deps:a});if((a.exports||a.init)&&!a.exportsFn)a.exportsFn=j.makeShimExports(a);c[b]=a}),m.shim=c);a.packages&&(x(a.packages,function(a){a="string"===typeof a?{name:a}:a;b[a.name]={name:a.name,location:a.location||a.name,main:(a.main||"main").replace(ga,"").replace(aa,"")}}),m.pkgs=b);E(k,function(a,b){!a.inited&&!a.map.unnormalized&&(a.map=h(b))});if(a.deps||a.callback)j.require(a.deps||[],a.callback)},makeShimExports:function(a){return function(){var b;a.init&&(b=a.init.apply(Y,arguments)); +return b||a.exports&&Z(a.exports)}},makeRequire:function(a,d){function f(e,c,u){var i,m;d.enableBuildCallback&&(c&&H(c))&&(c.__requireJsBuild=!0);if("string"===typeof e){if(H(c))return C(J("requireargs","Invalid require call"),u);if(a&&r(N,e))return N[e](k[a.id]);if(l.get)return l.get(j,e,a);i=h(e,a,!1,!0);i=i.id;return!r(p,i)?C(J("notloaded",'Module name "'+i+'" has not been loaded yet for context: '+b+(a?"":". Use require([])"))):p[i]}K();j.nextTick(function(){K();m=q(h(null,a));m.skipMap=d.skipMap; +m.init(e,c,u,{enabled:!0});B()});return f}d=d||{};Q(f,{isBrowser:z,toUrl:function(b){var d=b.lastIndexOf("."),g=null;-1!==d&&(g=b.substring(d,b.length),b=b.substring(0,d));return j.nameToUrl(c(b,a&&a.id,!0),g)},defined:function(b){return r(p,h(b,a,!1,!0).id)},specified:function(b){b=h(b,a,!1,!0).id;return r(p,b)||r(k,b)}});a||(f.undef=function(b){w();var c=h(b,a,!0),d=i(k,b);delete p[b];delete S[c.url];delete X[b];d&&(d.events.defined&&(X[b]=d.events),delete k[b])});return f},enable:function(a){i(k, +a.id)&&q(a).enable()},completeLoad:function(a){var b,c,d=i(m.shim,a)||{},h=d.exports;for(w();F.length;){c=F.shift();if(null===c[0]){c[0]=a;if(b)break;b=!0}else c[0]===a&&(b=!0);D(c)}c=i(k,a);if(!b&&!r(p,a)&&c&&!c.inited){if(m.enforceDefine&&(!h||!Z(h)))return y(a)?void 0:C(J("nodefine","No define call for "+a,null,[a]));D([a,d.deps||[],d.exportsFn])}B()},nameToUrl:function(a,b){var c,d,h,f,j,k;if(l.jsExtRegExp.test(a))f=a+(b||"");else{c=m.paths;d=m.pkgs;f=a.split("/");for(j=f.length;0f.attachEvent.toString().indexOf("[native code"))&&!V?(O=!0,f.attachEvent("onreadystatechange", +b.onScriptLoad)):(f.addEventListener("load",b.onScriptLoad,!1),f.addEventListener("error",b.onScriptError,!1)),f.src=d,K=f,D?A.insertBefore(f,D):A.appendChild(f),K=null,f;$&&(importScripts(d),b.completeLoad(c))};z&&M(document.getElementsByTagName("script"),function(b){A||(A=b.parentNode);if(s=b.getAttribute("data-main"))return q.baseUrl||(G=s.split("/"),ba=G.pop(),ca=G.length?G.join("/")+"/":"./",q.baseUrl=ca,s=ba),s=s.replace(aa,""),q.deps=q.deps?q.deps.concat(s):[s],!0});define=function(b,c,d){var i, +f;"string"!==typeof b&&(d=c,c=b,b=null);I(c)||(d=c,c=[]);!c.length&&H(d)&&d.length&&(d.toString().replace(ia,"").replace(ja,function(b,d){c.push(d)}),c=(1===d.length?["require"]:["require","exports","module"]).concat(c));if(O){if(!(i=K))P&&"interactive"===P.readyState||M(document.getElementsByTagName("script"),function(b){if("interactive"===b.readyState)return P=b}),i=P;i&&(b||(b=i.getAttribute("data-requiremodule")),f=B[i.getAttribute("data-requirecontext")])}(f?f.defQueue:R).push([b,c,d])};define.amd= +{jQuery:!0};l.exec=function(b){return eval(b)};l(q)}})(this); + +// The object which will be globally available via RequireJS variable. +return { + 'requirejs': requirejs, + 'require': require, + 'define': define +}; +}(); // End-of: var RequireJS = function() diff --git a/lms/envs/common.py b/lms/envs/common.py index 26941f7e01..549dc4b29f 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -407,6 +407,7 @@ courseware_only_js += [ ] main_vendor_js = [ + 'js/vendor/RequireJS.js', 'js/vendor/json2.js', 'js/vendor/jquery.min.js', 'js/vendor/jquery-ui.min.js', From c94530cb01e1930e1bdded240371b6d0ca64017e Mon Sep 17 00:00:00 2001 From: valera-rozuvan Date: Wed, 5 Dec 2012 09:16:37 +0200 Subject: [PATCH 17/59] Added initial RequireJS Jasmine test. --- lms/static/coffee/spec/requirejs_spec.coffee | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 lms/static/coffee/spec/requirejs_spec.coffee diff --git a/lms/static/coffee/spec/requirejs_spec.coffee b/lms/static/coffee/spec/requirejs_spec.coffee new file mode 100644 index 0000000000..77872fb3e5 --- /dev/null +++ b/lms/static/coffee/spec/requirejs_spec.coffee @@ -0,0 +1,3 @@ +describe "RequireJS", -> + it "check that the RequireJS object is present in the global namespace", -> + expect(true).toBe true From 8f120f1d27cb065c2443e5d937df1bc9d963cbce Mon Sep 17 00:00:00 2001 From: valera-rozuvan Date: Wed, 5 Dec 2012 09:26:46 +0200 Subject: [PATCH 18/59] Work on RequireJS Jasmine test. --- lms/static/coffee/spec/requirejs_spec.coffee | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lms/static/coffee/spec/requirejs_spec.coffee b/lms/static/coffee/spec/requirejs_spec.coffee index 77872fb3e5..ca52bb94cc 100644 --- a/lms/static/coffee/spec/requirejs_spec.coffee +++ b/lms/static/coffee/spec/requirejs_spec.coffee @@ -1,3 +1,12 @@ describe "RequireJS", -> - it "check that the RequireJS object is present in the global namespace", -> - expect(true).toBe true + it "check that the RequireJS object is present in the global namespace", -> + expect RequireJS.toEqual(jasmine.any(Object)) + expect window.RequireJS.toEqual(jasmine.any(Object)) + + it "check that requirejs(), require(), and define() are not in the global namespace", -> + expect(requirejs).not.toBeDefined() + expect(require).not.toBeDefined() + expect(define).not.toBeDefined() + expect(window.requirejs).not.toBeDefined() + expect(window.require).not.toBeDefined() + expect(window.define).not.toBeDefined() From c4f336d8fdcfe93af30feaf81d04c426b05c699e Mon Sep 17 00:00:00 2001 From: valera-rozuvan Date: Wed, 5 Dec 2012 09:31:43 +0200 Subject: [PATCH 19/59] Added RequireJS.js to the files.json config so that is it also loaded when testing with Jasmine. --- lms/static/coffee/files.json | 1 + 1 file changed, 1 insertion(+) diff --git a/lms/static/coffee/files.json b/lms/static/coffee/files.json index 4721ef58bb..5dc03613b9 100644 --- a/lms/static/coffee/files.json +++ b/lms/static/coffee/files.json @@ -1,5 +1,6 @@ { "js_files": [ + "/static/js/vendor/RequireJS.js", "/static/js/vendor/jquery.min.js", "/static/js/vendor/jquery-ui.min.js", "/static/js/vendor/jquery.leanModal.min.js", From 7cf848db9edb4fb84ebff5761e7dd0bb27d1319d Mon Sep 17 00:00:00 2001 From: valera-rozuvan Date: Wed, 5 Dec 2012 09:34:24 +0200 Subject: [PATCH 20/59] Work on RequireJS Jasmine test. --- lms/static/coffee/spec/requirejs_spec.coffee | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lms/static/coffee/spec/requirejs_spec.coffee b/lms/static/coffee/spec/requirejs_spec.coffee index ca52bb94cc..e988e3ae6e 100644 --- a/lms/static/coffee/spec/requirejs_spec.coffee +++ b/lms/static/coffee/spec/requirejs_spec.coffee @@ -1,7 +1,7 @@ describe "RequireJS", -> it "check that the RequireJS object is present in the global namespace", -> - expect RequireJS.toEqual(jasmine.any(Object)) - expect window.RequireJS.toEqual(jasmine.any(Object)) + expect(RequireJS).toEqual jasmine.any(Object) + expect(window.RequireJS).toEqual jasmine.any(Object) it "check that requirejs(), require(), and define() are not in the global namespace", -> expect(requirejs).not.toBeDefined() From 8af93229d32525d880e4f1bf1685b97ccc7e2d1d Mon Sep 17 00:00:00 2001 From: valera-rozuvan Date: Wed, 5 Dec 2012 09:44:08 +0200 Subject: [PATCH 21/59] Work on RequireJS Jasmine test. --- lms/static/coffee/spec/requirejs_spec.coffee | 26 ++++++++++++-------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/lms/static/coffee/spec/requirejs_spec.coffee b/lms/static/coffee/spec/requirejs_spec.coffee index e988e3ae6e..8f271c80ce 100644 --- a/lms/static/coffee/spec/requirejs_spec.coffee +++ b/lms/static/coffee/spec/requirejs_spec.coffee @@ -1,12 +1,18 @@ describe "RequireJS", -> - it "check that the RequireJS object is present in the global namespace", -> - expect(RequireJS).toEqual jasmine.any(Object) - expect(window.RequireJS).toEqual jasmine.any(Object) + beforeEach -> + @addMatchers requirejsTobeUndefined: -> + typeof requirejs is "undefined" - it "check that requirejs(), require(), and define() are not in the global namespace", -> - expect(requirejs).not.toBeDefined() - expect(require).not.toBeDefined() - expect(define).not.toBeDefined() - expect(window.requirejs).not.toBeDefined() - expect(window.require).not.toBeDefined() - expect(window.define).not.toBeDefined() + + it "check that the RequireJS object is present in the global namespace", -> + expect(RequireJS).toEqual jasmine.any(Object) + expect(window.RequireJS).toEqual jasmine.any(Object) + + it "check that requirejs(), require(), and define() are not in the global namespace", -> + expect({}).requirejsTobeUndefined() + + # expect(require).not.toBeDefined(); + # expect(define).not.toBeDefined(); + expect(window.requirejs).not.toBeDefined() + expect(window.require).not.toBeDefined() + expect(window.define).not.toBeDefined() From d0c72ebe160a958b24117c29fa144ae88f9ddd8b Mon Sep 17 00:00:00 2001 From: valera-rozuvan Date: Wed, 5 Dec 2012 09:46:39 +0200 Subject: [PATCH 22/59] Work on RequireJS Jasmine test. --- lms/static/coffee/spec/requirejs_spec.coffee | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/lms/static/coffee/spec/requirejs_spec.coffee b/lms/static/coffee/spec/requirejs_spec.coffee index 8f271c80ce..9e20f0c303 100644 --- a/lms/static/coffee/spec/requirejs_spec.coffee +++ b/lms/static/coffee/spec/requirejs_spec.coffee @@ -1,7 +1,14 @@ describe "RequireJS", -> beforeEach -> - @addMatchers requirejsTobeUndefined: -> - typeof requirejs is "undefined" + @addMatchers + requirejsTobeUndefined: -> + typeof requirejs is "undefined" + + requireTobeUndefined: -> + typeof require is "undefined" + + defineTobeUndefined: -> + typeof define is "undefined" it "check that the RequireJS object is present in the global namespace", -> @@ -10,9 +17,8 @@ describe "RequireJS", -> it "check that requirejs(), require(), and define() are not in the global namespace", -> expect({}).requirejsTobeUndefined() - - # expect(require).not.toBeDefined(); - # expect(define).not.toBeDefined(); + expect({}).requireTobeUndefined() + expect({}).defineTobeUndefined() expect(window.requirejs).not.toBeDefined() expect(window.require).not.toBeDefined() expect(window.define).not.toBeDefined() From de635410498f0fb3bd293a8636c20bf759b54c68 Mon Sep 17 00:00:00 2001 From: valera-rozuvan Date: Wed, 5 Dec 2012 09:49:31 +0200 Subject: [PATCH 23/59] Work on RequireJS Jasmine test. --- lms/static/coffee/spec/requirejs_spec.coffee | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lms/static/coffee/spec/requirejs_spec.coffee b/lms/static/coffee/spec/requirejs_spec.coffee index 9e20f0c303..13dff4b633 100644 --- a/lms/static/coffee/spec/requirejs_spec.coffee +++ b/lms/static/coffee/spec/requirejs_spec.coffee @@ -22,3 +22,8 @@ describe "RequireJS", -> expect(window.requirejs).not.toBeDefined() expect(window.require).not.toBeDefined() expect(window.define).not.toBeDefined() + + it "check that the RequireJS has requirejs(), require(), and define() functions as its properties", -> + expect(RequireJS.requirejs).toEqual jasmine.any(Function) + expect(RequireJS.require).toEqual jasmine.any(Function) + expect(RequireJS.define).toEqual jasmine.any(Function) From fd0ec0ed8a45d02f80c68e15fed3dd34291cec7a Mon Sep 17 00:00:00 2001 From: valera-rozuvan Date: Wed, 5 Dec 2012 10:42:40 +0200 Subject: [PATCH 24/59] Work on RequireJS Jasmine test. --- lms/static/coffee/spec/requirejs_spec.coffee | 29 ++++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/lms/static/coffee/spec/requirejs_spec.coffee b/lms/static/coffee/spec/requirejs_spec.coffee index 13dff4b633..c6f8c1e5d5 100644 --- a/lms/static/coffee/spec/requirejs_spec.coffee +++ b/lms/static/coffee/spec/requirejs_spec.coffee @@ -1,4 +1,4 @@ -describe "RequireJS", -> +describe "RequireJS namespacing", -> beforeEach -> @addMatchers requirejsTobeUndefined: -> @@ -23,7 +23,26 @@ describe "RequireJS", -> expect(window.require).not.toBeDefined() expect(window.define).not.toBeDefined() - it "check that the RequireJS has requirejs(), require(), and define() functions as its properties", -> - expect(RequireJS.requirejs).toEqual jasmine.any(Function) - expect(RequireJS.require).toEqual jasmine.any(Function) - expect(RequireJS.define).toEqual jasmine.any(Function) + +describe "RequireJS module creation", -> + inCallback = undefined + it "check that we can use RequireJS.define() to create a module", -> + runs -> + inCallback = false + RequireJS.define [], -> + inCallback = true + module_status: "OK" + + + waitsFor (-> + inCallback + ), "We should eventually end up in the defined callback", 1000 + runs -> + expects(inCallback).toBeTruthy() + + + + +# it('check that we can use RequireJS.require() to get our defined module', function () { + +# }); \ No newline at end of file From 213ca3fa325f3299386cb32b9bf3d8015e78913e Mon Sep 17 00:00:00 2001 From: valera-rozuvan Date: Wed, 5 Dec 2012 10:50:02 +0200 Subject: [PATCH 25/59] Work on RequireJS Jasmine test. --- lms/static/coffee/spec/requirejs_spec.coffee | 27 +++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/lms/static/coffee/spec/requirejs_spec.coffee b/lms/static/coffee/spec/requirejs_spec.coffee index c6f8c1e5d5..45bcd8d6c5 100644 --- a/lms/static/coffee/spec/requirejs_spec.coffee +++ b/lms/static/coffee/spec/requirejs_spec.coffee @@ -25,24 +25,27 @@ describe "RequireJS namespacing", -> describe "RequireJS module creation", -> - inCallback = undefined - it "check that we can use RequireJS.define() to create a module", -> + inDefineCallback = undefined + inRequireCallback = undefined + it "check that we can use RequireJS define() and require() a module", -> runs -> - inCallback = false - RequireJS.define [], -> - inCallback = true + inDefineCallback = false + inRequireCallback = false + RequireJS.define "test_module", [], -> + inDefineCallback = true module_status: "OK" + RequireJS.require "test_module", (test_module) -> + inRequireCallback = true + expects(test_module.module_status).toBe "OK" + waitsFor (-> - inCallback + return false if (inDefineCallback isnt true) or (inRequireCallback isnt true) + true ), "We should eventually end up in the defined callback", 1000 runs -> - expects(inCallback).toBeTruthy() + expects(inDefineCallback).toBeTruthy() + expects(inRequireCallback).toBeTruthy() - - -# it('check that we can use RequireJS.require() to get our defined module', function () { - -# }); \ No newline at end of file From 37c7967e8ca69d44e5c912eebb39ba9e16f912a7 Mon Sep 17 00:00:00 2001 From: valera-rozuvan Date: Wed, 5 Dec 2012 10:51:34 +0200 Subject: [PATCH 26/59] Work on RequireJS Jasmine test. --- lms/static/coffee/spec/requirejs_spec.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/static/coffee/spec/requirejs_spec.coffee b/lms/static/coffee/spec/requirejs_spec.coffee index 45bcd8d6c5..bad2f823db 100644 --- a/lms/static/coffee/spec/requirejs_spec.coffee +++ b/lms/static/coffee/spec/requirejs_spec.coffee @@ -35,7 +35,7 @@ describe "RequireJS module creation", -> inDefineCallback = true module_status: "OK" - RequireJS.require "test_module", (test_module) -> + RequireJS.require ["test_module"], (test_module) -> inRequireCallback = true expects(test_module.module_status).toBe "OK" From f2cce07bd7d88692e7ca49a51941331d5a420f95 Mon Sep 17 00:00:00 2001 From: valera-rozuvan Date: Wed, 5 Dec 2012 10:52:48 +0200 Subject: [PATCH 27/59] Work on RequireJS Jasmine test. --- lms/static/coffee/spec/requirejs_spec.coffee | 95 ++++++++++++-------- 1 file changed, 56 insertions(+), 39 deletions(-) diff --git a/lms/static/coffee/spec/requirejs_spec.coffee b/lms/static/coffee/spec/requirejs_spec.coffee index bad2f823db..fd3bc4ec42 100644 --- a/lms/static/coffee/spec/requirejs_spec.coffee +++ b/lms/static/coffee/spec/requirejs_spec.coffee @@ -1,51 +1,68 @@ -describe "RequireJS namespacing", -> - beforeEach -> - @addMatchers - requirejsTobeUndefined: -> - typeof requirejs is "undefined" +describe('RequireJS namespacing', function() { + beforeEach(function() { + this.addMatchers({ + requirejsTobeUndefined: function() { + return (typeof requirejs === 'undefined'); + }, + requireTobeUndefined: function() { + return (typeof require === 'undefined'); + }, + defineTobeUndefined: function() { + return (typeof define === 'undefined'); + }, + }); + }); - requireTobeUndefined: -> - typeof require is "undefined" + it('check that the RequireJS object is present in the global namespace', function() { + expect(RequireJS).toEqual(jasmine.any(Object)); + expect(window.RequireJS).toEqual(jasmine.any(Object)); + }); - defineTobeUndefined: -> - typeof define is "undefined" + it('check that requirejs(), require(), and define() are not in the global namespace', function () { + expect({}).requirejsTobeUndefined(); + expect({}).requireTobeUndefined(); + expect({}).defineTobeUndefined(); + expect(window.requirejs).not.toBeDefined(); + expect(window.require).not.toBeDefined(); + expect(window.define).not.toBeDefined(); + }); +}); - it "check that the RequireJS object is present in the global namespace", -> - expect(RequireJS).toEqual jasmine.any(Object) - expect(window.RequireJS).toEqual jasmine.any(Object) +describe('RequireJS module creation', function() { + var inDefineCallback, inRequireCallback; - it "check that requirejs(), require(), and define() are not in the global namespace", -> - expect({}).requirejsTobeUndefined() - expect({}).requireTobeUndefined() - expect({}).defineTobeUndefined() - expect(window.requirejs).not.toBeDefined() - expect(window.require).not.toBeDefined() - expect(window.define).not.toBeDefined() + it('check that we can use RequireJS define() and require() a module', function() { + runs(function () { + inDefineCallback = false; + inRequireCallback = false; + RequireJS.define('test_module', [], function () { + inDefineCallback = true; -describe "RequireJS module creation", -> - inDefineCallback = undefined - inRequireCallback = undefined - it "check that we can use RequireJS define() and require() a module", -> - runs -> - inDefineCallback = false - inRequireCallback = false - RequireJS.define "test_module", [], -> - inDefineCallback = true - module_status: "OK" + return { + 'module_status': 'OK' + }; + }); - RequireJS.require ["test_module"], (test_module) -> - inRequireCallback = true - expects(test_module.module_status).toBe "OK" + RequireJS.require(['test_module'], function (test_module) { + inRequireCallback = true; + expect(test_module.module_status).toBe('OK'); + }); + }); - waitsFor (-> - return false if (inDefineCallback isnt true) or (inRequireCallback isnt true) - true - ), "We should eventually end up in the defined callback", 1000 - runs -> - expects(inDefineCallback).toBeTruthy() - expects(inRequireCallback).toBeTruthy() + waitsFor(function () { + if ((inDefineCallback !== true) || (inRequireCallback !== true)) { + return false; + } + return true + }, 'We should eventually end up in the defined callback', 1000); + runs(function () { + expect(inDefineCallback).toBeTruthy(); + expect(inRequireCallback).toBeTruthy(); + }); + }); +}); From c5cd67a6a5cb87cf0be894e692f4c122946ca1de Mon Sep 17 00:00:00 2001 From: valera-rozuvan Date: Wed, 5 Dec 2012 10:55:10 +0200 Subject: [PATCH 28/59] Work on RequireJS Jasmine test. --- lms/static/coffee/spec/requirejs_spec.coffee | 95 ++++++++------------ 1 file changed, 39 insertions(+), 56 deletions(-) diff --git a/lms/static/coffee/spec/requirejs_spec.coffee b/lms/static/coffee/spec/requirejs_spec.coffee index fd3bc4ec42..1636dd9d21 100644 --- a/lms/static/coffee/spec/requirejs_spec.coffee +++ b/lms/static/coffee/spec/requirejs_spec.coffee @@ -1,68 +1,51 @@ -describe('RequireJS namespacing', function() { - beforeEach(function() { - this.addMatchers({ - requirejsTobeUndefined: function() { - return (typeof requirejs === 'undefined'); - }, - requireTobeUndefined: function() { - return (typeof require === 'undefined'); - }, - defineTobeUndefined: function() { - return (typeof define === 'undefined'); - }, - }); - }); +describe "RequireJS namespacing", -> + beforeEach -> + @addMatchers + requirejsTobeUndefined: -> + typeof requirejs is "undefined" - it('check that the RequireJS object is present in the global namespace', function() { - expect(RequireJS).toEqual(jasmine.any(Object)); - expect(window.RequireJS).toEqual(jasmine.any(Object)); - }); + requireTobeUndefined: -> + typeof require is "undefined" - it('check that requirejs(), require(), and define() are not in the global namespace', function () { - expect({}).requirejsTobeUndefined(); - expect({}).requireTobeUndefined(); - expect({}).defineTobeUndefined(); + defineTobeUndefined: -> + typeof define is "undefined" - expect(window.requirejs).not.toBeDefined(); - expect(window.require).not.toBeDefined(); - expect(window.define).not.toBeDefined(); - }); -}); -describe('RequireJS module creation', function() { - var inDefineCallback, inRequireCallback; + it "check that the RequireJS object is present in the global namespace", -> + expect(RequireJS).toEqual jasmine.any(Object) + expect(window.RequireJS).toEqual jasmine.any(Object) - it('check that we can use RequireJS define() and require() a module', function() { - runs(function () { - inDefineCallback = false; - inRequireCallback = false; + it "check that requirejs(), require(), and define() are not in the global namespace", -> + expect({}).requirejsTobeUndefined() + expect({}).requireTobeUndefined() + expect({}).defineTobeUndefined() + expect(window.requirejs).not.toBeDefined() + expect(window.require).not.toBeDefined() + expect(window.define).not.toBeDefined() - RequireJS.define('test_module', [], function () { - inDefineCallback = true; - return { - 'module_status': 'OK' - }; - }); +describe "RequireJS module creation", -> + inDefineCallback = undefined + inRequireCallback = undefined + it "check that we can use RequireJS define() and require() a module", -> + runs -> + inDefineCallback = false + inRequireCallback = false + RequireJS.define "test_module", [], -> + inDefineCallback = true + module_status: "OK" - RequireJS.require(['test_module'], function (test_module) { - inRequireCallback = true; + RequireJS.require ["test_module"], (test_module) -> + inRequireCallback = true + expect(test_module.module_status).toBe "OK" - expect(test_module.module_status).toBe('OK'); - }); - }); - waitsFor(function () { - if ((inDefineCallback !== true) || (inRequireCallback !== true)) { - return false; - } + waitsFor (-> + return false if (inDefineCallback isnt true) or (inRequireCallback isnt true) + true + ), "We should eventually end up in the defined callback", 1000 + runs -> + expect(inDefineCallback).toBeTruthy() + expect(inRequireCallback).toBeTruthy() - return true - }, 'We should eventually end up in the defined callback', 1000); - runs(function () { - expect(inDefineCallback).toBeTruthy(); - expect(inRequireCallback).toBeTruthy(); - }); - }); -}); From 7a8d83a17fc9d934b95ccc4bcd2a2ecdf342cc85 Mon Sep 17 00:00:00 2001 From: valera-rozuvan Date: Wed, 5 Dec 2012 11:21:18 +0200 Subject: [PATCH 29/59] Work on RequireJS Jasmine test. --- lms/static/coffee/spec/requirejs_spec.coffee | 42 +++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/lms/static/coffee/spec/requirejs_spec.coffee b/lms/static/coffee/spec/requirejs_spec.coffee index 1636dd9d21..3e54560d2b 100644 --- a/lms/static/coffee/spec/requirejs_spec.coffee +++ b/lms/static/coffee/spec/requirejs_spec.coffee @@ -1,5 +1,10 @@ describe "RequireJS namespacing", -> beforeEach -> + + # + # * Jasmine does not provide a way to use the typeof operator. We need + # * to create our own custom matchers so that a TypeError is not thrown. + # @addMatchers requirejsTobeUndefined: -> typeof requirejs is "undefined" @@ -16,6 +21,10 @@ describe "RequireJS namespacing", -> expect(window.RequireJS).toEqual jasmine.any(Object) it "check that requirejs(), require(), and define() are not in the global namespace", -> + + # The custom matchers that we defined in the beforeEach() function do + # not operate on an object. We pass a dummy empty object {} not to + # confuse Jasmine. expect({}).requirejsTobeUndefined() expect({}).requireTobeUndefined() expect({}).defineTobeUndefined() @@ -27,23 +36,54 @@ describe "RequireJS namespacing", -> describe "RequireJS module creation", -> inDefineCallback = undefined inRequireCallback = undefined - it "check that we can use RequireJS define() and require() a module", -> + it "check that we can use RequireJS to define() and require() a module", -> + + # Because Require JS works asynchronously when defining and requiring + # modules, we need to use the special Jasmine functions runs(), and + # waitsFor() to set up this test. runs -> + + # Initialize the variable that we will test for. They will be set + # to true in the appropriate callback functions called by Require + # JS. If their values do not change, this will mean that something + # is not working as is intended. inDefineCallback = false inRequireCallback = false + + # Define our test module. RequireJS.define "test_module", [], -> inDefineCallback = true + + # This module returns an object. It can be accessed via the + # Require JS require() function. module_status: "OK" + + # Require our defined test module. RequireJS.require ["test_module"], (test_module) -> inRequireCallback = true + + # If our test module was defined properly, then we should + # be able to get the object it returned, and query some + # property. expect(test_module.module_status).toBe "OK" + + # We will wait for a specified amount of time (1 second), before + # checking if our module was defined and that we were able to + # require() the module. waitsFor (-> + + # If at least one of the callback functions was not reached, we + # fail this test. return false if (inDefineCallback isnt true) or (inRequireCallback isnt true) + + # Both of the callbacks were reached. true ), "We should eventually end up in the defined callback", 1000 + + # The final test behavior, after waitsFor() finishes waiting. runs -> expect(inDefineCallback).toBeTruthy() expect(inRequireCallback).toBeTruthy() From 233a35e01e1a289b0fd66d121cdf86d610774971 Mon Sep 17 00:00:00 2001 From: valera-rozuvan Date: Wed, 5 Dec 2012 11:32:10 +0200 Subject: [PATCH 30/59] Work on RequireJS Jasmine test. --- lms/static/coffee/spec/requirejs_spec.coffee | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lms/static/coffee/spec/requirejs_spec.coffee b/lms/static/coffee/spec/requirejs_spec.coffee index 3e54560d2b..10d34a2f75 100644 --- a/lms/static/coffee/spec/requirejs_spec.coffee +++ b/lms/static/coffee/spec/requirejs_spec.coffee @@ -1,10 +1,8 @@ describe "RequireJS namespacing", -> beforeEach -> - # - # * Jasmine does not provide a way to use the typeof operator. We need - # * to create our own custom matchers so that a TypeError is not thrown. - # + # Jasmine does not provide a way to use the typeof operator. We need + # to create our own custom matchers so that a TypeError is not thrown. @addMatchers requirejsTobeUndefined: -> typeof requirejs is "undefined" From 8c4edc459ce7837e58f33b230401626d2c325504 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Thu, 3 Jan 2013 11:22:21 -0500 Subject: [PATCH 31/59] update testing doc --- doc/testing.md | 52 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/doc/testing.md b/doc/testing.md index ee54ae74d9..694a9e8231 100644 --- a/doc/testing.md +++ b/doc/testing.md @@ -1,17 +1,25 @@ # Testing -Testing is good. Here is some useful info about how we set up tests-- +Testing is good. Here is some useful info about how we set up tests. +More info is [on the wiki](https://edx-wiki.atlassian.net/wiki/display/ENG/Test+Engineering) -### Backend code: +## Backend code -- TODO +- The python unit tests can be run via rake tasks. +See development.md for more info on how to do this. -### Frontend code: +## Frontend code -We're using Jasmine to unit-testing the JavaScript files. All the specs are -written in CoffeeScript for the consistency. To access the test cases, start the -server in debug mode, navigate to `http://127.0.0.1:[port number]/_jasmine` to -see the test result. +### Jasmine + +We're using Jasmine to unit/integration test the JavaScript files. +More info [on the wiki](https://edx-wiki.atlassian.net/wiki/display/ENG/Jasmine) + +All the specs are written in CoffeeScript to be consistent with the code. +To access the test cases, start the server using the settings file **jasmine.py** using this command: + `rake django-admin[runserver,lms,jasmine,12345]` + +Then navigate to `http://localhost:12345/_jasmine/` to see the test results. All the JavaScript codes must have test coverage. Both CMS and LMS has its own test directory in `{cms,lms}/static/coffee/spec` If you haven't @@ -30,3 +38,31 @@ If you're finishing a feature that contains JavaScript code snippets and do not sure how to test, please feel free to open up a pull request and asking people for help. (However, the best way to do it would be writing your test first, then implement your feature - Test Driven Development.) + +### BDD style acceptance tests with Lettuce + +We're using Lettuce for end user acceptance testing of features. +More info [on the wiki](https://edx-wiki.atlassian.net/wiki/display/ENG/Lettuce+Acceptance+Testing) + +Lettuce is a port of Cucumber. We're using it to drive Splinter, which is a python wrapper to Selenium. +To execute the automated test scripts, you'll need to start up the django server separately, then launch the tests. +Do both use the settings file named **acceptance.py**. + +What this will do is to use a sqllite database named mitx_all/db/test_mitx.db. +That way it can be flushed etc. without messing up your dev db. +Note that this also means that you need to syncdb and migrate the db first before starting the server to initialize it if it does not yet exist. + +1. Set up the test database (only needs to be done once): + rm ../db/test_mitx.db + rake django-admin[syncdb,lms,acceptance,--noinput] + rake django-admin[migrate,lms,acceptance,--noinput] + +2. Start up the django server separately in a shell + rake lms[acceptance] + +3. Then in another shell, run the tests in different ways as below. Lettuce comes with a new django-admin command called _harvest_. See the [lettuce django docs](http://lettuce.it/recipes/django-lxml.html) for more details. +* All tests in a specified feature folder: `django-admin.py harvest --no-server --settings=lms.envs.acceptance --pythonpath=. lms/djangoapps/portal/features/` +* Only the specified feature's scenarios: `django-admin.py harvest --no-server --settings=lms.envs.acceptance --pythonpath=. lms/djangoapps/courseware/features/high-level-tabs.feature` + +4. Troubleshooting +* If you get an error msg that says something about harvest not being a command, you probably are missing a requirement. Pip install (test-requirements.txt) and/or brew install as needed. \ No newline at end of file From d663d23257299d2f0a3d397b6665ee1fee5de60d Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 12 Dec 2012 10:33:36 -0500 Subject: [PATCH 32/59] Work on adding in a new ajax post type --- common/lib/capa/capa/inputtypes.py | 2 +- common/lib/capa/capa/responsetypes.py | 1 + common/lib/capa/capa/templates/openendedinput.html | 7 +++++++ common/lib/xmodule/xmodule/capa_module.py | 14 ++++++++++++++ .../lib/xmodule/xmodule/js/src/capa/display.coffee | 6 ++++++ 5 files changed, 29 insertions(+), 1 deletion(-) diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 73056bc09e..e3eb47acc5 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -748,7 +748,7 @@ class OpenEndedInput(InputTypeBase): # pulled out for testing submitted_msg = ("Feedback not yet available. Reload to check again. " "Once the problem is graded, this message will be " - "replaced with the grader's feedback") + "replaced with the grader's feedback.") @classmethod def get_attributes(cls): diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 8517e71d04..989e29c7a2 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1836,6 +1836,7 @@ class OpenEndedResponse(LoncapaResponse): """ DEFAULT_QUEUE = 'open-ended' + DEFAULT_MESSAGE_QUEUE = 'open-ended-message' response_tag = 'openendedresponse' allowed_inputfields = ['openendedinput'] max_inputfields = 1 diff --git a/common/lib/capa/capa/templates/openendedinput.html b/common/lib/capa/capa/templates/openendedinput.html index 65fc7fb9bb..3d51b7c3b2 100644 --- a/common/lib/capa/capa/templates/openendedinput.html +++ b/common/lib/capa/capa/templates/openendedinput.html @@ -28,5 +28,12 @@ % endif
    ${msg|n} + % if status in ['correct','incorrect']: +
    + Score Evaluation: + + +
    + % endif
    diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 4c10a1703a..8ce0b17190 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -380,6 +380,7 @@ class CapaModule(XModule): 'problem_save': self.save_problem, 'problem_show': self.get_answer, 'score_update': self.update_score, + 'message_post' : self.message_post, } if dispatch not in handlers: @@ -394,6 +395,19 @@ class CapaModule(XModule): }) return json.dumps(d, cls=ComplexEncoder) + def feedback_post(self, get): + """ + Posts a message from a form to an appropriate location + """ + event_info = dict() + event_info['state'] = self.lcp.get_state() + event_info['problem_id'] = self.location.url() + + answers = self.make_dict_of_responses(get) + log.debug(answers) + + + def closed(self): ''' Is the student still allowed to submit answers? ''' if self.attempts == self.max_attempts: diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 1c0ace9e59..2c676e1e52 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -25,6 +25,7 @@ class @Problem @$('section.action input.reset').click @reset @$('section.action input.show').click @show @$('section.action input.save').click @save + @$('section.evaluation input.submit-message').click @message_post # Collapsibles Collapsible.setCollapsibles(@el) @@ -197,6 +198,11 @@ class @Problem else @gentle_alert response.success + message_post: => + Logger.log 'message_post', @answers + $.postWithPrefix "#{@url}/message_post", @answers, (response) => + @gentle_alert response.success + reset: => Logger.log 'problem_reset', @answers $.postWithPrefix "#{@url}/problem_reset", id: @id, (response) => From 634b586085a3e7a0fe14f2fb6bdb3bc027992d6e Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 12 Dec 2012 10:44:17 -0500 Subject: [PATCH 33/59] Integrate new ajax action --- common/lib/capa/capa/templates/openendedinput.html | 4 ++-- common/lib/xmodule/xmodule/capa_module.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/common/lib/capa/capa/templates/openendedinput.html b/common/lib/capa/capa/templates/openendedinput.html index 3d51b7c3b2..6b2b293f42 100644 --- a/common/lib/capa/capa/templates/openendedinput.html +++ b/common/lib/capa/capa/templates/openendedinput.html @@ -29,9 +29,9 @@
    ${msg|n} % if status in ['correct','incorrect']: -
    +
    Score Evaluation: - +
    % endif diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 8ce0b17190..3f60fc77f6 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -395,7 +395,7 @@ class CapaModule(XModule): }) return json.dumps(d, cls=ComplexEncoder) - def feedback_post(self, get): + def message_post(self, get): """ Posts a message from a form to an appropriate location """ @@ -405,8 +405,7 @@ class CapaModule(XModule): answers = self.make_dict_of_responses(get) log.debug(answers) - - + log.debug(event_info) def closed(self): ''' Is the student still allowed to submit answers? ''' From b782f712b9ab481391ae6dc5e39d064c162d2291 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 12 Dec 2012 11:42:24 -0500 Subject: [PATCH 34/59] Add in more js to handle student feedback --- .../lib/capa/capa/templates/openendedinput.html | 2 +- common/lib/xmodule/xmodule/capa_module.py | 6 ++++-- .../xmodule/xmodule/js/src/capa/display.coffee | 16 ++++++++++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/common/lib/capa/capa/templates/openendedinput.html b/common/lib/capa/capa/templates/openendedinput.html index 6b2b293f42..f384cc3167 100644 --- a/common/lib/capa/capa/templates/openendedinput.html +++ b/common/lib/capa/capa/templates/openendedinput.html @@ -31,7 +31,7 @@ % if status in ['correct','incorrect']:
    Score Evaluation: - +
    % endif diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 3f60fc77f6..08f57ba14c 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -402,11 +402,13 @@ class CapaModule(XModule): event_info = dict() event_info['state'] = self.lcp.get_state() event_info['problem_id'] = self.location.url() + event_info['student_id'] = self.system.anonymous_system_id + event_info['survey_responses']= get - answers = self.make_dict_of_responses(get) - log.debug(answers) log.debug(event_info) + return {'success' : True} + def closed(self): ''' Is the student still allowed to submit answers? ''' if self.attempts == self.max_attempts: diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 2c676e1e52..e75d3a5a1d 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -200,8 +200,20 @@ class @Problem message_post: => Logger.log 'message_post', @answers - $.postWithPrefix "#{@url}/message_post", @answers, (response) => - @gentle_alert response.success + + fd = new FormData() + feedback = @$('section.evaluation textarea.feedback-on-feedback')[0] + fd.append(feedback.class, feedback.value) + + settings = + type: "POST" + data: fd + processData: false + contentType: false + success: (response) => + @gentle_alert response.success + + $.ajaxWithPrefix("#{@url}/message_post", settings) reset: => Logger.log 'problem_reset', @answers From ce3b84d0e59fc4361814bbc1b0ffcf74f110fd6e Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 12 Dec 2012 11:55:03 -0500 Subject: [PATCH 35/59] Message passing in lms --- common/lib/capa/capa/capa_problem.py | 8 ++++++++ common/lib/capa/capa/responsetypes.py | 8 +++++++- common/lib/xmodule/xmodule/capa_module.py | 4 ++-- common/lib/xmodule/xmodule/js/src/capa/display.coffee | 2 +- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 2eaa0e4286..585e087b36 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -186,6 +186,14 @@ class LoncapaProblem(object): maxscore += responder.get_max_score() return maxscore + def message_post(self,event_info): + """ + Handle an ajax post that contains feedback on feedback + """ + for responder in self.responders.values(): + if hasattr(responder, 'message_post'): + responder.message_post(event_info) + def get_score(self): """ Compute score for this problem. The score is the number of points awarded. diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 989e29c7a2..f8ee12650e 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1854,6 +1854,10 @@ class OpenEndedResponse(LoncapaResponse): prompt = self.xml.find('prompt') rubric = self.xml.find('openendedrubric') + #This is needed to attach feedback to specific responses later + self.submission_id=None + self.grader_id=None + if oeparam is None: raise ValueError("No oeparam found in problem xml.") if prompt is None: @@ -2139,13 +2143,15 @@ class OpenEndedResponse(LoncapaResponse): " Received score_result = {0}".format(score_result)) return fail - for tag in ['score', 'feedback', 'grader_type', 'success']: + for tag in ['score', 'feedback', 'grader_type', 'success', 'grader_id', 'submission_id']: if tag not in score_result: log.error("External grader message is missing required tag: {0}" .format(tag)) return fail feedback = self._format_feedback(score_result) + self.submission_id=score_result['submission_id'] + self.grader_id=score_result['grader_id'] # HACK: for now, just assume it's correct if you got more than 2/3. # Also assumes that score_result['score'] is an integer. diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 08f57ba14c..727449ea99 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -402,10 +402,10 @@ class CapaModule(XModule): event_info = dict() event_info['state'] = self.lcp.get_state() event_info['problem_id'] = self.location.url() - event_info['student_id'] = self.system.anonymous_system_id + event_info['student_id'] = self.system.anonymous_student_id event_info['survey_responses']= get - log.debug(event_info) + success_dict = self.lcp.message_post(event_info) return {'success' : True} diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index e75d3a5a1d..204080dd64 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -203,7 +203,7 @@ class @Problem fd = new FormData() feedback = @$('section.evaluation textarea.feedback-on-feedback')[0] - fd.append(feedback.class, feedback.value) + fd.append('feedback', feedback.value) settings = type: "POST" From b71233fd5054dffb9564817cb75ce31564d4dd4c Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 12 Dec 2012 12:46:07 -0500 Subject: [PATCH 36/59] Add in message passing pipeline --- common/lib/capa/capa/capa_problem.py | 14 ++++- common/lib/capa/capa/responsetypes.py | 62 ++++++++++++++++++- common/lib/xmodule/xmodule/capa_module.py | 4 +- .../xmodule/js/src/capa/display.coffee | 8 ++- 4 files changed, 79 insertions(+), 9 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 585e087b36..9c383d114d 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -189,10 +189,20 @@ class LoncapaProblem(object): def message_post(self,event_info): """ Handle an ajax post that contains feedback on feedback + Returns a boolean success variable + Note: This only allows for feedback to be posted back to the grading controller for the first + open ended response problem on each page. Multiple problems will cause some sync issues. + TODO: Handle multiple problems on one page sync issues. """ + success=False + message = "" + log.debug("in lcp") for responder in self.responders.values(): - if hasattr(responder, 'message_post'): - responder.message_post(event_info) + if hasattr(responder, 'handle_message_post'): + success, message = responder.handle_message_post(event_info) + if success: + break + return success, message def get_score(self): """ diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index f8ee12650e..038586f7f4 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1848,6 +1848,7 @@ class OpenEndedResponse(LoncapaResponse): xml = self.xml self.url = xml.get('url', None) self.queue_name = xml.get('queuename', self.DEFAULT_QUEUE) + self.message_queue_name = xml.get('message-queuename', self.DEFAULT_MESSAGE_QUEUE) # The openendedparam tag encapsulates all grader settings oeparam = self.xml.find('openendedparam') @@ -1921,6 +1922,52 @@ class OpenEndedResponse(LoncapaResponse): except ValueError: self.max_score = 1 + def handle_message_post(self,event_info): + """ + Handles a student message post (a reaction to the grade they received from an open ended grader type) + Returns a boolean success/fail and an error message + """ + survey_responses=event_info['survey_responses'] + for tag in ['feedback', 'submission_id', 'grader_id']: + if tag not in survey_responses: + return False, "Could not find needed tag {0}".format(tag) + try: + submission_id=int(survey_responses['submission_id'][0]) + grader_id = int(survey_responses['grader_id'][0]) + feedback = str(survey_responses['feedback'][0]) + except: + error_message="Could not parse submission id, grader id, or feedback from message_post ajax call." + log.exception(error_message) + return False, error_message + + qinterface = self.system.xqueue['interface'] + qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat) + anonymous_student_id = self.system.anonymous_student_id + queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime + + anonymous_student_id + + self.answer_id) + + xheader = xqueue_interface.make_xheader(lms_key=queuekey,queue_name=self.message_queue_name) + student_info = {'anonymous_student_id': anonymous_student_id, + 'submission_time': qtime, + } + contents= { + 'feedback' : feedback, + 'submission_id' : submission_id, + 'grader_id' : grader_id, + 'student_info' : json.dumps(student_info), + } + + (error, msg) = qinterface.send_to_queue(header=xheader, + body=json.dumps(contents)) + + #Convert error to a success value + success=True + if error: + success=False + + return success, "Successfully sent to queue." + def get_score(self, student_answers): try: @@ -2068,11 +2115,18 @@ class OpenEndedResponse(LoncapaResponse):
    """.format(feedback_type=feedback_type, value=value) + def format_feedback_hidden(feedback_type , value): + return """ + + """.format(feedback_type=feedback_type, value=value) + # TODO (vshnayder): design and document the details of this format so # that we can do proper escaping here (e.g. are the graders allowed to # include HTML?) - for tag in ['success', 'feedback']: + for tag in ['success', 'feedback', 'submission_id', 'grader_id']: if tag not in response_items: return format_feedback('errors', 'Error getting feedback') @@ -2088,10 +2142,12 @@ class OpenEndedResponse(LoncapaResponse): return format_feedback('errors', 'No feedback available') feedback_lst = sorted(feedback.items(), key=get_priority) - return u"\n".join(format_feedback(k, v) for k, v in feedback_lst) + feedback_list_part1 = u"\n".join(format_feedback(k, v) for k, v in feedback_lst) else: - return format_feedback('errors', response_items['feedback']) + feedback_list_part1 = format_feedback('errors', response_items['feedback']) + feedback_list_part2=u"\n".join([format_feedback_hidden(k,response_items[k]) for k in response_items.keys() if k in ['submission_id', 'grader_id']]) + return u"\n".join([feedback_list_part1,feedback_list_part2]) def _format_feedback(self, response_items): """ diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 727449ea99..8d72fdf01c 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -405,9 +405,9 @@ class CapaModule(XModule): event_info['student_id'] = self.system.anonymous_student_id event_info['survey_responses']= get - success_dict = self.lcp.message_post(event_info) + success, message = self.lcp.message_post(event_info) - return {'success' : True} + return {'success' : success} def closed(self): ''' Is the student still allowed to submit answers? ''' diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 204080dd64..393435a2e4 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -202,8 +202,12 @@ class @Problem Logger.log 'message_post', @answers fd = new FormData() - feedback = @$('section.evaluation textarea.feedback-on-feedback')[0] - fd.append('feedback', feedback.value) + feedback = @$('section.evaluation textarea.feedback-on-feedback')[0].value + submission_id = $('div.external-grader-message div.submission_id')[0].innerHTML + grader_id = $('div.external-grader-message div.grader_id')[0].innerHTML + fd.append('feedback', feedback) + fd.append('submission_id', submission_id) + fd.append('grader_id', grader_id) settings = type: "POST" From b06ce84afdf7b76e5b824b637a0acac8d634fd66 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 12 Dec 2012 12:58:29 -0500 Subject: [PATCH 37/59] Wire in submit message --- common/lib/capa/capa/capa_problem.py | 2 +- common/lib/capa/capa/responsetypes.py | 16 +++++++++++----- common/lib/xmodule/xmodule/capa_module.py | 2 +- .../xmodule/xmodule/js/src/capa/display.coffee | 5 ++++- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 9c383d114d..efc96fc717 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -195,7 +195,7 @@ class LoncapaProblem(object): TODO: Handle multiple problems on one page sync issues. """ success=False - message = "" + message = "Could not find a valid responder." log.debug("in lcp") for responder in self.responders.values(): if hasattr(responder, 'handle_message_post'): diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 038586f7f4..e9a363adba 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1932,9 +1932,10 @@ class OpenEndedResponse(LoncapaResponse): if tag not in survey_responses: return False, "Could not find needed tag {0}".format(tag) try: - submission_id=int(survey_responses['submission_id'][0]) - grader_id = int(survey_responses['grader_id'][0]) - feedback = str(survey_responses['feedback'][0]) + log.debug(survey_responses['submission_id']) + submission_id=int(survey_responses['submission_id']) + grader_id = int(survey_responses['grader_id']) + feedback = str(survey_responses['feedback']) except: error_message="Could not parse submission id, grader id, or feedback from message_post ajax call." log.exception(error_message) @@ -1947,7 +1948,12 @@ class OpenEndedResponse(LoncapaResponse): anonymous_student_id + self.answer_id) - xheader = xqueue_interface.make_xheader(lms_key=queuekey,queue_name=self.message_queue_name) + xheader = xqueue_interface.make_xheader( + lms_callback_url=self.system.xqueue['callback_url'], + lms_key=queuekey, + queue_name=self.message_queue_name + ) + student_info = {'anonymous_student_id': anonymous_student_id, 'submission_time': qtime, } @@ -1966,7 +1972,7 @@ class OpenEndedResponse(LoncapaResponse): if error: success=False - return success, "Successfully sent to queue." + return success, "Successfully submitted your feedback." def get_score(self, student_answers): diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 8d72fdf01c..d65fa1f40a 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -407,7 +407,7 @@ class CapaModule(XModule): success, message = self.lcp.message_post(event_info) - return {'success' : success} + return {'success' : success, 'message' : message} def closed(self): ''' Is the student still allowed to submit answers? ''' diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 393435a2e4..1783df0e04 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -215,7 +215,10 @@ class @Problem processData: false contentType: false success: (response) => - @gentle_alert response.success + @gentle_alert response.message + switch response.success + when 'True', 'true' + @$('section.evaluation input.submit-message').hide() $.ajaxWithPrefix("#{@url}/message_post", settings) From ad415583791575524554bb26196ed8b6dd8f3723 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 12 Dec 2012 13:30:20 -0500 Subject: [PATCH 38/59] Hide submit button --- common/lib/xmodule/xmodule/js/src/capa/display.coffee | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 1783df0e04..0364efe526 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -216,9 +216,7 @@ class @Problem contentType: false success: (response) => @gentle_alert response.message - switch response.success - when 'True', 'true' - @$('section.evaluation input.submit-message').hide() + @$('section.evaluation input.submit-message').hide() $.ajaxWithPrefix("#{@url}/message_post", settings) From fa97187a3136ce33b2f2f9740bd4d69b0c18b050 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 12 Dec 2012 14:10:00 -0500 Subject: [PATCH 39/59] Patch naming issue --- common/lib/capa/capa/templates/openendedinput.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/capa/capa/templates/openendedinput.html b/common/lib/capa/capa/templates/openendedinput.html index f384cc3167..e2353d72a3 100644 --- a/common/lib/capa/capa/templates/openendedinput.html +++ b/common/lib/capa/capa/templates/openendedinput.html @@ -31,7 +31,7 @@ % if status in ['correct','incorrect']:
    Score Evaluation: - +
    % endif From 54c541de456d4535bbb06c6536c70471982514da Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Thu, 13 Dec 2012 15:28:04 -0500 Subject: [PATCH 40/59] Fix up the feedback-on-feedback styling and add radio button score selection. --- .../capa/capa/templates/openendedinput.html | 16 +++++++-- .../lib/xmodule/xmodule/css/capa/display.scss | 36 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/common/lib/capa/capa/templates/openendedinput.html b/common/lib/capa/capa/templates/openendedinput.html index e2353d72a3..9f745bda72 100644 --- a/common/lib/capa/capa/templates/openendedinput.html +++ b/common/lib/capa/capa/templates/openendedinput.html @@ -30,9 +30,21 @@ ${msg|n} % if status in ['correct','incorrect']:
    - Score Evaluation: + How accurate do you think this grading is? +
    +
      +
    • +
    • +
    • +
    • +
    • +
    +
    + Additional comments: - +
    + +
    % endif
    diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index b25ab3d3a2..18f1604edc 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -297,6 +297,42 @@ section.problem { float: left; } } + + } + .evaluation { + p { + margin-bottom: 4px; + } + } + + + .feedback-on-feedback { + height: 100px; + margin-right: 20px; + } + + .evaluation-scoring { + .scoring-list { + list-style-type: none; + margin-left: 3px; + + li { + &:first-child { + margin-left: 0px; + } + display:inline; + margin-left: 50px; + + label { + font-size: .9em; + } + + } + } + + } + .submit-message-container { + margin: 10px 0px ; } } From 69ae1cc268192182e6f447d8ad64bff133b98fa8 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Thu, 13 Dec 2012 17:01:21 -0500 Subject: [PATCH 41/59] Clean up the styling and make the evaluation parts collapsible. --- common/lib/capa/capa/templates/openendedinput.html | 7 ++++++- common/lib/xmodule/xmodule/css/capa/display.scss | 13 +++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/common/lib/capa/capa/templates/openendedinput.html b/common/lib/capa/capa/templates/openendedinput.html index 9f745bda72..58cfec06a9 100644 --- a/common/lib/capa/capa/templates/openendedinput.html +++ b/common/lib/capa/capa/templates/openendedinput.html @@ -27,8 +27,12 @@ % endif
    - ${msg|n} + ${msg|n} % if status in ['correct','incorrect']: +
    +
    + Respond to Feedback +
    How accurate do you think this grading is?
    @@ -46,6 +50,7 @@
    +
    % endif
    diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index 18f1604edc..929b6dcb48 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -311,6 +311,15 @@ section.problem { margin-right: 20px; } + .evaluation-response { + header { + text-align: right; + a { + font-size: .85em; + } + } + } + .evaluation-scoring { .scoring-list { list-style-type: none; @@ -670,6 +679,10 @@ section.problem { color: #2C2C2C; font-family: monospace; font-size: 1em; + padding-top: 10px; + header { + font-size: 1.4em; + } .shortform { font-weight: bold; From 8f4efb045695806e23ab444b2c2f1d3adafe7a22 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 14 Dec 2012 09:47:40 -0500 Subject: [PATCH 42/59] Hook up new radio buttons to the Javascript --- common/lib/capa/capa/templates/openendedinput.html | 4 ++-- common/lib/xmodule/xmodule/js/src/capa/display.coffee | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/common/lib/capa/capa/templates/openendedinput.html b/common/lib/capa/capa/templates/openendedinput.html index 58cfec06a9..aff55d6740 100644 --- a/common/lib/capa/capa/templates/openendedinput.html +++ b/common/lib/capa/capa/templates/openendedinput.html @@ -34,7 +34,7 @@ Respond to Feedback
    - How accurate do you think this grading is? +

    How accurate do you think this feedback is?

    • @@ -44,7 +44,7 @@
    - Additional comments: +

    Additional comments:

    diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 0364efe526..72294e8ff9 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -205,9 +205,13 @@ class @Problem feedback = @$('section.evaluation textarea.feedback-on-feedback')[0].value submission_id = $('div.external-grader-message div.submission_id')[0].innerHTML grader_id = $('div.external-grader-message div.grader_id')[0].innerHTML + score = $(".evaluation-scoring input:radio[name='evaluation-score']:checked").val() fd.append('feedback', feedback) fd.append('submission_id', submission_id) fd.append('grader_id', grader_id) + if(score) + fd.append('score', score) + settings = type: "POST" @@ -216,7 +220,7 @@ class @Problem contentType: false success: (response) => @gentle_alert response.message - @$('section.evaluation input.submit-message').hide() + @$('section.evaluation').slideToggle() $.ajaxWithPrefix("#{@url}/message_post", settings) From de7d6f184a93f451cd9158641481ba41de493402 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 14 Dec 2012 09:59:59 -0500 Subject: [PATCH 43/59] Parse out the score given into an int. --- common/lib/xmodule/xmodule/js/src/capa/display.coffee | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 72294e8ff9..932df2fda9 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -209,8 +209,8 @@ class @Problem fd.append('feedback', feedback) fd.append('submission_id', submission_id) fd.append('grader_id', grader_id) - if(score) - fd.append('score', score) + if(score && parseInt(score) != NaN) + fd.append('score', parseInt(score)) settings = From c87d0f11d62ac725bfc0f39fdb1c7fbabf73a7f2 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 14 Dec 2012 10:48:22 -0500 Subject: [PATCH 44/59] Show error when no score selected --- common/lib/xmodule/xmodule/js/src/capa/display.coffee | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 932df2fda9..441d8b7634 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -209,8 +209,9 @@ class @Problem fd.append('feedback', feedback) fd.append('submission_id', submission_id) fd.append('grader_id', grader_id) - if(score && parseInt(score) != NaN) - fd.append('score', parseInt(score)) + if(!score || parseInt(score) == NaN) + @gentle_alert "You need to pick a rating to submit." + return settings = From 09ed3d12a29e61518f4a1cd57347714c326e8435 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 14 Dec 2012 12:00:42 -0500 Subject: [PATCH 45/59] Hook up the backend into the new feedback response for scores --- common/lib/capa/capa/responsetypes.py | 4 +++- common/lib/capa/capa/templates/openendedinput.html | 2 +- common/lib/xmodule/xmodule/js/src/capa/display.coffee | 6 ++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index e9a363adba..81843b6edf 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1928,7 +1928,7 @@ class OpenEndedResponse(LoncapaResponse): Returns a boolean success/fail and an error message """ survey_responses=event_info['survey_responses'] - for tag in ['feedback', 'submission_id', 'grader_id']: + for tag in ['feedback', 'submission_id', 'grader_id', 'score']: if tag not in survey_responses: return False, "Could not find needed tag {0}".format(tag) try: @@ -1936,6 +1936,7 @@ class OpenEndedResponse(LoncapaResponse): submission_id=int(survey_responses['submission_id']) grader_id = int(survey_responses['grader_id']) feedback = str(survey_responses['feedback']) + score = int(survey_responses['score']) except: error_message="Could not parse submission id, grader id, or feedback from message_post ajax call." log.exception(error_message) @@ -1961,6 +1962,7 @@ class OpenEndedResponse(LoncapaResponse): 'feedback' : feedback, 'submission_id' : submission_id, 'grader_id' : grader_id, + 'score': score, 'student_info' : json.dumps(student_info), } diff --git a/common/lib/capa/capa/templates/openendedinput.html b/common/lib/capa/capa/templates/openendedinput.html index aff55d6740..27042fda85 100644 --- a/common/lib/capa/capa/templates/openendedinput.html +++ b/common/lib/capa/capa/templates/openendedinput.html @@ -34,7 +34,7 @@ Respond to Feedback
    -

    How accurate do you think this feedback is?

    +

    How accurate do you find this feedback?

    • diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 441d8b7634..005840e9d3 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -209,9 +209,11 @@ class @Problem fd.append('feedback', feedback) fd.append('submission_id', submission_id) fd.append('grader_id', grader_id) - if(!score || parseInt(score) == NaN) - @gentle_alert "You need to pick a rating to submit." + if(!score) + @gentle_alert "You need to pick a rating before you can submit." return + else + fd.append('score', score) settings = From 1ac75b94209a0d4a7ed91348f3c585ee983b56d4 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 12 Dec 2012 12:46:07 -0500 Subject: [PATCH 46/59] Add in message passing pipeline --- common/lib/capa/capa/responsetypes.py | 69 ++------------------------- 1 file changed, 3 insertions(+), 66 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 81843b6edf..720620dbe1 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1922,60 +1922,6 @@ class OpenEndedResponse(LoncapaResponse): except ValueError: self.max_score = 1 - def handle_message_post(self,event_info): - """ - Handles a student message post (a reaction to the grade they received from an open ended grader type) - Returns a boolean success/fail and an error message - """ - survey_responses=event_info['survey_responses'] - for tag in ['feedback', 'submission_id', 'grader_id', 'score']: - if tag not in survey_responses: - return False, "Could not find needed tag {0}".format(tag) - try: - log.debug(survey_responses['submission_id']) - submission_id=int(survey_responses['submission_id']) - grader_id = int(survey_responses['grader_id']) - feedback = str(survey_responses['feedback']) - score = int(survey_responses['score']) - except: - error_message="Could not parse submission id, grader id, or feedback from message_post ajax call." - log.exception(error_message) - return False, error_message - - qinterface = self.system.xqueue['interface'] - qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat) - anonymous_student_id = self.system.anonymous_student_id - queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime + - anonymous_student_id + - self.answer_id) - - xheader = xqueue_interface.make_xheader( - lms_callback_url=self.system.xqueue['callback_url'], - lms_key=queuekey, - queue_name=self.message_queue_name - ) - - student_info = {'anonymous_student_id': anonymous_student_id, - 'submission_time': qtime, - } - contents= { - 'feedback' : feedback, - 'submission_id' : submission_id, - 'grader_id' : grader_id, - 'score': score, - 'student_info' : json.dumps(student_info), - } - - (error, msg) = qinterface.send_to_queue(header=xheader, - body=json.dumps(contents)) - - #Convert error to a success value - success=True - if error: - success=False - - return success, "Successfully submitted your feedback." - def get_score(self, student_answers): try: @@ -2123,18 +2069,11 @@ class OpenEndedResponse(LoncapaResponse):
    """.format(feedback_type=feedback_type, value=value) - def format_feedback_hidden(feedback_type , value): - return """ - - """.format(feedback_type=feedback_type, value=value) - # TODO (vshnayder): design and document the details of this format so # that we can do proper escaping here (e.g. are the graders allowed to # include HTML?) - for tag in ['success', 'feedback', 'submission_id', 'grader_id']: + for tag in ['success', 'feedback']: if tag not in response_items: return format_feedback('errors', 'Error getting feedback') @@ -2150,12 +2089,10 @@ class OpenEndedResponse(LoncapaResponse): return format_feedback('errors', 'No feedback available') feedback_lst = sorted(feedback.items(), key=get_priority) - feedback_list_part1 = u"\n".join(format_feedback(k, v) for k, v in feedback_lst) + return u"\n".join(format_feedback(k, v) for k, v in feedback_lst) else: - feedback_list_part1 = format_feedback('errors', response_items['feedback']) + return format_feedback('errors', response_items['feedback']) - feedback_list_part2=u"\n".join([format_feedback_hidden(k,response_items[k]) for k in response_items.keys() if k in ['submission_id', 'grader_id']]) - return u"\n".join([feedback_list_part1,feedback_list_part2]) def _format_feedback(self, response_items): """ From 2c469cad752e1b77d8276a379df65c4c782afbf2 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 12 Dec 2012 12:58:29 -0500 Subject: [PATCH 47/59] Wire in submit message --- common/lib/capa/capa/responsetypes.py | 61 +++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 720620dbe1..038586f7f4 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1922,6 +1922,52 @@ class OpenEndedResponse(LoncapaResponse): except ValueError: self.max_score = 1 + def handle_message_post(self,event_info): + """ + Handles a student message post (a reaction to the grade they received from an open ended grader type) + Returns a boolean success/fail and an error message + """ + survey_responses=event_info['survey_responses'] + for tag in ['feedback', 'submission_id', 'grader_id']: + if tag not in survey_responses: + return False, "Could not find needed tag {0}".format(tag) + try: + submission_id=int(survey_responses['submission_id'][0]) + grader_id = int(survey_responses['grader_id'][0]) + feedback = str(survey_responses['feedback'][0]) + except: + error_message="Could not parse submission id, grader id, or feedback from message_post ajax call." + log.exception(error_message) + return False, error_message + + qinterface = self.system.xqueue['interface'] + qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat) + anonymous_student_id = self.system.anonymous_student_id + queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime + + anonymous_student_id + + self.answer_id) + + xheader = xqueue_interface.make_xheader(lms_key=queuekey,queue_name=self.message_queue_name) + student_info = {'anonymous_student_id': anonymous_student_id, + 'submission_time': qtime, + } + contents= { + 'feedback' : feedback, + 'submission_id' : submission_id, + 'grader_id' : grader_id, + 'student_info' : json.dumps(student_info), + } + + (error, msg) = qinterface.send_to_queue(header=xheader, + body=json.dumps(contents)) + + #Convert error to a success value + success=True + if error: + success=False + + return success, "Successfully sent to queue." + def get_score(self, student_answers): try: @@ -2069,11 +2115,18 @@ class OpenEndedResponse(LoncapaResponse):
    """.format(feedback_type=feedback_type, value=value) + def format_feedback_hidden(feedback_type , value): + return """ + + """.format(feedback_type=feedback_type, value=value) + # TODO (vshnayder): design and document the details of this format so # that we can do proper escaping here (e.g. are the graders allowed to # include HTML?) - for tag in ['success', 'feedback']: + for tag in ['success', 'feedback', 'submission_id', 'grader_id']: if tag not in response_items: return format_feedback('errors', 'Error getting feedback') @@ -2089,10 +2142,12 @@ class OpenEndedResponse(LoncapaResponse): return format_feedback('errors', 'No feedback available') feedback_lst = sorted(feedback.items(), key=get_priority) - return u"\n".join(format_feedback(k, v) for k, v in feedback_lst) + feedback_list_part1 = u"\n".join(format_feedback(k, v) for k, v in feedback_lst) else: - return format_feedback('errors', response_items['feedback']) + feedback_list_part1 = format_feedback('errors', response_items['feedback']) + feedback_list_part2=u"\n".join([format_feedback_hidden(k,response_items[k]) for k in response_items.keys() if k in ['submission_id', 'grader_id']]) + return u"\n".join([feedback_list_part1,feedback_list_part2]) def _format_feedback(self, response_items): """ From 11c330bf52a48cfd437e21cd31c12e56aa093b06 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 14 Dec 2012 17:33:54 -0500 Subject: [PATCH 48/59] Fix rebase issues --- common/lib/xmodule/xmodule/js/src/capa/display.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 005840e9d3..ba746fecb8 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -211,7 +211,7 @@ class @Problem fd.append('grader_id', grader_id) if(!score) @gentle_alert "You need to pick a rating before you can submit." - return + return else fd.append('score', score) From 5caa42fbe39271c4d10bd069da21feb931ce9b6b Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 14 Dec 2012 17:39:56 -0500 Subject: [PATCH 49/59] Reset some files --- lms/static/coffee/src/discussion/utils.coffee | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lms/static/coffee/src/discussion/utils.coffee b/lms/static/coffee/src/discussion/utils.coffee index 6b2714dc54..a032c0248f 100644 --- a/lms/static/coffee/src/discussion/utils.coffee +++ b/lms/static/coffee/src/discussion/utils.coffee @@ -249,10 +249,7 @@ class @DiscussionUtil $3 else if RE_DISPLAYMATH.test(text) text = text.replace RE_DISPLAYMATH, ($0, $1, $2, $3) -> - #processedText += $1 + processor("$$" + $2 + "$$", 'display') - #bug fix, ordering is off - processedText = processor("$$" + $2 + "$$", 'display') + processedText - processedText = $1 + processedText + processedText += $1 + processor("$$" + $2 + "$$", 'display') $3 else processedText += text From 37af39ef8d3eaa0ada0063d133327211fb976c5a Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 2 Jan 2013 12:19:24 -0500 Subject: [PATCH 50/59] Change names --- common/lib/capa/capa/templates/openendedinput.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/common/lib/capa/capa/templates/openendedinput.html b/common/lib/capa/capa/templates/openendedinput.html index 27042fda85..c42ad73faf 100644 --- a/common/lib/capa/capa/templates/openendedinput.html +++ b/common/lib/capa/capa/templates/openendedinput.html @@ -37,11 +37,11 @@

    How accurate do you find this feedback?

      -
    • -
    • -
    • -
    • -
    • +
    • +
    • +
    • +
    • +

    Additional comments:

    From 9559c7b426eb8a6d8b8ff3cb4211a517154a7bed Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 3 Jan 2013 09:41:46 -0500 Subject: [PATCH 51/59] Patch "correct" answer display --- common/lib/capa/capa/responsetypes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 038586f7f4..1e2dd77d60 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -2211,7 +2211,7 @@ class OpenEndedResponse(LoncapaResponse): # HACK: for now, just assume it's correct if you got more than 2/3. # Also assumes that score_result['score'] is an integer. - score_ratio = int(score_result['score']) / self.max_score + score_ratio = int(score_result['score']) / float(self.max_score) correct = (score_ratio >= 0.66) #Currently ignore msg and only return feedback (which takes the place of msg) From a4eb76017b4f9021910e330efdd1460a02f70742 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 3 Jan 2013 09:51:04 -0500 Subject: [PATCH 52/59] Display max score as well as score --- common/lib/capa/capa/responsetypes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 1e2dd77d60..9b222ccce1 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -2165,7 +2165,7 @@ class OpenEndedResponse(LoncapaResponse): feedback_template = self.system.render_template("open_ended_feedback.html", { 'grader_type': response_items['grader_type'], - 'score': response_items['score'], + 'score': "{0} / {1}".format(response_items['score'], self.max_score), 'feedback': feedback, }) From 68f0f7e0a8764f38ca98572c94cf0d0a982752d8 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 3 Jan 2013 10:54:47 -0500 Subject: [PATCH 53/59] Handle unicode feedback --- common/lib/capa/capa/responsetypes.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 9b222ccce1..a67c0498cd 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -2108,19 +2108,30 @@ class OpenEndedResponse(LoncapaResponse): """ return priorities.get(elt[0], default_priority) + def encode_values(feedback_type,value): + feedback_type=str(feedback_type).encode('ascii', 'ignore') + if not isinstance(value,basestring): + value=str(value) + value=value.encode('ascii', 'ignore') + return feedback_type,value + def format_feedback(feedback_type, value): - return """ + feedback_type,value=encode_values(feedback_type,value) + feedback= """
    {value}
    """.format(feedback_type=feedback_type, value=value) + return cgi.escape(feedback) def format_feedback_hidden(feedback_type , value): - return """ + feedback_type,value=encode_values(feedback_type,value) + feedback = """ """.format(feedback_type=feedback_type, value=value) + return cgi.escape(feedback) # TODO (vshnayder): design and document the details of this format so # that we can do proper escaping here (e.g. are the graders allowed to From cce6b70103651d91e15b562f70a47444741ba27c Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 3 Jan 2013 11:11:45 -0500 Subject: [PATCH 54/59] Fixes to log entire state on errors, format feedback better --- common/lib/capa/capa/responsetypes.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index a67c0498cd..341563a24e 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1928,13 +1928,15 @@ class OpenEndedResponse(LoncapaResponse): Returns a boolean success/fail and an error message """ survey_responses=event_info['survey_responses'] - for tag in ['feedback', 'submission_id', 'grader_id']: + for tag in ['feedback', 'submission_id', 'grader_id', 'score']: if tag not in survey_responses: return False, "Could not find needed tag {0}".format(tag) try: - submission_id=int(survey_responses['submission_id'][0]) - grader_id = int(survey_responses['grader_id'][0]) - feedback = str(survey_responses['feedback'][0]) + log.debug(survey_responses['submission_id']) + submission_id=int(survey_responses['submission_id']) + grader_id = int(survey_responses['grader_id']) + feedback = str(survey_responses['feedback']) + score = int(survey_responses['score']) except: error_message="Could not parse submission id, grader id, or feedback from message_post ajax call." log.exception(error_message) @@ -1947,7 +1949,12 @@ class OpenEndedResponse(LoncapaResponse): anonymous_student_id + self.answer_id) - xheader = xqueue_interface.make_xheader(lms_key=queuekey,queue_name=self.message_queue_name) + xheader = xqueue_interface.make_xheader( + lms_callback_url=self.system.xqueue['callback_url'], + lms_key=queuekey, + queue_name=self.message_queue_name + ) + student_info = {'anonymous_student_id': anonymous_student_id, 'submission_time': qtime, } @@ -1955,6 +1962,7 @@ class OpenEndedResponse(LoncapaResponse): 'feedback' : feedback, 'submission_id' : submission_id, 'grader_id' : grader_id, + 'score': score, 'student_info' : json.dumps(student_info), } @@ -1966,7 +1974,7 @@ class OpenEndedResponse(LoncapaResponse): if error: success=False - return success, "Successfully sent to queue." + return success, "Successfully submitted your feedback." def get_score(self, student_answers): From 0c3eb67d5c2b4f04f847e8fe523f873b62a05e37 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 3 Jan 2013 11:15:00 -0500 Subject: [PATCH 55/59] More descriptive error message --- common/lib/capa/capa/responsetypes.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 341563a24e..d279b7e9e2 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1932,13 +1932,13 @@ class OpenEndedResponse(LoncapaResponse): if tag not in survey_responses: return False, "Could not find needed tag {0}".format(tag) try: - log.debug(survey_responses['submission_id']) submission_id=int(survey_responses['submission_id']) grader_id = int(survey_responses['grader_id']) feedback = str(survey_responses['feedback']) score = int(survey_responses['score']) except: - error_message="Could not parse submission id, grader id, or feedback from message_post ajax call." + error_message=("Could not parse submission id, grader id, " + "or feedback from message_post ajax call. Here is the message data: {0}".format(survey_responses)) log.exception(error_message) return False, error_message @@ -2130,7 +2130,7 @@ class OpenEndedResponse(LoncapaResponse): {value}
    """.format(feedback_type=feedback_type, value=value) - return cgi.escape(feedback) + return feedback def format_feedback_hidden(feedback_type , value): feedback_type,value=encode_values(feedback_type,value) @@ -2139,7 +2139,7 @@ class OpenEndedResponse(LoncapaResponse): {value}
    """.format(feedback_type=feedback_type, value=value) - return cgi.escape(feedback) + return feedback # TODO (vshnayder): design and document the details of this format so # that we can do proper escaping here (e.g. are the graders allowed to @@ -2165,7 +2165,10 @@ class OpenEndedResponse(LoncapaResponse): else: feedback_list_part1 = format_feedback('errors', response_items['feedback']) - feedback_list_part2=u"\n".join([format_feedback_hidden(k,response_items[k]) for k in response_items.keys() if k in ['submission_id', 'grader_id']]) + feedback_list_part2=(u"\n".join([format_feedback_hidden(feedback_type,value) + for feedback_type,value in response_items.items() + if feedback_type in ['submission_id', 'grader_id']])) + return u"\n".join([feedback_list_part1,feedback_list_part2]) def _format_feedback(self, response_items): From 5567477c6737aa5b1c53a8fabf1180105b93a84a Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 3 Jan 2013 11:19:25 -0500 Subject: [PATCH 56/59] Pass through initial display and answer --- common/lib/capa/capa/responsetypes.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index d279b7e9e2..443f661418 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1905,18 +1905,22 @@ class OpenEndedResponse(LoncapaResponse): # response types) except TypeError, ValueError: log.exception("Grader payload %r is not a json object!", grader_payload) + + self.initial_display = find_with_default(oeparam, 'initial_display', '') + self.answer = find_with_default(oeparam, 'answer_display', 'No answer given.') + parsed_grader_payload.update({ 'location' : self.system.location, 'course_id' : self.system.course_id, 'prompt' : prompt_string, 'rubric' : rubric_string, - }) + 'initial_display' : self.initial_display, + 'answer' : self.answer, + }) updated_grader_payload = json.dumps(parsed_grader_payload) self.payload = {'grader_payload': updated_grader_payload} - self.initial_display = find_with_default(oeparam, 'initial_display', '') - self.answer = find_with_default(oeparam, 'answer_display', 'No answer given.') try: self.max_score = int(find_with_default(oeparam, 'max_score', 1)) except ValueError: @@ -2016,7 +2020,7 @@ class OpenEndedResponse(LoncapaResponse): contents.update({ 'student_info': json.dumps(student_info), 'student_response': submission, - 'max_score' : self.max_score + 'max_score' : self.max_score, }) # Submit request. When successful, 'msg' is the prior length of the queue From b01666f6d686816b62d734c490bce0ad2461c2c8 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 3 Jan 2013 11:23:20 -0500 Subject: [PATCH 57/59] Fix error messages --- common/lib/capa/capa/responsetypes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 443f661418..3e79ca2084 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1938,13 +1938,13 @@ class OpenEndedResponse(LoncapaResponse): try: submission_id=int(survey_responses['submission_id']) grader_id = int(survey_responses['grader_id']) - feedback = str(survey_responses['feedback']) + feedback = str(survey_responses['feedback'].encode('ascii', 'ignore')) score = int(survey_responses['score']) except: error_message=("Could not parse submission id, grader id, " "or feedback from message_post ajax call. Here is the message data: {0}".format(survey_responses)) log.exception(error_message) - return False, error_message + return False, "There was an error saving your feedback. Please contact course staff." qinterface = self.system.xqueue['interface'] qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat) From 90d902d105386f04d40fee1852d082572ef3e240 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 3 Jan 2013 12:11:27 -0500 Subject: [PATCH 58/59] Fix file version --- lms/static/coffee/src/discussion/utils.coffee | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lms/static/coffee/src/discussion/utils.coffee b/lms/static/coffee/src/discussion/utils.coffee index a032c0248f..6b2714dc54 100644 --- a/lms/static/coffee/src/discussion/utils.coffee +++ b/lms/static/coffee/src/discussion/utils.coffee @@ -249,7 +249,10 @@ class @DiscussionUtil $3 else if RE_DISPLAYMATH.test(text) text = text.replace RE_DISPLAYMATH, ($0, $1, $2, $3) -> - processedText += $1 + processor("$$" + $2 + "$$", 'display') + #processedText += $1 + processor("$$" + $2 + "$$", 'display') + #bug fix, ordering is off + processedText = processor("$$" + $2 + "$$", 'display') + processedText + processedText = $1 + processedText $3 else processedText += text From fd902165c099d0eacb7d4292de575fcbad71a855 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Thu, 3 Jan 2013 12:34:54 -0500 Subject: [PATCH 59/59] Add splinter to test-requirements.txt --- test-requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/test-requirements.txt b/test-requirements.txt index e61bd1398f..b34c06a788 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -6,3 +6,4 @@ pep8 lettuce selenium factory_boy +splinter \ No newline at end of file