From 3257a7baf2fdcaf2e12832d8e4bb2d53e28efb51 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Sun, 6 Oct 2013 11:58:04 -0400 Subject: [PATCH] Refactored navigation feature Fixed grading tests --- .../contentstore/features/common.py | 34 +-- .../component_settings_editor_helpers.py | 16 +- .../contentstore/features/grading.py | 38 +-- .../contentstore/features/video-editor.py | 33 +-- common/djangoapps/terrain/course_helpers.py | 12 +- common/djangoapps/terrain/ui_helpers.py | 49 +++- .../courseware/features/navigation.feature | 23 +- .../courseware/features/navigation.py | 216 +++++++++--------- 8 files changed, 236 insertions(+), 185 deletions(-) diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 1cc35b761d..992de9301c 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -2,7 +2,7 @@ # pylint: disable=W0621 from lettuce import world, step -from nose.tools import assert_true, assert_in, assert_false # pylint: disable=E0611 +from nose.tools import assert_true, assert_equal, assert_in, assert_false # pylint: disable=E0611 from auth.authz import get_user_by_email, get_course_groupname_for_role from django.conf import settings @@ -64,32 +64,16 @@ def select_new_course(_step, whom): @step(u'I press the "([^"]*)" notification button$') def press_the_notification_button(_step, name): - # TODO: fix up this code. Selenium is not dealing well with css transforms, - # as it thinks that the notification and the buttons are always visible - # First wait for the notification to pop up - notification_css = 'div#page-notification div.wrapper-notification' - world.wait_for_visible(notification_css) - - # You would think that the above would have worked, but it doesn't. - # Brute force wait for now. - world.wait(.5) - - # Now make sure the button is there + # Because the notification uses a CSS transition, + # Selenium will always report it as being visible. + # This makes it very difficult to successfully click + # the "Save" button at the UI level. + # Instead, we use JavaScript to reliably click + # the button. btn_css = 'div#page-notification a.action-%s' % name.lower() - world.wait_for_visible(btn_css) - - # You would think that the above would have worked, but it doesn't. - # Brute force wait for now. - world.wait(.5) - - if world.is_firefox(): - # This is done to explicitly make the changes save on firefox. - # It will remove focus from the previously focused element - world.trigger_event(btn_css, event='focus') - world.browser.execute_script("$('{}').click()".format(btn_css)) - else: - world.css_click(btn_css) + world.trigger_event(btn_css, event='focus') + world.browser.execute_script("$('{}').click()".format(btn_css)) world.wait_for_ajax_complete() diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index 976cb3b21c..9881290eba 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -2,7 +2,7 @@ #pylint: disable=C0111 from lettuce import world -from nose.tools import assert_equal # pylint: disable=E0611 +from nose.tools import assert_equal, assert_true # pylint: disable=E0611 from terrain.steps import reload_the_page @@ -12,9 +12,13 @@ def create_component_instance(step, component_button_css, category, has_multiple_templates=True): click_new_component_button(step, component_button_css) + if category in ('problem', 'html'): + def animation_done(_driver): - return world.browser.evaluate_script("$('div.new-component').css('display')") == 'none' + script = "$('div.new-component').css('display')" + return world.browser.evaluate_script(script) == 'none' + world.wait_for(animation_done) if has_multiple_templates: @@ -23,10 +27,7 @@ def create_component_instance(step, component_button_css, category, if category in ('video',): world.wait_for_xmodule() - assert_equal( - 1, - len(world.css_find(expected_css)), - "Component instance with css {css} was not created successfully".format(css=expected_css)) + assert_true(world.is_css_present(expected_css)) @world.absorb @@ -34,7 +35,8 @@ def click_new_component_button(step, component_button_css): step.given('I have clicked the new unit button') world.wait_for_requirejs( ["jquery", "js/models/course", "coffee/src/models/module", - "coffee/src/views/unit", "jquery.ui"]) + "coffee/src/views/unit", "jquery.ui"] + ) world.css_click(component_button_css) diff --git a/cms/djangoapps/contentstore/features/grading.py b/cms/djangoapps/contentstore/features/grading.py index 541fc6bbd4..a12041b96d 100644 --- a/cms/djangoapps/contentstore/features/grading.py +++ b/cms/djangoapps/contentstore/features/grading.py @@ -6,7 +6,7 @@ from common import * from terrain.steps import reload_the_page from selenium.common.exceptions import ( InvalidElementStateException, WebDriverException) -from nose.tools import assert_in, assert_not_in # pylint: disable=E0611 +from nose.tools import assert_in, assert_not_in, assert_equal, assert_not_equal # pylint: disable=E0611 @step(u'I am viewing the grading settings') @@ -36,7 +36,7 @@ def delete_grade(step): def view_grade_slider(step, how_many): grade_slider_css = '.grade-specific-bar' all_grades = world.css_find(grade_slider_css) - assert len(all_grades) == int(how_many) + assert_equal(len(all_grades), int(how_many)) @step(u'I move a grading section') @@ -51,7 +51,7 @@ def confirm_change(step): range_css = '.range' all_ranges = world.css_find(range_css) for i in range(len(all_ranges)): - assert world.css_html(range_css, index=i) != '0-50' + assert_not_equal(world.css_html(range_css, index=i), '0-50') @step(u'I change assignment type "([^"]*)" to "([^"]*)"$') @@ -59,7 +59,7 @@ def change_assignment_name(step, old_name, new_name): name_id = '#course-grading-assignment-name' index = get_type_index(old_name) f = world.css_find(name_id)[index] - assert index != -1 + assert_not_equal(index, -1) for count in range(len(old_name)): f._element.send_keys(Keys.END, Keys.BACK_SPACE) f._element.send_keys(new_name) @@ -78,7 +78,10 @@ def main_course_page(step): def see_assignment_name(step, do_not, name): assignment_menu_css = 'ul.menu > li > a' # First assert that it is there, make take a bit to redraw - assert world.css_find(assignment_menu_css) + assert_true( + world.css_find(assignment_menu_css), + msg="Could not find assignment menu" + ) assignment_menu = world.css_find(assignment_menu_css) allnames = [item.html for item in assignment_menu] @@ -113,7 +116,7 @@ def populate_course(step): def changes_not_persisted(step): reload_the_page(step) name_id = '#course-grading-assignment-name' - assert(world.css_value(name_id) == 'Homework') + assert_equal(world.css_value(name_id), 'Homework') @step(u'I see the assignment type "(.*)"$') @@ -121,7 +124,7 @@ def i_see_the_assignment_type(_step, name): assignment_css = '#course-grading-assignment-name' assignments = world.css_find(assignment_css) types = [ele['value'] for ele in assignments] - assert name in types + assert_in(name, types) @step(u'I change the highest grade range to "(.*)"$') @@ -135,15 +138,15 @@ def change_grade_range(_step, range_name): def i_see_highest_grade_range(_step, range_name): range_css = 'span.letter-grade' grade = world.css_find(range_css).first - assert grade.value == range_name, "{0} != {1}".format(grade.value, range_name) + assert_equal(grade.value, range_name) @step(u'I cannot edit the "Fail" grade range$') def cannot_edit_fail(_step): range_css = 'span.letter-grade' ranges = world.css_find(range_css) - assert len(ranges) == 2 - assert ranges.last.value != 'Failure' + assert_equal(len(ranges), 2) + assert_not_equal(ranges.last.value, 'Failure') # try to change the grade range -- this should throw an exception try: @@ -153,14 +156,23 @@ def cannot_edit_fail(_step): # check to be sure that nothing has changed ranges = world.css_find(range_css) - assert len(ranges) == 2 - assert ranges.last.value != 'Failure' + assert_equal(len(ranges), 2) + assert_not_equal(ranges.last.value, 'Failure') @step(u'I change the grace period to "(.*)"$') def i_change_grace_period(_step, grace_period): grace_period_css = '#course-grading-graceperiod' ele = world.css_find(grace_period_css).first + + # Sometimes it takes a moment for the JavaScript + # to populate the field. If we don't wait for + # this to happen, then we can end up with + # an invalid value (e.g. "00:0048:00") + # which prevents us from saving. + assert_true(world.css_has_value(grace_period_css, "00:00", allow_blank=False)) + + # Set the new grace period ele.value = grace_period @@ -168,7 +180,7 @@ def i_change_grace_period(_step, grace_period): def the_grace_period_is(_step, grace_period): grace_period_css = '#course-grading-graceperiod' ele = world.css_find(grace_period_css).first - assert ele.value == grace_period + assert_equal(ele.value, grace_period) def get_type_index(name): diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py index 00f3c2535d..56b1610ce6 100644 --- a/cms/djangoapps/contentstore/features/video-editor.py +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -32,18 +32,21 @@ def shows_captions(_step, show_captions): @step('I see the correct video settings and default values$') def correct_video_settings(_step): - world.verify_all_setting_entries([['Display Name', 'Video', False], - ['Download Track', '', False], - ['Download Video', '', False], - ['End Time', '0', False], - ['HTML5 Timed Transcript', '', False], - ['Show Captions', 'True', False], - ['Start Time', '0', False], - ['Video Sources', '', False], - ['Youtube ID', 'OEoXaMPEzfM', False], - ['Youtube ID for .75x speed', '', False], - ['Youtube ID for 1.25x speed', '', False], - ['Youtube ID for 1.5x speed', '', False]]) + expected_entries = [ + ['Display Name', 'Video', False], + ['Download Track', '', False], + ['Download Video', '', False], + ['End Time', '0', False], + ['HTML5 Timed Transcript', '', False], + ['Show Captions', 'True', False], + ['Start Time', '0', False], + ['Video Sources', '', False], + ['Youtube ID', 'OEoXaMPEzfM', False], + ['Youtube ID for .75x speed', '', False], + ['Youtube ID for 1.25x speed', '', False], + ['Youtube ID for 1.5x speed', '', False] + ] + world.verify_all_setting_entries(expected_entries) @step('my video display name change is persisted on save$') @@ -52,4 +55,8 @@ def video_name_persisted(step): reload_the_page(step) world.wait_for_xmodule() world.edit_component() - world.verify_setting_entry(world.get_setting_entry('Display Name'), 'Display Name', '3.4', True) + + world.verify_setting_entry( + world.get_setting_entry('Display Name'), + 'Display Name', '3.4', True + ) diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py index 22222d30a4..1d41e880ea 100644 --- a/common/djangoapps/terrain/course_helpers.py +++ b/common/djangoapps/terrain/course_helpers.py @@ -41,13 +41,13 @@ def log_in(username='robot', password='test', email='robot@edx.org', name='Robot @world.absorb -def register_by_course_id(course_id, is_staff=False): - create_user('robot', 'password') - u = User.objects.get(username='robot') +def register_by_course_id(course_id, username='robot', password='test', is_staff=False): + create_user(username, password) + user = User.objects.get(username=username) if is_staff: - u.is_staff = True - u.save() - CourseEnrollment.enroll(u, course_id) + user.is_staff = True + user.save() + CourseEnrollment.enroll(user, course_id) @world.absorb diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 3fb5dfce67..abb2c6c5c9 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -34,7 +34,7 @@ def wait(seconds): def wait_for_js_variable_truthy(variable): """ Using Selenium's `execute_async_script` function, poll the Javascript - enviornment until the given variable is defined and truthy. This process + environment until the given variable is defined and truthy. This process guards against page reloads, and seamlessly retries on the next page. """ js = """ @@ -194,8 +194,51 @@ def is_css_not_present(css_selector, wait_time=5): @world.absorb -def css_has_text(css_selector, text, index=0): - return world.css_text(css_selector, index=index) == text +def css_has_text(css_selector, text, index=0, + strip=False, allow_blank=True): + """ + Return a boolean indicating whether the element with `css_selector` + has `text`. + + If `strip` is True, strip whitespace at beginning/end of both + strings before comparing. + + If `allow_blank` is False, wait for the element to have non-empty + text before making the assertion. This is useful for elements + that are populated by JavaScript after the page loads. + + If there are multiple elements matching the css selector, + use `index` to indicate which one. + """ + + if not allow_blank: + world.wait_for(lambda _: world.css_text(css_selector, index=index)) + + actual_text = world.css_text(css_selector, index=index) + + if strip: + actual_text = actual_text.strip() + text = text.strip() + + return actual_text == text + + +@world.absorb +def css_has_value(css_selector, value, index=0, allow_blank=False): + """ + Return a boolean indicating whether the element with + `css_selector` has the specified `value`. + + If `allow_blank` is False, wait for the element to have + a value that is a non-empty string. + + If there are multiple elements matching the css selector, + use `index` to indicate which one. + """ + if not allow_blank: + world.wait_for(lambda _: world.css_value(css_selector, index=index)) + + return world.css_value(css_selector, index=index) == value @world.absorb diff --git a/lms/djangoapps/courseware/features/navigation.feature b/lms/djangoapps/courseware/features/navigation.feature index 69a7a5a4a4..ad84019725 100644 --- a/lms/djangoapps/courseware/features/navigation.feature +++ b/lms/djangoapps/courseware/features/navigation.feature @@ -1,26 +1,27 @@ @shard_1 Feature: LMS.Navigate Course As a student in an edX course - In order to view the course properly + In order to access courseware I want to be able to navigate through the content Scenario: I can navigate to a section Given I am viewing a course with multiple sections - When I click on section "2" - Then I should see the content of section "2" + When I navigate to a section + Then I see the content of the section Scenario: I can navigate to subsections Given I am viewing a section with multiple subsections - When I click on subsection "2" - Then I should see the content of subsection "2" + When I navigate to a subsection + Then I see the content of the subsection Scenario: I can navigate to sequences Given I am viewing a section with multiple sequences - When I click on sequence "2" - Then I should see the content of sequence "2" + When I navigate to an item in a sequence + Then I see the content of the sequence item - Scenario: I can go back to where I was after I log out and back in + Scenario: I can return to the last section I visited Given I am viewing a course with multiple sections - When I click on section "2" - And I return later - Then I should see that I was most recently in section "2" + When I navigate to a section + And I see the content of the section + And I return to the courseware + Then I see that I was most recently in the subsection diff --git a/lms/djangoapps/courseware/features/navigation.py b/lms/djangoapps/courseware/features/navigation.py index 18f66cc1d4..b7baf6d5d0 100644 --- a/lms/djangoapps/courseware/features/navigation.py +++ b/lms/djangoapps/courseware/features/navigation.py @@ -2,37 +2,39 @@ #pylint: disable=W0621 from lettuce import world, step -from django.contrib.auth.models import User -from lettuce.django import django_url -from student.models import CourseEnrollment from common import course_id, course_location from problems_setup import PROBLEM_DICT - -TEST_SECTION_NAME = 'Test Section' -TEST_SUBSECTION_NAME = 'Test Subsection' +from nose.tools import assert_in @step(u'I am viewing a course with multiple sections') def view_course_multiple_sections(step): create_course() - # Add a section to the course to contain problems - section1 = world.ItemFactory.create(parent_location=course_location(world.scenario_dict['COURSE'].number), - display_name=section_name(1)) - # Add a section to the course to contain problems - section2 = world.ItemFactory.create(parent_location=course_location(world.scenario_dict['COURSE'].number), - display_name=section_name(2)) + section1 = world.ItemFactory.create( + parent_location=course_location(world.scenario_dict['COURSE'].number), + display_name="Test Section 1" + ) - place1 = world.ItemFactory.create(parent_location=section1.location, - category='sequential', - display_name=subsection_name(1)) + section2 = world.ItemFactory.create( + parent_location=course_location(world.scenario_dict['COURSE'].number), + display_name="Test Section 2" + ) - place2 = world.ItemFactory.create(parent_location=section2.location, - category='sequential', - display_name=subsection_name(2)) + place1 = world.ItemFactory.create( + parent_location=section1.location, + category='sequential', + display_name="Test Subsection 1" + ) - add_problem_to_course_section('model_course', 'multiple choice', place1.location) - add_problem_to_course_section('model_course', 'drop down', place2.location) + place2 = world.ItemFactory.create( + parent_location=section2.location, + category='sequential', + display_name="Test Subsection 2" + ) + + add_problem_to_course_section(place1.location, "Problem 1") + add_problem_to_course_section(place2.location, "Problem 2") create_user_and_visit_course() @@ -41,19 +43,24 @@ def view_course_multiple_sections(step): def view_course_multiple_subsections(step): create_course() - # Add a section to the course to contain problems - section1 = world.ItemFactory.create(parent_location=course_location(world.scenario_dict['COURSE'].number), - display_name=section_name(1)) + section1 = world.ItemFactory.create( + parent_location=course_location(world.scenario_dict['COURSE'].number), + display_name="Test Section 1" + ) - place1 = world.ItemFactory.create(parent_location=section1.location, - category='sequential', - display_name=subsection_name(1)) + place1 = world.ItemFactory.create( + parent_location=section1.location, + category='sequential', + display_name="Test Subsection 1" + ) - place2 = world.ItemFactory.create(parent_location=section1.location, - display_name=subsection_name(2)) + place2 = world.ItemFactory.create( + parent_location=section1.location, + display_name="Test Subsection 2" + ) - add_problem_to_course_section('model_course', 'multiple choice', place1.location) - add_problem_to_course_section('model_course', 'drop down', place2.location) + add_problem_to_course_section(place1.location, "Problem 3") + add_problem_to_course_section(place2.location, "Problem 4") create_user_and_visit_course() @@ -61,121 +68,116 @@ def view_course_multiple_subsections(step): @step(u'I am viewing a section with multiple sequences') def view_course_multiple_sequences(step): create_course() - # Add a section to the course to contain problems - section1 = world.ItemFactory.create(parent_location=course_location(world.scenario_dict['COURSE'].number), - display_name=section_name(1)) - place1 = world.ItemFactory.create(parent_location=section1.location, - category='sequential', - display_name=subsection_name(1)) + section1 = world.ItemFactory.create( + parent_location=course_location(world.scenario_dict['COURSE'].number), + display_name="Test Section 1" + ) - add_problem_to_course_section('model_course', 'multiple choice', place1.location) - add_problem_to_course_section('model_course', 'drop down', place1.location) + place1 = world.ItemFactory.create( + parent_location=section1.location, + category='sequential', + display_name="Test Subsection 1" + ) + + add_problem_to_course_section(place1.location, "Problem 5") + add_problem_to_course_section(place1.location, "Problem 6") create_user_and_visit_course() -@step(u'I click on section "([^"]*)"$') -def click_on_section(step, section): +@step(u'I navigate to a section') +def when_i_navigate_to_a_section(step): section_css = 'h3[tabindex="-1"]' world.css_click(section_css) - subid = "ui-accordion-accordion-panel-{}".format(str(int(section) - 1)) + subid = "ui-accordion-accordion-panel-1" + world.wait_for_visible("#" + subid) + subsection_css = "ul.ui-accordion-content-active[id='{}'] > li > a".format(subid) world.css_click(subsection_css) -@step(u'I click on subsection "([^"]*)"$') -def click_on_subsection(step, subsection): +@step(u'I navigate to a subsection') +def when_i_navigate_to_a_subsection(step): subsection_css = 'ul[id="ui-accordion-accordion-panel-0"]> li > a' - world.css_click(subsection_css, index=(int(subsection) - 1)) + world.css_click(subsection_css, index=1) -@step(u'I click on sequence "([^"]*)"$') -def click_on_sequence(step, sequence): - sequence_css = 'a[data-element="%s"]' % sequence +@step(u'I navigate to an item in a sequence') +def when_i_navigate_to_an_item_in_a_sequence(step): + sequence_css = 'a[data-element="2"]' world.css_click(sequence_css) -@step(u'I should see the content of (?:sub)?section "([^"]*)"$') -def see_section_content(step, section): - world.wait(0.5) - if section == "2": - text = 'The correct answer is Option 2' - elif section == "1": - text = 'The correct answer is Choice 3' - step.given('I should see "' + text + '" somewhere on the page') +@step(u'I see the content of the section') +def then_i_see_the_content_of_the_section(step): + wait_for_problem('PROBLEM 2') -@step(u'I should see the content of sequence "([^"]*)"$') -def see_sequence_content(step, sequence): - step.given('I should see the content of section "2"') +@step(u'I see the content of the subsection') +def then_i_see_the_content_of_the_subsection(step): + wait_for_problem('PROBLEM 4') -@step(u'I return later') -def return_to_course(step): - step.given('I visit the homepage') +@step(u'I see the content of the sequence item') +def then_i_see_the_content_of_the_sequence_item(step): + wait_for_problem('PROBLEM 6') + + +@step(u'I return to the courseware') +def and_i_return_to_the_courseware(step): + world.visit('/') world.click_link("View Course") world.click_link("Courseware") -@step(u'I should see that I was most recently in section "([^"]*)"$') -def see_recent_section(step, section): - step.given('I should see "You were most recently in %s" somewhere on the page' % subsection_name(int(section))) - -##################### -# HELPERS -##################### - - -def section_name(section): - return TEST_SECTION_NAME + str(section) - - -def subsection_name(section): - return TEST_SUBSECTION_NAME + str(section) +@step(u'I see that I was most recently in the subsection') +def then_i_see_that_i_was_most_recently_in_the_subsection(step): + message = world.css_text('section.course-content > p') + assert_in("You were most recently in Test Subsection 2", message) def create_course(): world.clear_courses() - - world.scenario_dict['COURSE'] = world.CourseFactory.create(org='edx', number='model_course', display_name='Test Course') + world.scenario_dict['COURSE'] = world.CourseFactory.create( + org='edx', number='999', display_name='Test Course' + ) def create_user_and_visit_course(): - world.create_user('robot', 'test') - u = User.objects.get(username='robot') - - CourseEnrollment.enroll(u, course_id(world.scenario_dict['COURSE'].number)) - - world.log_in(username='robot', password='test') - chapter_name = (TEST_SECTION_NAME + "1").replace(" ", "_") - section_name = (TEST_SUBSECTION_NAME + "1").replace(" ", "_") - url = django_url('/courses/edx/model_course/Test_Course/courseware/%s/%s' % - (chapter_name, section_name)) - - world.browser.visit(url) + world.register_by_course_id('edx/999/Test_Course') + world.log_in() + world.visit('/courses/edx/999/Test_Course/courseware/') -def add_problem_to_course_section(course, problem_type, parent_location, extraMeta=None): - ''' - Add a problem to the course we have created using factories. - ''' +def add_problem_to_course_section(parent_location, display_name): + """ + Add a problem to the course at `parent_location` (a `Location` instance) - assert(problem_type in PROBLEM_DICT) + `display_name` is the name of the problem to display, which + is useful to identify which problem we're looking at. + """ # Generate the problem XML using capa.tests.response_xml_factory - factory_dict = PROBLEM_DICT[problem_type] + # Since this is just a placeholder, we always use multiple choice. + factory_dict = PROBLEM_DICT['multiple choice'] 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) - # Create a problem item using our generated XML - # We set rerandomize=always in the metadata so that the "Reset" button - # will appear. - world.ItemFactory.create(parent_location=parent_location, - category='problem', - display_name=str(problem_type), - data=problem_xml, - metadata=metadata) + # Add the problem + world.ItemFactory.create( + parent_location=parent_location, + category='problem', + display_name=display_name, + data=problem_xml + ) + + +def wait_for_problem(display_name): + """ + Wait for the problem with `display_name` to appear on the page. + """ + wait_func = lambda _: world.css_has_text( + 'h2.problem-header', display_name, strip=True + ) + world.wait_for(wait_func)