From 8423816076f48539a5035f3b4a011bbecd3c52ff Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 15 Mar 2013 11:33:04 -0400 Subject: [PATCH] LMS contentstore lettuce tests now dynamically create courses in mongo using terrain.factories.py and capa.tests.response_xml_factory --- common/djangoapps/terrain/factories.py | 33 +++- common/djangoapps/terrain/steps.py | 28 ++-- .../capa/capa/tests/response_xml_factory.py | 8 +- lms/djangoapps/courseware/features/common.py | 77 +++++++++- .../courseware/features/courseware.feature | 11 -- .../features/high-level-tabs.feature | 33 ++-- .../courseware/features/problems.py | 142 ++++++++++++++---- .../courseware/features/registration.feature | 7 +- .../courseware/features/registration.py | 7 +- lms/envs/acceptance.py | 24 ++- 10 files changed, 272 insertions(+), 98 deletions(-) delete mode 100644 lms/djangoapps/courseware/features/courseware.feature diff --git a/common/djangoapps/terrain/factories.py b/common/djangoapps/terrain/factories.py index a531f4fd26..5fa88e4b1d 100644 --- a/common/djangoapps/terrain/factories.py +++ b/common/djangoapps/terrain/factories.py @@ -121,21 +121,41 @@ class XModuleItemFactory(Factory): @classmethod def _create(cls, target_class, *args, **kwargs): """ - kwargs must include parent_location, template. Can contain display_name - target_class is ignored + Uses *kwargs*: + + *parent_location* (required): the location of the parent module + (e.g. the parent course or section) + + *template* (required): the template to create the item from + (e.g. i4x://templates/section/Empty) + + *data* (optional): the data for the item + (e.g. XML problem definition for a problem item) + + *display_name* (optional): the display name of the item + + *metadata* (optional): dictionary of metadata attributes + + *target_class* is ignored """ DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info'] parent_location = Location(kwargs.get('parent_location')) template = Location(kwargs.get('template')) + data = kwargs.get('data') display_name = kwargs.get('display_name') + metadata = kwargs.get('metadata', {}) store = modulestore('direct') # This code was based off that in cms/djangoapps/contentstore/views.py parent = store.get_item(parent_location) - dest_location = parent_location._replace(category=template.category, name=uuid4().hex) + + # If a display name is set, use that + dest_name = display_name.replace(" ", "_") if display_name is not None else uuid4().hex + dest_location = parent_location._replace(category=template.category, + name=dest_name) new_item = store.clone_item(template, dest_location) @@ -143,8 +163,15 @@ class XModuleItemFactory(Factory): if display_name is not None: new_item.display_name = display_name + # Add additional metadata or override current metadata + new_item.metadata.update(metadata) + store.update_metadata(new_item.location.url(), own_metadata(new_item)) + # replace the data with the optional *data* parameter + if data is not None: + store.update_item(new_item.location, data) + if new_item.location.category not in DETACHED_CATEGORIES: store.update_children(parent_location, parent.children + [new_item.location.url()]) diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index 5917d171b9..1f90113f46 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -69,6 +69,11 @@ def the_page_title_should_be(step, title): assert_equals(world.browser.title, title) +@step(u'the page title should contain "([^"]*)"$') +def the_page_title_should_contain(step, title): + assert(title in world.browser.title) + + @step('I am a logged in user$') def i_am_logged_in_user(step): create_user('robot') @@ -80,18 +85,6 @@ 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): - register_by_course_id(course_id) - - @step('I am staff for course "([^"]*)"$') def i_am_staff_for_course_by_id(step, course_id): register_by_course_id(course_id, True) @@ -139,13 +132,16 @@ def log_in(email, password): world.browser.is_element_present_by_css('header.global', 10) world.browser.click_link_by_href('#login-modal') - # wait for the login dialog to load - assert(world.browser.is_element_present_by_css('form#login_form', wait_time=10)) + # Wait for the login dialog to load + # This is complicated by the fact that sometimes a second #login_form + # dialog loads, while the first one remains hidden. + # We give them both time to load, starting with the second one. + world.browser.is_element_present_by_css('section.content-wrapper form#login_form', wait_time=2) + world.browser.is_element_present_by_css('form#login_form', wait_time=2) # For some reason, the page sometimes includes two #login_form # elements, the first of which is not visible. - # To avoid this, we always select the last of the two #login_form - # dialogs + # To avoid this, we always select the last of the two #login_form dialogs login_form = world.browser.find_by_css('form#login_form').last login_form.find_by_name('email').fill(email) diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py index 7aa299d20d..08ed1ca668 100644 --- a/common/lib/capa/capa/tests/response_xml_factory.py +++ b/common/lib/capa/capa/tests/response_xml_factory.py @@ -151,13 +151,11 @@ class ResponseXMLFactory(object): choice_element = etree.SubElement(group_element, "choice") choice_element.set("correct", "true" if correct_val else "false") - # Add some text describing the choice - etree.SubElement(choice_element, "startouttext") - etree.text = "Choice description" - etree.SubElement(choice_element, "endouttext") - # Add a name identifying the choice, if one exists + # For simplicity, we use the same string as both the + # name attribute and the text of the element if name: + choice_element.text = str(name) choice_element.set("name", str(name)) return group_element diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py index 145a56e183..4f307511df 100644 --- a/lms/djangoapps/courseware/features/common.py +++ b/lms/djangoapps/courseware/features/common.py @@ -5,6 +5,10 @@ from lettuce.django import django_url from django.conf import settings from django.contrib.auth.models import User from student.models import CourseEnrollment +from terrain.factories import CourseFactory, ItemFactory +from xmodule.modulestore import Location +from xmodule.modulestore.django import _MODULESTORES, modulestore +from xmodule.templates import update_templates import time from logging import getLogger @@ -81,17 +85,53 @@ def i_am_not_logged_in(step): world.browser.cookies.delete() +TEST_COURSE_ORG = 'edx' +TEST_COURSE_NAME = 'Test Course' +TEST_SECTION_NAME = "Problem" + +@step(u'The course "([^"]*)" exists$') +def create_course(step, course): + + # First clear the modulestore so we don't try to recreate + # the same course twice + # This also ensures that the necessary templates are loaded + flush_xmodule_store() + + # Create the course + # We always use the same org and display name, + # but vary the course identifier (e.g. 600x or 191x) + course = CourseFactory.create(org=TEST_COURSE_ORG, + number=course, + display_name=TEST_COURSE_NAME) + + # Add a section to the course to contain problems + section = ItemFactory.create(parent_location=course.location, + display_name=TEST_SECTION_NAME) + + problem_section = ItemFactory.create(parent_location=section.location, + template='i4x://edx/templates/sequential/Empty', + display_name=TEST_SECTION_NAME) + @step(u'I am registered for the course "([^"]*)"$') -def i_am_registered_for_the_course(step, course_id): +def i_am_registered_for_the_course(step, course): + # Create the course + create_course(step, course) + + # Create the user world.create_user('robot') u = User.objects.get(username='robot') # If the user is not already enrolled, enroll the user. - if len(CourseEnrollment.objects.filter(user=u, course_id=course_id)) == 0: - CourseEnrollment.objects.create(user=u, course_id=course_id) + CourseEnrollment.objects.get_or_create(user=u, course_id=course_id(course)) world.log_in('robot@edx.org', 'test') +@step(u'The course "([^"]*)" has extra tab "([^"]*)"$') +def add_tab_to_course(step, course, extra_tab_name): + section_item = ItemFactory.create(parent_location=course_location(course), + template="i4x://edx/templates/static_tab/Empty", + display_name=str(extra_tab_name)) + @step(u'I am an edX user$') def i_am_an_edx_user(step): @@ -101,3 +141,34 @@ def i_am_an_edx_user(step): @step(u'User "([^"]*)" is an edX user$') def registered_edx_user(step, uname): world.create_user(uname) + + +def flush_xmodule_store(): + # Flush and initialize the module store + # It needs the templates because it creates new records + # by cloning from the template. + # Note that if your test module gets in some weird state + # (though it shouldn't), do this manually + # from the bash shell to drop it: + # $ mongo test_xmodule --eval "db.dropDatabase()" + _MODULESTORES = {} + modulestore().collection.drop() + update_templates() + +def course_id(course_num): + return "%s/%s/%s" % (TEST_COURSE_ORG, course_num, + TEST_COURSE_NAME.replace(" ", "_")) + +def course_location(course_num): + return Location(loc_or_tag="i4x", + org=TEST_COURSE_ORG, + course=course_num, + category='course', + name=TEST_COURSE_NAME.replace(" ", "_")) + +def section_location(course_num): + return Location(loc_or_tag="i4x", + org=TEST_COURSE_ORG, + course=course_num, + category='sequential', + name=TEST_SECTION_NAME.replace(" ", "_")) diff --git a/lms/djangoapps/courseware/features/courseware.feature b/lms/djangoapps/courseware/features/courseware.feature deleted file mode 100644 index 14e7786fc9..0000000000 --- a/lms/djangoapps/courseware/features/courseware.feature +++ /dev/null @@ -1,11 +0,0 @@ -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 the course "MITx/6.002x/2013_Spring" - And I log in - And I click on View Courseware - When I click on the "Courseware" tab - Then the "Courseware" tab is active diff --git a/lms/djangoapps/courseware/features/high-level-tabs.feature b/lms/djangoapps/courseware/features/high-level-tabs.feature index 354376b154..102f752e1f 100644 --- a/lms/djangoapps/courseware/features/high-level-tabs.feature +++ b/lms/djangoapps/courseware/features/high-level-tabs.feature @@ -3,21 +3,18 @@ Feature: All the high level tabs should work 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 the course "MITx/6.002x/2013_Spring" - 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" +Scenario: I can navigate to all high-level tabs in a course + Given: I am registered for the course "6.002x" + And The course "6.002x" has extra tab "Custom Tab" + And I log in + And I click on View Courseware + When I click on the "" tab + Then the page title should contain "" + + Examples: + | TabName | PageTitle | + | Courseware | 6.002x Courseware | + | Course Info | 6.002x Course Info | + | Custom Tab | 6.002x Custom Tab | + | Wiki | edX Wiki | + | Progress | 6.002x Progress | diff --git a/lms/djangoapps/courseware/features/problems.py b/lms/djangoapps/courseware/features/problems.py index 686fc8c7a1..3af4843c3c 100644 --- a/lms/djangoapps/courseware/features/problems.py +++ b/lms/djangoapps/courseware/features/problems.py @@ -2,14 +2,118 @@ from lettuce import world, step from lettuce.django import django_url from selenium.webdriver.support.ui import Select import random -from common import i_am_registered_for_the_course +import textwrap +from common import i_am_registered_for_the_course, TEST_SECTION_NAME, section_location +from terrain.factories import ItemFactory +from capa.tests.response_xml_factory import OptionResponseXMLFactory, \ + ChoiceResponseXMLFactory, MultipleChoiceResponseXMLFactory, \ + StringResponseXMLFactory, NumericalResponseXMLFactory, \ + FormulaResponseXMLFactory, CustomResponseXMLFactory + +# Factories from capa.tests.response_xml_factory that we will use +# to generate the problem XML, with the keyword args used to configure +# the output. +problem_factory_dict = { + 'drop down': { + 'factory': OptionResponseXMLFactory(), + 'kwargs': { + 'question_text': 'The correct answer is Option 2', + 'options': ['Option 1', 'Option 2', 'Option 3', 'Option 4'], + 'correct_option': 'Option 2'}}, + + 'multiple choice': { + 'factory': MultipleChoiceResponseXMLFactory(), + 'kwargs': { + 'question_text': 'The correct answer is Choice 3', + 'choices': [False, False, True, False], + 'choice_names': ['choice_1', 'choice_2', 'choice_3', 'choice_4']}}, + + 'checkbox': { + 'factory': ChoiceResponseXMLFactory(), + 'kwargs': { + 'question_text': 'The correct answer is Choices 1 and 3', + 'choice_type':'checkbox', + 'choices':[True, False, True, False, False], + 'choice_names': ['Choice 1', 'Choice 2', 'Choice 3', 'Choice 4']}}, + + 'string': { + 'factory': StringResponseXMLFactory(), + 'kwargs': { + 'question_text': 'The answer is "correct string"', + 'case_sensitive': False, + 'answer': 'correct string' }}, + + 'numerical': { + 'factory': NumericalResponseXMLFactory(), + 'kwargs': { + 'question_text': 'The answer is pi + 1', + 'answer': '4.14159', + 'tolerance': '0.00001', + 'math_display': True }}, + + 'formula': { + 'factory': FormulaResponseXMLFactory(), + 'kwargs': { + 'question_text': 'The solution is [mathjax]x^2+2x+y[/mathjax]', + 'sample_dict': {'x': (-100, 100), 'y': (-100, 100) }, + 'num_samples': 10, + 'tolerance': 0.00001, + 'math_display': True, + 'answer': 'x^2+2*x+y'}}, + + 'script': { + 'factory': CustomResponseXMLFactory(), + 'kwargs': { + 'question_text': 'Enter two integers that sum to 10.', + 'cfn': 'test_add_to_ten', + 'expect': '10', + 'num_inputs': 2, + 'script': textwrap.dedent(""" + def test_add_to_ten(expect,ans): + try: + a1=int(ans[0]) + a2=int(ans[1]) + except ValueError: + a1=0 + a2=0 + return (a1+a2)==int(expect) + """) }}, + } + +def add_problem_to_course(course, problem_type): + + assert(problem_type in problem_factory_dict) + + # Generate the problem XML using capa.tests.response_xml_factory + factory_dict = problem_factory_dict[problem_type] + problem_xml = factory_dict['factory'].build_xml(**factory_dict['kwargs']) + + # Create a problem item using our generated XML + # We set rerandomize=always in the metadata so that the "Reset" button + # will appear. + problem_item = ItemFactory.create(parent_location=section_location(course), + template="i4x://edx/templates/problem/Blank_Common_Problem", + display_name=str(problem_type), + data=problem_xml, + metadata={'rerandomize':'always'}) @step(u'I am viewing a "([^"]*)" problem') def view_problem(step, problem_type): - i_am_registered_for_the_course(step, 'edX/model_course/2013_Spring') - url = django_url(problem_url(problem_type)) + i_am_registered_for_the_course(step, 'model_course') + + # Ensure that the course has this problem type + add_problem_to_course('model_course', problem_type) + + # Go to the one section in the factory-created course + # which should be loaded with the correct problem + chapter_name = TEST_SECTION_NAME.replace(" ", "_") + section_name = chapter_name + url = django_url('/courses/edx/model_course/Test_Course/courseware/%s/%s' % + (chapter_name, section_name)) + world.browser.visit(url) + @step(u'I answer a "([^"]*)" problem "([^"]*)ly"') def answer_problem(step, problem_type, correctness): """ Mark a given problem type correct or incorrect, then submit it. @@ -21,7 +125,7 @@ def answer_problem(step, problem_type, correctness): assert(correctness in ['correct', 'incorrect']) if problem_type == "drop down": - select_name = "input_i4x-edX-model_course-problem-Drop_Down_Problem_2_1" + select_name = "input_i4x-edx-model_course-problem-drop_down_2_1" option_text = 'Option 2' if correctness == 'correct' else 'Option 3' world.browser.select(select_name, option_text) @@ -125,21 +229,6 @@ def assert_answer_mark(step, problem_type, correctness): assert(world.browser.is_element_not_present_by_css(sel, wait_time=4)) -def problem_url(problem_type): - """ Construct a url to a page with the given problem type """ - base = '/courses/edX/model_course/2013_Spring/courseware/Problem_Components/' - url_extensions = { 'drop down': 'Drop_Down_Problems', - 'multiple choice': 'Multiple_Choice_Problems', - 'checkbox': 'Checkbox_Problems', - 'string': 'String_Problems', - 'numerical': 'Numerical_Problems', - 'formula': 'Formula_Problems', } - - assert(problem_type in url_extensions) - return base + url_extensions[problem_type] - - - def inputfield(problem_type, choice=None): """ Return the element for *problem_type*. For example, if problem_type is 'string', return @@ -148,19 +237,14 @@ def inputfield(problem_type, choice=None): *choice* is the name of the checkbox input in a group of checkboxes. """ - field_extensions = { 'drop down': 'Drop_Down_Problem', - 'multiple choice': 'Multiple_Choice_Problem', - 'checkbox': 'Checkbox_Problem', - 'string': 'String_Problem', - 'numerical': 'Numerical_Problem', - 'formula': 'Formula_Problem', } - - assert(problem_type in field_extensions) - extension = field_extensions[problem_type] - sel = "input#input_i4x-edX-model_course-problem-%s_2_1" % extension + sel = "input#input_i4x-edx-model_course-problem-%s_2_1" % problem_type.replace(" ", "_") if choice is not None: base = "_choice_" if problem_type == "multiple choice" else "_" sel = sel + base + str(choice) + # If the input element doesn't exist, fail immediately + assert(world.browser.is_element_present_by_css(sel, wait_time=4)) + + # Retrieve the input element return world.browser.find_by_css(sel) diff --git a/lms/djangoapps/courseware/features/registration.feature b/lms/djangoapps/courseware/features/registration.feature index 890beec1d8..5933f860bb 100644 --- a/lms/djangoapps/courseware/features/registration.feature +++ b/lms/djangoapps/courseware/features/registration.feature @@ -4,13 +4,14 @@ Feature: Register for a course I want to register for a class on the edX website Scenario: I can register for a course - Given I am logged in + Given The course "6.002x" exists + And I am logged in And I visit the courses page - When I register for the course "MITx/6.002x/2013_Spring" + When I register for the course "6.002x" Then I should see the course numbered "6.002x" in my dashboard Scenario: I can unregister for a course - Given I am registered for the course "MITx/6.002x/2013_Spring" + Given I am registered for the course "6.002x" 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/courseware/features/registration.py b/lms/djangoapps/courseware/features/registration.py index 5535319f15..9587842dd6 100644 --- a/lms/djangoapps/courseware/features/registration.py +++ b/lms/djangoapps/courseware/features/registration.py @@ -1,9 +1,12 @@ from lettuce import world, step from lettuce.django import django_url +from common import TEST_COURSE_ORG, TEST_COURSE_NAME @step('I register for the course "([^"]*)"$') -def i_register_for_the_course(step, course_id): - world.browser.visit(django_url('courses/%s/about' % course_id)) +def i_register_for_the_course(step, course): + cleaned_name = TEST_COURSE_NAME.replace(' ', '_') + url = django_url('courses/%s/%s/%s/about' % (TEST_COURSE_ORG, course, cleaned_name)) + world.browser.visit(url) intro_section = world.browser.find_by_css('section.intro') register_link = intro_section.find_by_css('a.register') diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index b6941f4a70..3dac545367 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -8,16 +8,24 @@ from .test import * # otherwise the browser will not render the pages correctly DEBUG = True -# Show the courses that are in the data directory -COURSES_ROOT = ENV_ROOT / "data" -DATA_DIR = COURSES_ROOT +# Use the mongo store for acceptance tests +modulestore_options = { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'test_xmodule', + 'collection': 'modulestore', + 'fs_root': GITHUB_REPO_ROOT, + 'render_template': 'mitxmako.shortcuts.render_to_string', +} + MODULESTORE = { 'default': { - 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', - 'OPTIONS': { - 'data_dir': DATA_DIR, - 'default_class': 'xmodule.hidden_module.HiddenDescriptor', - } + 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', + 'OPTIONS': modulestore_options + }, + 'direct': { + 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', + 'OPTIONS': modulestore_options } }