diff --git a/common/djangoapps/terrain/factories.py b/common/djangoapps/terrain/factories.py index 9aa6b3b54d46..4173efa1d902 100644 --- a/common/djangoapps/terrain/factories.py +++ b/common/djangoapps/terrain/factories.py @@ -9,68 +9,11 @@ from lettuce import world -@world.absorb -class UserFactory(sf.UserFactory): - """ - User account for lms / cms - """ - FACTORY_DJANGO_GET_OR_CREATE = ('username',) - pass - - -@world.absorb -class UserProfileFactory(sf.UserProfileFactory): - """ - Demographics etc for the User - """ - FACTORY_DJANGO_GET_OR_CREATE = ('user',) - pass - - -@world.absorb -class RegistrationFactory(sf.RegistrationFactory): - """ - Activation key for registering the user account - """ - FACTORY_DJANGO_GET_OR_CREATE = ('user',) - pass - - -@world.absorb -class GroupFactory(sf.GroupFactory): - """ - Groups for user permissions for courses - """ - pass - - -@world.absorb -class CourseEnrollmentAllowedFactory(sf.CourseEnrollmentAllowedFactory): - """ - Users allowed to enroll in the course outside of the usual window - """ - pass - - -@world.absorb -class CourseModeFactory(cmf.CourseModeFactory): - """ - Course modes - """ - pass - - -@world.absorb -class CourseFactory(xf.CourseFactory): - """ - Courseware courses - """ - pass - - -@world.absorb -class ItemFactory(xf.ItemFactory): - """ - Everything included inside a course - """ - pass +world.absorb(sf.UserFactory) +world.absorb(sf.UserProfileFactory) +world.absorb(sf.RegistrationFactory) +world.absorb(sf.GroupFactory) +world.absorb(sf.CourseEnrollmentAllowedFactory) +world.absorb(cmf.CourseModeFactory) +world.absorb(xf.CourseFactory) +world.absorb(xf.ItemFactory) diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index d5abb6260cd9..dea0e6f0f32f 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -11,10 +11,14 @@ # Disable the "unused argument" warning because lettuce uses "step" #pylint: disable=W0613 +# django_url is assigned late in the process of loading lettuce, +# so we import this as a module, and then read django_url from +# it to get the correct value +import lettuce.django + from lettuce import world, step from .course_helpers import * from .ui_helpers import * -from lettuce.django import django_url from nose.tools import assert_equals # pylint: disable=E0611 from logging import getLogger @@ -135,7 +139,7 @@ def should_have_link_with_id_and_text(step, link_id, text): def should_have_link_with_path_and_text(step, path, text): link = world.browser.find_link_by_text(text) assert len(link) > 0 - assert_equals(link.first["href"], django_url(path)) + assert_equals(link.first["href"], lettuce.django.django_url(path)) @step(r'should( not)? see "(.*)" (?:somewhere|anywhere) (?:in|on) (?:the|this) page') @@ -154,7 +158,7 @@ def should_see_in_the_page(step, doesnt_appear, text): def i_am_logged_in(step): world.create_user('robot', 'test') world.log_in(username='robot', password='test') - world.browser.visit(django_url('/')) + world.browser.visit(lettuce.django.django_url('/')) dash_css = 'section.container.dashboard' assert world.is_css_present(dash_css) @@ -176,7 +180,7 @@ def dialogs_are_closed(step): @step(u'visit the url "([^"]*)"') def visit_url(step, url): - world.browser.visit(django_url(url)) + world.browser.visit(lettuce.django.django_url(url)) @step(u'wait for AJAX to (?:finish|complete)') diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 2e6a6efc3b7f..d5cb969bbbb1 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -2,10 +2,18 @@ #pylint: disable=W0621 from lettuce import world + import time import json import re import platform + +# django_url is assigned late in the process of loading lettuce, +# so we import this as a module, and then read django_url from +# it to get the correct value +import lettuce.django + + from textwrap import dedent from urllib import quote_plus from selenium.common.exceptions import ( @@ -14,7 +22,6 @@ from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait -from lettuce.django import django_url from nose.tools import assert_true # pylint: disable=E0611 @@ -247,13 +254,13 @@ def wait_for_ajax_complete(): @world.absorb def visit(url): - world.browser.visit(django_url(url)) + world.browser.visit(lettuce.django.django_url(url)) wait_for_js_to_load() @world.absorb def url_equals(url): - return world.browser.url == django_url(url) + return world.browser.url == lettuce.django.django_url(url) @world.absorb diff --git a/common/lib/xmodule/xmodule/conditional_module.py b/common/lib/xmodule/xmodule/conditional_module.py index 824834337183..48872b5393b7 100644 --- a/common/lib/xmodule/xmodule/conditional_module.py +++ b/common/lib/xmodule/xmodule/conditional_module.py @@ -96,7 +96,11 @@ def _get_condition(self): xml_value = self.descriptor.xml_attributes.get(xml_attr) if xml_value: return xml_value, attr_name - raise Exception('Error in conditional module: unknown condition "%s"' % xml_attr) + raise Exception( + 'Error in conditional module: no known conditional found in {!r}'.format( + self.descriptor.xml_attributes.keys() + ) + ) @lazy def required_modules(self): diff --git a/common/lib/xmodule/xmodule/poll_module.py b/common/lib/xmodule/xmodule/poll_module.py index 86852cd698e3..47b65fe89299 100644 --- a/common/lib/xmodule/xmodule/poll_module.py +++ b/common/lib/xmodule/xmodule/poll_module.py @@ -32,7 +32,9 @@ class PollFields(object): poll_answer = String(help="Student answer", scope=Scope.user_state, default='') poll_answers = Dict(help="All possible answers for the poll fro other students", scope=Scope.user_state_summary) + # List of answers, in the form {'id': 'some id', 'text': 'the answer text'} answers = List(help="Poll answers from xml", scope=Scope.content, default=[]) + question = String(help="Poll question", scope=Scope.content, default='') diff --git a/common/static/coffee/src/xblock/core.coffee b/common/static/coffee/src/xblock/core.coffee index f95f2fc6af89..71fbfd291e5e 100644 --- a/common/static/coffee/src/xblock/core.coffee +++ b/common/static/coffee/src/xblock/core.coffee @@ -18,6 +18,8 @@ block.element = element block.name = $element.data("name") + $element.trigger("xblock-initialized") + $element.data("initialized", true) block initializeBlocks: (element) -> diff --git a/lms/djangoapps/courseware/features/annotatable.feature b/lms/djangoapps/courseware/features/annotatable.feature new file mode 100644 index 000000000000..6427620cb6d0 --- /dev/null +++ b/lms/djangoapps/courseware/features/annotatable.feature @@ -0,0 +1,26 @@ +@shard_2 +Feature: LMS.Annotatable Component + As a student, I want to view an Annotatable component in the LMS + + Scenario: An Annotatable component can be rendered in the LMS + Given that a course has an annotatable component with 2 annotations + When I view the annotatable component + Then the annotatable component has rendered + And the annotatable component has 2 highlighted passages + + Scenario: An Annotatable component links to annonation problems in the LMS + Given that a course has an annotatable component with 2 annotations + And the course has 2 annotatation problems + When I view the annotatable component + And I click "Reply to annotation" on passage + Then I am scrolled to that annotation problem + When I answer that annotation problem + Then I recieve feedback on that annotation problem + When I click "Return to annotation" on that problem + Then I am scrolled to the annotatable component + + Examples: + | problem | + | 0 | + | 1 | + diff --git a/lms/djangoapps/courseware/features/annotatable.py b/lms/djangoapps/courseware/features/annotatable.py new file mode 100644 index 000000000000..56622af5e968 --- /dev/null +++ b/lms/djangoapps/courseware/features/annotatable.py @@ -0,0 +1,174 @@ +import textwrap + +from lettuce import world, steps +from nose.tools import assert_in, assert_equals, assert_true + +from common import i_am_registered_for_the_course, visit_scenario_item + +DATA_TEMPLATE = textwrap.dedent("""\ + + Instruction text +

{}

+
+""") + +ANNOTATION_TEMPLATE = textwrap.dedent("""\ + Before {0}. + + Region Contents {0} + + After {0}. +""") + +PROBLEM_TEMPLATE = textwrap.dedent("""\ + + + + Question {number} + Region Contents {number} + What number is this region? + Type your response below: + What number is this region? + + {options} + + + + + This problem is checking region {number} + + +""") + +OPTION_TEMPLATE = """""" + + +def _correctness(choice, target): + if choice == target: + return "correct" + elif abs(choice - target) == 1: + return "partially-correct" + else: + return "incorrect" + + +@steps +class AnnotatableSteps(object): + + def __init__(self): + self.annotations_count = None + self.active_problem = None + + def define_component(self, step, count): + r"""that a course has an annotatable component with (?P\d+) annotations$""" + + count = int(count) + coursenum = 'test_course' + i_am_registered_for_the_course(step, coursenum) + + world.scenario_dict['ANNOTATION_VERTICAL'] = world.ItemFactory( + parent_location=world.scenario_dict['SECTION'].location, + category='vertical', + display_name="Test Annotation Vertical" + ) + + world.scenario_dict['ANNOTATABLE'] = world.ItemFactory( + parent_location=world.scenario_dict['ANNOTATION_VERTICAL'].location, + category='annotatable', + display_name="Test Annotation Module", + data=DATA_TEMPLATE.format("\n".join(ANNOTATION_TEMPLATE.format(i) for i in xrange(count))) + ) + + self.annotations_count = count + + def view_component(self, step): + r"""I view the annotatable component$""" + visit_scenario_item('ANNOTATABLE') + + def check_rendered(self, step): + r"""the annotatable component has rendered$""" + world.wait_for_js_variable_truthy('$(".xblock-student_view[data-type=Annotatable]").data("initialized")') + annotatable_text = world.css_find('.xblock-student_view[data-type=Annotatable]').first.text + assert_in("Instruction text", annotatable_text) + + for i in xrange(self.annotations_count): + assert_in("Region Contents {}".format(i), annotatable_text) + + def count_passages(self, step, count): + r"""the annotatable component has (?P\d+) highlighted passages$""" + count = int(count) + assert_equals(len(world.css_find('.annotatable-span')), count) + assert_equals(len(world.css_find('.annotatable-span.highlight')), count) + assert_equals(len(world.css_find('.annotatable-span.highlight-yellow')), count) + + def add_problems(self, step, count): + r"""the course has (?P\d+) annotatation problems$""" + count = int(count) + + for i in xrange(count): + world.scenario_dict.setdefault('PROBLEMS', []).append( + world.ItemFactory( + parent_location=world.scenario_dict['ANNOTATION_VERTICAL'].location, + category='problem', + display_name="Test Annotation Problem {}".format(i), + data=PROBLEM_TEMPLATE.format( + number=i, + options="\n".join( + OPTION_TEMPLATE.format( + number=k, + correctness=_correctness(k, i) + ) + for k in xrange(count) + ) + ) + ) + ) + + def click_reply(self, step, problem): + r"""I click "Reply to annotation" on passage (?P\d+)$""" + problem = int(problem) + + annotation_span_selector = '.annotatable-span[data-problem-id="{}"]'.format(problem) + + world.css_find(annotation_span_selector).first.mouse_over() + + annotation_reply_selector = '.annotatable-reply[data-problem-id="{}"]'.format(problem) + assert_equals(len(world.css_find(annotation_reply_selector)), 1) + world.css_click(annotation_reply_selector) + + self.active_problem = problem + + def active_problem_selector(self, subselector): + return 'section[data-problem-id="{}"] {}'.format( + world.scenario_dict['PROBLEMS'][self.active_problem].location.url(), + subselector, + ) + + def check_scroll_to_problem(self, step): + r"""I am scrolled to that annotation problem$""" + annotation_input_selector = self.active_problem_selector('.annotation-input') + assert_true(world.css_visible(annotation_input_selector)) + + def answer_problem(self, step): + r"""I answer that annotation problem$""" + world.css_fill(self.active_problem_selector('.comment'), 'Test Response') + world.css_click(self.active_problem_selector('.tag[data-id="{}"]'.format(self.active_problem))) + world.css_click(self.active_problem_selector('.check')) + + def check_feedback(self, step): + r"""I recieve feedback on that annotation problem$""" + world.wait_for_visible(self.active_problem_selector('.tag-status.correct')) + assert_equals(len(world.css_find(self.active_problem_selector('.tag-status.correct'))), 1) + assert_equals(len(world.css_find(self.active_problem_selector('.show'))), 1) + + def click_return_to(self, step): + r"""I click "Return to annotation" on that problem$""" + world.css_click(self.active_problem_selector('.annotation-return')) + + def check_scroll_to_annotatable(self, step): + r"""I am scrolled to the annotatable component$""" + assert_true(world.css_visible('.annotation-header')) + +# This line is required by @steps in order to actually bind the step +# regexes +AnnotatableSteps() diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py index d0a777eb1e45..0c30044300ea 100644 --- a/lms/djangoapps/courseware/features/common.py +++ b/lms/djangoapps/courseware/features/common.py @@ -4,7 +4,9 @@ from __future__ import absolute_import from lettuce import world, step +from lettuce.django import django_url from django.contrib.auth.models import User +from django.core.urlresolvers import reverse from student.models import CourseEnrollment from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore @@ -27,18 +29,24 @@ def create_course(_step, course): # Create the course # We always use the same org and display name, # but vary the course identifier (e.g. 600x or 191x) - world.scenario_dict['COURSE'] = world.CourseFactory.create(org='edx', - number=course, - display_name='Test Course') - - # Add a section to the course to contain problems - world.scenario_dict['SECTION'] = world.ItemFactory.create(parent_location=world.scenario_dict['COURSE'].location, - display_name='Test Section') - - world.ItemFactory.create( - parent_location=world.scenario_dict['SECTION'].location, + world.scenario_dict['COURSE'] = world.CourseFactory.create( + org='edx', + number=course, + display_name='Test Course' + ) + + # Add a chapter to the course to contain problems + world.scenario_dict['CHAPTER'] = world.ItemFactory.create( + parent_location=world.scenario_dict['COURSE'].location, + category='chapter', + display_name='Test Chapter', + ) + + world.scenario_dict['SECTION'] = world.ItemFactory.create( + parent_location=world.scenario_dict['CHAPTER'].location, category='sequential', - display_name='Test Section') + display_name='Test Section', + ) @step(u'I am registered for the course "([^"]*)"$') @@ -74,23 +82,32 @@ def go_into_course(step): def course_id(course_num): return "%s/%s/%s" % (world.scenario_dict['COURSE'].org, course_num, - world.scenario_dict['COURSE'].display_name.replace(" ", "_")) + world.scenario_dict['COURSE'].url_name) def course_location(course_num): - return Location(loc_or_tag="i4x", - org=world.scenario_dict['COURSE'].org, - course=course_num, - category='course', - name=world.scenario_dict['COURSE'].display_name.replace(" ", "_")) + return world.scenario_dict['COURSE'].location._replace(course=course_num) def section_location(course_num): - return Location(loc_or_tag="i4x", - org=world.scenario_dict['COURSE'].org, - course=course_num, - category='sequential', - name=world.scenario_dict['SECTION'].display_name.replace(" ", "_")) + return world.scenario_dict['SECTION'].location._replace(course=course_num) + + +def visit_scenario_item(item_key): + """ + Go to the courseware page containing the item stored in `world.scenario_dict` + under the key `item_key` + """ + + url = django_url(reverse( + 'jump_to', + kwargs={ + 'course_id': world.scenario_dict['COURSE'].id, + 'location': str(world.scenario_dict[item_key].location), + } + )) + + world.browser.visit(url) def get_courses(): diff --git a/lms/djangoapps/courseware/features/conditional.feature b/lms/djangoapps/courseware/features/conditional.feature new file mode 100644 index 000000000000..65080f76be73 --- /dev/null +++ b/lms/djangoapps/courseware/features/conditional.feature @@ -0,0 +1,22 @@ +@shard_2 +Feature: LMS.Conditional Module + As a student, I want to view a Conditional component in the LMS + + Scenario: A Conditional hides content when conditions aren't satisfied + Given that a course has a Conditional conditioned on problem attempted=True + And that the conditioned problem has not been attempted + When I view the conditional + Then the conditional contents are hidden + + Scenario: A Conditional shows content when conditions are satisfied + Given that a course has a Conditional conditioned on problem attempted=True + And that the conditioned problem has been attempted + When I view the conditional + Then the conditional contents are visible + + Scenario: A Conditional containing a Poll is updated when the poll is answered + Given that a course has a Conditional conditioned on poll poll_answer=yes + When I view the conditional + Then the conditional contents are hidden + When I answer the conditioned poll "yes" + Then the conditional contents are visible diff --git a/lms/djangoapps/courseware/features/conditional.py b/lms/djangoapps/courseware/features/conditional.py new file mode 100644 index 000000000000..665cfbcd0766 --- /dev/null +++ b/lms/djangoapps/courseware/features/conditional.py @@ -0,0 +1,119 @@ + +from lettuce import world, steps +from nose.tools import assert_in, assert_equals, assert_true + +from common import i_am_registered_for_the_course, visit_scenario_item +from problems_setup import add_problem_to_course, answer_problem + +@steps +class ConditionalSteps(object): + COURSE_NUM = 'test_course' + + def setup_conditional(self, step, condition_type, condition, cond_value): + r'that a course has a Conditional conditioned on (?P\w+) (?P\w+)=(?P\w+)$' + + i_am_registered_for_the_course(step, self.COURSE_NUM) + + world.scenario_dict['VERTICAL'] = world.ItemFactory( + parent_location=world.scenario_dict['SECTION'].location, + category='vertical', + display_name="Test Vertical", + ) + + world.scenario_dict['WRAPPER'] = world.ItemFactory( + parent_location=world.scenario_dict['VERTICAL'].location, + category='wrapper', + display_name="Test Poll Wrapper" + ) + + if condition_type == 'problem': + world.scenario_dict['CONDITION_SOURCE'] = add_problem_to_course(self.COURSE_NUM, 'string') + elif condition_type == 'poll': + world.scenario_dict['CONDITION_SOURCE'] = world.ItemFactory( + parent_location=world.scenario_dict['WRAPPER'].location, + category='poll_question', + display_name='Conditional Poll', + data={ + 'question': 'Is this a good poll?', + 'answers': [ + {'id': 'yes', 'text': 'Yes, of course'}, + {'id': 'no', 'text': 'Of course not!'} + ], + } + ) + else: + raise Exception("Unknown condition type: {!r}".format(condition_type)) + + metadata = { + 'xml_attributes': { + 'sources': world.scenario_dict['CONDITION_SOURCE'].location.url() + } + } + metadata['xml_attributes'][condition] = cond_value + + world.scenario_dict['CONDITIONAL'] = world.ItemFactory( + parent_location=world.scenario_dict['WRAPPER'].location, + category='conditional', + display_name="Test Conditional", + metadata=metadata + ) + + world.ItemFactory( + parent_location=world.scenario_dict['CONDITIONAL'].location, + category='html', + display_name='Conditional Contents', + data='
Hidden Contents

' + ) + + + def setup_problem_attempts(self, step, not_attempted=None): + r'that the conditioned problem has (?Pnot )?been attempted$' + visit_scenario_item('CONDITION_SOURCE') + + if not_attempted is None: + answer_problem(self.COURSE_NUM, 'string', True) + world.css_click("input.check") + + def when_i_view_the_conditional(self, step): + r'I view the conditional$' + visit_scenario_item('CONDITIONAL') + world.wait_for_js_variable_truthy('$(".xblock-student_view[data-type=Conditional]").data("initialized")') + + def check_visibility(self, step, visible): + r'the conditional contents are (?P\w+)$' + world.wait_for_ajax_complete() + + assert_in(visible, ('visible', 'hidden')) + + if visible == 'visible': + world.wait_for_visible('.hidden-contents') + assert_true(world.css_visible('.hidden-contents')) + else: + assert_true(world.is_css_not_present('.hidden-contents')) + + def answer_poll(self, step, answer): + r' I answer the conditioned poll "([^"]*)"$' + visit_scenario_item('CONDITION_SOURCE') + world.wait_for_js_variable_truthy('$(".xblock-student_view[data-type=Poll]").data("initialized")') + world.wait_for_ajax_complete() + + answer_text = [ + poll_answer['text'] + for poll_answer + in world.scenario_dict['CONDITION_SOURCE'].answers + if poll_answer['id'] == answer + ][0] + + text_selector = '.poll_answer .text' + + poll_texts = world.retry_on_exception( + lambda: [elem.text for elem in world.css_find(text_selector)] + ) + + for idx, poll_text in enumerate(poll_texts): + if poll_text == answer_text: + world.css_click(text_selector, index=idx) + return + + +ConditionalSteps() \ No newline at end of file diff --git a/lms/djangoapps/courseware/features/gst.feature b/lms/djangoapps/courseware/features/gst.feature new file mode 100644 index 000000000000..f397c54bbc06 --- /dev/null +++ b/lms/djangoapps/courseware/features/gst.feature @@ -0,0 +1,10 @@ +@shard_2 +Feature: LMS.Graphical Slider Tool Module + As a student, I want to view a Graphical Slider Tool Component + + Scenario: The slider changes values on the page + Given that I have a course with a Graphical Slider Tool + When I view the Graphical Slider Tool + Then the displayed value should be 0 + And I move the slider to the right + Then the displayed value should be 10 \ No newline at end of file diff --git a/lms/djangoapps/courseware/features/gst.py b/lms/djangoapps/courseware/features/gst.py new file mode 100644 index 000000000000..a8693ead3063 --- /dev/null +++ b/lms/djangoapps/courseware/features/gst.py @@ -0,0 +1,76 @@ + +from lettuce import world, steps +from nose.tools import assert_in, assert_equals, assert_true + +from common import i_am_registered_for_the_course, visit_scenario_item +from problems_setup import add_problem_to_course, answer_problem + + +DEFAULT_DATA = """\ + +

Test of the graphical slider tool

+
+ +
+
+ +
+
+ + + + + + a + + +""" + +@steps +class GraphicalSliderToolSteps(object): + COURSE_NUM = 'test_course' + + def setup_gst(self, step): + r'that I have a course with a Graphical Slider Tool$' + + i_am_registered_for_the_course(step, self.COURSE_NUM) + + world.scenario_dict['GST'] = world.ItemFactory( + parent_location=world.scenario_dict['SECTION'].location, + category='graphical_slider_tool', + display_name="Test GST", + data=DEFAULT_DATA + ) + + def view_gst(self, step): + r'I view the Graphical Slider Tool$' + visit_scenario_item('GST') + world.wait_for_js_variable_truthy('$(".xblock-student_view[data-type=GraphicalSliderTool]").data("initialized")') + world.wait_for_ajax_complete() + + def check_value(self, step, value): + r'the displayed value should be (?P\d+)$' + + assert_equals(world.css_text('.gst-value'), value) + + def move_slider(self, step): + r'I move the slider to the right$' + + handle_selector = '.gst-input .ui-slider-handle' + world.wait_for_visible(handle_selector) + world.wait_for_visible('.gst-value #value-display') + + def try_move(): + handle = world.css_find(handle_selector).first + slider = world.css_find('.gst-input .ui-slider').first + (handle.action_chains + .click_and_hold(handle._element) + .move_by_offset( + int(handle._element.location['x'] + 400), + 0 + ).release().perform()) + + world.retry_on_exception(try_move) + + +GraphicalSliderToolSteps() \ No newline at end of file diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index f2b80a8787c7..2959ffee85d0 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -3,9 +3,10 @@ import os from django.contrib.auth.models import User +from django.core.urlresolvers import reverse from lettuce import world, step from lettuce.django import django_url -from common import course_id +from common import course_id, visit_scenario_item from courseware.tests.factories import InstructorFactory @@ -111,7 +112,7 @@ def add_correct_lti_to_course(_step, fields): metadata.update(_step.hashes[0]) world.scenario_dict['LTI'] = world.ItemFactory.create( - parent_location=world.scenario_dict['SEQUENTIAL'].location, + parent_location=world.scenario_dict['SECTION'].location, category=category, display_name='LTI', metadata=metadata, @@ -122,19 +123,7 @@ def add_correct_lti_to_course(_step, fields): port=world.browser.port, )) - course = world.scenario_dict["COURSE"] - chapter_name = world.scenario_dict['SECTION'].display_name.replace( - " ", "_") - section_name = chapter_name - path = "/courses/{org}/{num}/{name}/courseware/{chapter}/{section}".format( - org=course.org, - num=course.number, - name=course.display_name.replace(' ', '_'), - chapter=chapter_name, - section=section_name) - url = django_url(path) - - world.browser.visit(url) + visit_scenario_item('LTI') def create_course(course, metadata): @@ -180,12 +169,13 @@ def create_course(course, metadata): ) # Add a section to the course to contain problems - world.scenario_dict['SECTION'] = world.ItemFactory.create( + world.scenario_dict['CHAPTER'] = world.ItemFactory.create( parent_location=world.scenario_dict['COURSE'].location, - display_name='Test Section', + category='chapter', + display_name='Test Chapter', ) - world.scenario_dict['SEQUENTIAL'] = world.ItemFactory.create( - parent_location=world.scenario_dict['SECTION'].location, + world.scenario_dict['SECTION'] = world.ItemFactory.create( + parent_location=world.scenario_dict['CHAPTER'].location, category='sequential', display_name='Test Section', metadata={'graded': True, 'format': 'Homework'}) diff --git a/lms/djangoapps/courseware/features/problems.feature b/lms/djangoapps/courseware/features/problems.feature index d1b525f1cf6e..8e2d937b711c 100644 --- a/lms/djangoapps/courseware/features/problems.feature +++ b/lms/djangoapps/courseware/features/problems.feature @@ -26,6 +26,7 @@ Feature: LMS.Answer problems | code | | radio_text | | checkbox_text | + | image | Scenario: I can answer a problem incorrectly Given External graders respond "incorrect" @@ -47,6 +48,7 @@ Feature: LMS.Answer problems | code | | radio_text | | checkbox_text | + | image | Scenario: I can submit a blank answer Given I am viewing a "" problem @@ -66,6 +68,7 @@ Feature: LMS.Answer problems | script | | radio_text | | checkbox_text | + | image | Scenario: I can reset a problem @@ -97,6 +100,8 @@ Feature: LMS.Answer problems | radio_text | incorrect | | checkbox_text | correct | | checkbox_text | incorrect | + | image | correct | + | image | incorrect | Scenario: I can answer a problem with one attempt correctly and not reset @@ -157,6 +162,8 @@ Feature: LMS.Answer problems | formula | incorrect | 1 point possible | 1 point possible | | script | correct | 2/2 points | 2 points possible | | script | incorrect | 2 points possible | 2 points possible | + | image | correct | 1/1 points | 1 point possible | + | image | incorrect | 1 point possible | 1 point possible | Scenario: I can see my score on a problem to which I submit a blank answer Given I am viewing a "" problem @@ -173,6 +180,7 @@ Feature: LMS.Answer problems | numerical | 1 point possible | | formula | 1 point possible | | script | 2 points possible | + | image | 1 point possible | Scenario: I can reset the correctness of a problem after changing my answer diff --git a/lms/djangoapps/courseware/features/problems.py b/lms/djangoapps/courseware/features/problems.py index 0be5b722840a..bc3c16ed8b45 100644 --- a/lms/djangoapps/courseware/features/problems.py +++ b/lms/djangoapps/courseware/features/problems.py @@ -7,60 +7,35 @@ from lettuce import world, step from lettuce.django import django_url -from common import i_am_registered_for_the_course +from common import i_am_registered_for_the_course, visit_scenario_item from problems_setup import PROBLEM_DICT, answer_problem, problem_has_answer, add_problem_to_course from nose.tools import assert_equal -@step(u'I am viewing a "([^"]*)" problem with "([^"]*)" attempt') -def view_problem_with_attempts(step, problem_type, attempts): +def _view_problem(step, problem_type, problem_settings=None): i_am_registered_for_the_course(step, 'model_course') # Ensure that the course has this problem type - add_problem_to_course(world.scenario_dict['COURSE'].number, problem_type, {'max_attempts': attempts}) + add_problem_to_course(world.scenario_dict['COURSE'].number, problem_type, problem_settings) # Go to the one section in the factory-created course # which should be loaded with the correct problem - chapter_name = world.scenario_dict['SECTION'].display_name.replace(" ", "_") - section_name = chapter_name - url = django_url('/courses/%s/%s/%s/courseware/%s/%s' % - (world.scenario_dict['COURSE'].org, world.scenario_dict['COURSE'].number, world.scenario_dict['COURSE'].display_name.replace(' ', '_'), - chapter_name, section_name,)) - world.browser.visit(url) + visit_scenario_item('SECTION') -@step(u'I am viewing a "([^"]*)" that shows the answer "([^"]*)"') -def view_problem_with_show_answer(step, problem_type, answer): - i_am_registered_for_the_course(step, 'model_course') +@step(u'I am viewing a "([^"]*)" problem with "([^"]*)" attempt') +def view_problem_with_attempts(step, problem_type, attempts): + _view_problem(step, problem_type, {'max_attempts': attempts}) - # Ensure that the course has this problem type - add_problem_to_course('model_course', problem_type, {'showanswer': answer}) - # Go to the one section in the factory-created course - # which should be loaded with the correct problem - chapter_name = world.scenario_dict['SECTION'].display_name.replace(" ", "_") - section_name = chapter_name - url = django_url('/courses/%s/%s/%s/courseware/%s/%s' % - (world.scenario_dict['COURSE'].org, world.scenario_dict['COURSE'].number, world.scenario_dict['COURSE'].display_name.replace(' ', '_'), - chapter_name, section_name,)) - world.browser.visit(url) +@step(u'I am viewing a "([^"]*)" that shows the answer "([^"]*)"') +def view_problem_with_show_answer(step, problem_type, answer): + _view_problem(step, problem_type, {'showanswer': answer}) @step(u'I am viewing a "([^"]*)" problem') def view_problem(step, 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 = world.scenario_dict['SECTION'].display_name.replace(" ", "_") - section_name = chapter_name - url = django_url('/courses/%s/%s/%s/courseware/%s/%s' % - (world.scenario_dict['COURSE'].org, world.scenario_dict['COURSE'].number, world.scenario_dict['COURSE'].display_name.replace(' ', '_'), - chapter_name, section_name,)) - world.browser.visit(url) + _view_problem(step, problem_type) @step(u'External graders respond "([^"]*)"') @@ -97,7 +72,7 @@ def input_problem_answer(_, problem_type, correctness): """ assert(correctness in ['correct', 'incorrect']) assert(problem_type in PROBLEM_DICT) - answer_problem(problem_type, correctness) + answer_problem(world.scenario_dict['COURSE'].number, problem_type, correctness) @step(u'I check a problem') @@ -123,7 +98,7 @@ def assert_problem_has_answer(step, problem_type, answer_class): ''' assert answer_class in ['correct', 'incorrect', 'blank'] assert problem_type in PROBLEM_DICT - problem_has_answer(problem_type, answer_class) + problem_has_answer(world.scenario_dict['COURSE'].number, problem_type, answer_class) @step(u'I reset the problem') diff --git a/lms/djangoapps/courseware/features/problems_setup.py b/lms/djangoapps/courseware/features/problems_setup.py index c403890557b5..26867188ab22 100644 --- a/lms/djangoapps/courseware/features/problems_setup.py +++ b/lms/djangoapps/courseware/features/problems_setup.py @@ -14,11 +14,18 @@ import random import textwrap from common import section_location -from capa.tests.response_xml_factory import OptionResponseXMLFactory, \ - ChoiceResponseXMLFactory, MultipleChoiceResponseXMLFactory, \ - StringResponseXMLFactory, NumericalResponseXMLFactory, \ - FormulaResponseXMLFactory, CustomResponseXMLFactory, \ - CodeResponseXMLFactory, ChoiceTextResponseXMLFactory +from capa.tests.response_xml_factory import ( + ChoiceResponseXMLFactory, + ChoiceTextResponseXMLFactory, + CodeResponseXMLFactory, + CustomResponseXMLFactory, + FormulaResponseXMLFactory, + ImageResponseXMLFactory, + MultipleChoiceResponseXMLFactory, + NumericalResponseXMLFactory, + OptionResponseXMLFactory, + StringResponseXMLFactory, +) # Factories from capa.tests.response_xml_factory that we will use @@ -158,50 +165,62 @@ def test_add_to_ten(expect,ans): }, 'correct': ['span.correct'], 'incorrect': ['span.incorrect'], + 'unanswered': ['span.unanswered']}, + + 'image': { + 'factory': ImageResponseXMLFactory(), + 'kwargs': { + 'src': '/static/images/mit_dome.jpg', + 'rectangle': '(50,50)-(100,100)' + }, + 'correct': ['span.correct'], + 'incorrect': ['span.incorrect'], 'unanswered': ['span.unanswered']} } -def answer_problem(problem_type, correctness): +def answer_problem(course, problem_type, correctness): # Make sure that the problem has been completely rendered before # starting to input an answer. world.wait_for_ajax_complete() + section_loc = section_location(course) + if problem_type == "drop down": - select_name = "input_i4x-edx-model_course-problem-drop_down_2_1" + select_name = "input_i4x-{0.org}-{0.course}-problem-drop_down_2_1".format(section_loc) option_text = 'Option 2' if correctness == 'correct' else 'Option 3' world.select_option(select_name, option_text) elif problem_type == "multiple choice": if correctness == 'correct': - world.css_check(inputfield('multiple choice', choice='choice_2')) + world.css_check(inputfield(course, 'multiple choice', choice='choice_2')) else: - world.css_check(inputfield('multiple choice', choice='choice_1')) + world.css_check(inputfield(course, 'multiple choice', choice='choice_1')) elif problem_type == "checkbox": if correctness == 'correct': - world.css_check(inputfield('checkbox', choice='choice_0')) - world.css_check(inputfield('checkbox', choice='choice_2')) + world.css_check(inputfield(course, 'checkbox', choice='choice_0')) + world.css_check(inputfield(course, 'checkbox', choice='choice_2')) else: - world.css_check(inputfield('checkbox', choice='choice_3')) + world.css_check(inputfield(course, 'checkbox', choice='choice_3')) elif problem_type == 'radio': if correctness == 'correct': - world.css_check(inputfield('radio', choice='choice_2')) + world.css_check(inputfield(course, 'radio', choice='choice_2')) else: - world.css_check(inputfield('radio', choice='choice_1')) + world.css_check(inputfield(course, 'radio', choice='choice_1')) elif problem_type == 'string': textvalue = 'correct string' if correctness == 'correct' else 'incorrect' - world.css_fill(inputfield('string'), textvalue) + world.css_fill(inputfield(course, 'string'), textvalue) elif problem_type == 'numerical': textvalue = "pi + 1" if correctness == 'correct' else str(random.randint(-2, 2)) - world.css_fill(inputfield('numerical'), textvalue) + world.css_fill(inputfield(course, 'numerical'), textvalue) elif problem_type == 'formula': textvalue = "x^2+2*x+y" if correctness == 'correct' else 'x^2' - world.css_fill(inputfield('formula'), textvalue) + world.css_fill(inputfield(course, 'formula'), textvalue) elif problem_type == 'script': # Correct answer is any two integers that sum to 10 @@ -213,8 +232,8 @@ def answer_problem(problem_type, correctness): if correctness == 'incorrect': second_addend += random.randint(1, 10) - world.css_fill(inputfield('script', input_num=1), str(first_addend)) - world.css_fill(inputfield('script', input_num=2), str(second_addend)) + world.css_fill(inputfield(course, 'script', input_num=1), str(first_addend)) + world.css_fill(inputfield(course, 'script', input_num=2), str(second_addend)) elif problem_type == 'code': # The fake xqueue server is configured to respond @@ -233,15 +252,37 @@ def answer_problem(problem_type, correctness): choice = "choiceinput_0bc" if correctness == 'correct' else "choiceinput_1bc" world.css_fill( inputfield( + course, problem_type, choice="choiceinput_0_numtolerance_input_0" ), input_value ) - world.css_check(inputfield(problem_type, choice=choice)) + world.css_check(inputfield(course, problem_type, choice=choice)) + elif problem_type == 'image': + offset = 25 if correctness == "correct" else -25 + + def try_click(): + image_selector = "#imageinput_i4x-{0.org}-{0.course}-problem-image_2_1".format(section_loc) + input_selector = "#input_i4x-{0.org}-{0.course}-problem-image_2_1".format(section_loc) + world.browser.execute_script('$("body").on("click", function(event) {console.log(event);})') -def problem_has_answer(problem_type, answer_class): + initial_input = world.css_value(input_selector) + world.wait_for_visible(image_selector) + image = world.css_find(image_selector).first + (image.action_chains + .move_to_element(image._element) + .move_by_offset(offset, offset) + .click() + .perform()) + + world.wait_for(lambda _: world.css_value(input_selector) != initial_input) + + world.retry_on_exception(try_click) + + +def problem_has_answer(course, problem_type, answer_class): if problem_type == "drop down": if answer_class == 'blank': assert world.is_css_not_present('option[selected="true"]') @@ -252,52 +293,52 @@ def problem_has_answer(problem_type, answer_class): elif problem_type == "multiple choice": if answer_class == 'correct': - assert_checked('multiple choice', ['choice_2']) + assert_checked(course, 'multiple choice', ['choice_2']) elif answer_class == 'incorrect': - assert_checked('multiple choice', ['choice_1']) + assert_checked(course, 'multiple choice', ['choice_1']) else: - assert_checked('multiple choice', []) + assert_checked(course, 'multiple choice', []) elif problem_type == "checkbox": if answer_class == 'correct': - assert_checked('checkbox', ['choice_0', 'choice_2']) + assert_checked(course, 'checkbox', ['choice_0', 'choice_2']) elif answer_class == 'incorrect': - assert_checked('checkbox', ['choice_3']) + assert_checked(course, 'checkbox', ['choice_3']) else: - assert_checked('checkbox', []) + assert_checked(course, 'checkbox', []) elif problem_type == "radio": if answer_class == 'correct': - assert_checked('radio', ['choice_2']) + assert_checked(course, 'radio', ['choice_2']) elif answer_class == 'incorrect': - assert_checked('radio', ['choice_1']) + assert_checked(course, 'radio', ['choice_1']) else: - assert_checked('radio', []) + assert_checked(course, 'radio', []) elif problem_type == 'string': if answer_class == 'blank': expected = '' else: expected = 'correct string' if answer_class == 'correct' else 'incorrect' - assert_textfield('string', expected) + assert_textfield(course, 'string', expected) elif problem_type == 'formula': if answer_class == 'blank': expected = '' else: expected = "x^2+2*x+y" if answer_class == 'correct' else 'x^2' - assert_textfield('formula', expected) + assert_textfield(course, 'formula', expected) elif problem_type in ("radio_text", "checkbox_text"): if answer_class == 'blank': expected = ('', '') - assert_choicetext_values(problem_type, (), expected) + assert_choicetext_values(course, problem_type, (), expected) elif answer_class == 'incorrect': expected = ('5', '') - assert_choicetext_values(problem_type, ["choiceinput_1bc"], expected) + assert_choicetext_values(course, problem_type, ["choiceinput_1bc"], expected) else: expected = ('8', '') - assert_choicetext_values(problem_type, ["choiceinput_0bc"], expected) + assert_choicetext_values(course, problem_type, ["choiceinput_0bc"], expected) else: # The other response types use random data, @@ -307,7 +348,7 @@ def problem_has_answer(problem_type, answer_class): pass -def add_problem_to_course(course, problem_type, extraMeta=None): +def add_problem_to_course(course, problem_type, extra_meta=None): ''' Add a problem to the course we have created using factories. ''' @@ -318,21 +359,23 @@ def add_problem_to_course(course, problem_type, extraMeta=None): factory_dict = PROBLEM_DICT[problem_type] problem_xml = factory_dict['factory'].build_xml(**factory_dict['kwargs']) metadata = {'rerandomize': 'always'} if not 'metadata' in factory_dict else factory_dict['metadata'] - if extraMeta: - metadata = dict(metadata, **extraMeta) + if extra_meta: + metadata = dict(metadata, **extra_meta) # Create a problem item using our generated XML # We set rerandomize=always in the metadata so that the "Reset" button # will appear. category_name = "problem" - return world.ItemFactory.create(parent_location=section_location(course), - category=category_name, - display_name=str(problem_type), - data=problem_xml, - metadata=metadata) + return world.ItemFactory.create( + parent_location=section_location(course), + category=category_name, + display_name=str(problem_type), + data=problem_xml, + metadata=metadata + ) -def inputfield(problem_type, choice=None, input_num=1): +def inputfield(course, problem_type, choice=None, input_num=1): """ Return the css selector for `problem_type`. For example, if problem_type is 'string', return the text field for the string problem in the test course. @@ -340,14 +383,20 @@ def inputfield(problem_type, choice=None, input_num=1): `choice` is the name of the checkbox input in a group of checkboxes. """ - sel = ("input#input_i4x-edx-model_course-problem-%s_2_%s" % - (problem_type.replace(" ", "_"), str(input_num))) + section_loc = section_location(course) - # this is necessary due to naming requirement for this problem type + # this is necessary due to naming requirement for this problem type if problem_type in ("radio_text", "checkbox_text"): - sel = "input#i4x-edx-model_course-problem-{0}_2_{1}".format( - problem_type.replace(" ", "_"), str(input_num) - ) + selector_template = "input#i4x-{org}-{course}-problem-{ptype}_2_{input}" + else: + selector_template = "input#input_i4x-{org}-{course}-problem-{ptype}_2_{input}" + + sel = selector_template.format( + org=section_loc.org, + course=section_loc.course, + ptype=problem_type.replace(" ", "_"), + input=input_num, + ) if choice is not None: base = "_choice_" if problem_type == "multiple choice" else "_" @@ -360,7 +409,7 @@ def inputfield(problem_type, choice=None, input_num=1): return sel -def assert_checked(problem_type, choices): +def assert_checked(course, problem_type, choices): ''' Assert that choice names given in *choices* are the only ones checked. @@ -371,7 +420,7 @@ def assert_checked(problem_type, choices): all_choices = ['choice_0', 'choice_1', 'choice_2', 'choice_3'] for this_choice in all_choices: def check_problem(): - element = world.css_find(inputfield(problem_type, choice=this_choice)) + element = world.css_find(inputfield(course, problem_type, choice=this_choice)) if this_choice in choices: assert element.checked else: @@ -379,12 +428,12 @@ def check_problem(): world.retry_on_exception(check_problem) -def assert_textfield(problem_type, expected_text, input_num=1): - element_value = world.css_value(inputfield(problem_type, input_num=input_num)) +def assert_textfield(course, problem_type, expected_text, input_num=1): + element_value = world.css_value(inputfield(course, problem_type, input_num=input_num)) assert element_value == expected_text -def assert_choicetext_values(problem_type, choices, expected_values): +def assert_choicetext_values(course, problem_type, choices, expected_values): """ Asserts that only the given choices are checked, and given text fields have a desired value @@ -397,7 +446,7 @@ def assert_choicetext_values(problem_type, choices, expected_values): "choiceinput_1_numtolerance_input_0" ] for this_choice in all_choices: - element = world.css_find(inputfield(problem_type, choice=this_choice)) + element = world.css_find(inputfield(course, problem_type, choice=this_choice)) if this_choice in choices: assert element.checked @@ -405,6 +454,6 @@ def assert_choicetext_values(problem_type, choices, expected_values): assert not element.checked for (name, expected) in zip(all_inputs, expected_values): - element = world.css_find(inputfield(problem_type, name)) + element = world.css_find(inputfield(course, problem_type, name)) # Remove any trailing spaces that may have been added assert element.value.strip() == expected diff --git a/lms/djangoapps/courseware/features/registration.py b/lms/djangoapps/courseware/features/registration.py index b54416c66174..d773afdba4ee 100644 --- a/lms/djangoapps/courseware/features/registration.py +++ b/lms/djangoapps/courseware/features/registration.py @@ -7,8 +7,7 @@ @step('I register for the course "([^"]*)"$') def i_register_for_the_course(_step, course): - cleaned_name = world.scenario_dict['COURSE'].display_name.replace(' ', '_') - url = django_url('courses/%s/%s/%s/about' % (world.scenario_dict['COURSE'].org, course, cleaned_name)) + url = django_url('courses/%s/about' % world.scenario_dict['COURSE'].id) world.browser.visit(url) world.css_click('section.intro a.register') diff --git a/lms/djangoapps/courseware/features/video.py b/lms/djangoapps/courseware/features/video.py index 57fc8a0a9ab1..6babd602a5fe 100644 --- a/lms/djangoapps/courseware/features/video.py +++ b/lms/djangoapps/courseware/features/video.py @@ -2,7 +2,7 @@ from lettuce import world, step from lettuce.django import django_url -from common import i_am_registered_for_the_course, section_location +from common import i_am_registered_for_the_course, section_location, visit_scenario_item from django.utils.translation import ugettext as _ ############### ACTIONS #################### @@ -28,12 +28,7 @@ def view_video(_step, player_mode): # Make sure we have a video add_video_to_course(coursenum, player_mode.lower()) - chapter_name = world.scenario_dict['SECTION'].display_name.replace(" ", "_") - section_name = chapter_name - url = django_url('/courses/%s/%s/%s/courseware/%s/%s' % - (world.scenario_dict['COURSE'].org, world.scenario_dict['COURSE'].number, world.scenario_dict['COURSE'].display_name.replace(' ', '_'), - chapter_name, section_name,)) - world.browser.visit(url) + visit_scenario_item('SECTION') def add_video_to_course(course, player_mode): diff --git a/lms/djangoapps/courseware/features/word_cloud.py b/lms/djangoapps/courseware/features/word_cloud.py index 5ef2931ee4dd..2fb4f2eee991 100644 --- a/lms/djangoapps/courseware/features/word_cloud.py +++ b/lms/djangoapps/courseware/features/word_cloud.py @@ -4,7 +4,7 @@ from lettuce import world, step from lettuce.django import django_url -from common import i_am_registered_for_the_course, section_location +from common import i_am_registered_for_the_course, section_location, visit_scenario_item @step('I view the word cloud and it has rendered') @@ -18,16 +18,7 @@ def view_word_cloud(_step): i_am_registered_for_the_course(_step, coursenum) add_word_cloud_to_course(coursenum) - chapter_name = world.scenario_dict['SECTION'].display_name.replace( - " ", "_") - section_name = chapter_name - url = django_url('/courses/%s/%s/%s/courseware/%s/%s' % ( - world.scenario_dict['COURSE'].org, - world.scenario_dict['COURSE'].number, - world.scenario_dict['COURSE'].display_name.replace(' ', '_'), - chapter_name, section_name,) - ) - world.browser.visit(url) + visit_scenario_item('SECTION') @step('I press the Save button') diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 798c40f6bd3c..90b5b28b55e1 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -10,7 +10,7 @@ -e git+https://github.com/edx/django-staticfiles.git@6d2504e5c8#egg=django-staticfiles -e git+https://github.com/edx/django-pipeline.git@88ec8a011e481918fdc9d2682d4017c835acd8be#egg=django-pipeline -e git+https://github.com/edx/django-wiki.git@41815e2ef1b0323f92900f8e60711b0f0c37766b#egg=django-wiki --e git+https://github.com/edx/lettuce.git@503fe2d2599290c45b021d6c424ab5ea899e42be#egg=lettuce +-e git+https://github.com/gabrielfalcao/lettuce.git@cccc3978ad2df82a78b6f9648fe2e9baddd22f88#egg=lettuce -e git+https://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev -e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk